diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a0d98c24f..d9e6c9db1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -169,6 +169,28 @@ jobs: - name: Run full CI suite run: HARDEN=${{ matrix.harden }} CC=${{ matrix.cc }} AE_SWEEP_SHARD=${{ matrix.shard }} CI_SWEEP_UNIT=${{ matrix.unit }} AE_SWEEP_KEEP_C=${{ matrix.name == 'Linux / GCC' && '1' || '0' }} make ${{ matrix.target || 'ci' }} + # The cross-version ABI matrix (#2957, docs/abi-policy.md): this tree's + # libraries and hosts against the latest published release's, on the + # toolchain `make ci` just built. One Linux and one macOS leg (the + # release workflow runs it on every platform it ships); without the + # release in AETHER_ABI_OLD_HOME the sweep above skipped it. + - name: ABI matrix against the latest release + if: matrix.name == 'Linux / GCC' || matrix.name == 'macOS / Clang (ARM64)' + timeout-minutes: 15 + env: + GH_TOKEN: ${{ github.token }} + run: | + case "${{ matrix.os }}" in + ubuntu*) target=linux-x86_64 ;; + *) target=macos-arm64 ;; + esac + prev="$RUNNER_TEMP/aether-prev" + mkdir -p "$prev" + gh release download --repo "${{ github.repository }}" --pattern "aether-*-$target.tar.gz" --dir "$RUNNER_TEMP" + tar -xzf "$RUNNER_TEMP"/aether-*-"$target".tar.gz -C "$prev" + echo "against $(cat "$prev/VERSION")" + AETHER_ABI_OLD_HOME="$prev" CC=${{ matrix.cc }} sh tests/integration/abi_matrix/test_abi_matrix.sh + # The .ae corpus at -O0 (what `ae run` compiles at) against -O2 (what the # sweep in `make ci` compiles at), diffing stdout and exit code (#2488). # One leg is enough to catch a program that behaves differently at -O0, @@ -1382,6 +1404,64 @@ jobs: sh "tests/integration/$t/test_$t.sh" done + # std.cryptography's secret-key operations must not branch on, or index + # memory with, a secret (#2600, #1298). The probe marks every secret input + # undefined for Valgrind's memcheck; memcheck reports each conditional jump + # and address computed from one, and any such report fails the job. The + # other legs have no valgrind and run the probe as a known-answer test. + # + # The same job, on the same build, runs the conformance corpora of the pure + # TLS stack (#1298), each pinned to a commit and a checksum and fetched + # into a cache rather than kept in the repository: x509-limbo and the + # bettertls suite it imports, against tls13_cert's path validation. Each + # step reports on its own; tests/conformance/*/expected.txt lists every + # case that is skipped or does not agree, with the reason. + ci-crypto-ct: + name: Crypto constant-time (Valgrind) and conformance corpora + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v4 + + - name: Install toolchain + run: sudo rm -f /etc/apt/sources.list.d/google-chrome.list; sudo apt-get update && sudo apt-get install -y gcc make bc valgrind + + - name: Build the toolchain + run: make -j"$(nproc)" compiler ae stdlib + + - name: Constant-time probe under memcheck + run: CT_REQUIRE_VALGRIND=1 sh tests/integration/crypto_ct_valgrind/test_crypto_ct_valgrind.sh + + # The corpora are fetched once from main's runs and read by every PR's + # (the key names the pins, so moving one fetches afresh); a miss only + # downloads them again. + - name: Restore the conformance corpora + id: conformance_cache + if: success() || failure() + uses: actions/cache/restore@v4 + with: + path: ~/.cache/aether-conformance + key: conformance-${{ hashFiles('tests/conformance/*/run.sh') }} + + - name: x509-limbo (C2SP/x509-limbo, pinned) + if: success() || failure() + run: sh tests/conformance/x509_limbo/run.sh limbo + + - name: bettertls (through x509-limbo's import) + if: success() || failure() + run: sh tests/conformance/x509_limbo/run.sh bettertls + + - name: Save the conformance corpora + if: >- + (success() || failure()) + && github.event_name == 'push' && github.ref == 'refs/heads/main' + && steps.conformance_cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: ~/.cache/aether-conformance + key: conformance-${{ hashFiles('tests/conformance/*/run.sh') }} + ci-wasm: name: WebAssembly (Emscripten) runs-on: ubuntu-22.04 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bad3bbcc2..508c075e0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -608,6 +608,44 @@ jobs: make test-install make test-release-archive + # -- The cross-version ABI matrix (#2957, docs/abi-policy.md) --- + # This release's libraries and hosts against the release before it: the + # latest published one, since this one is published only after every + # build leg. The tree names the script, so an older tag that lacks it + # skips the step (the rule at the checkout above). + - name: ABI matrix against the previous release (Unix) + if: matrix.os != 'windows-latest' + env: + GH_TOKEN: ${{ github.token }} + run: | + [ -f tests/integration/abi_matrix/test_abi_matrix.sh ] || exit 0 + prev="$RUNNER_TEMP/aether-prev" + mkdir -p "$prev" + gh release download --repo "${{ github.repository }}" --pattern "aether-*-${{ matrix.target }}.tar.gz" --dir "$RUNNER_TEMP" + tar -xzf "$RUNNER_TEMP"/aether-*-${{ matrix.target }}.tar.gz -C "$prev" + echo "against $(cat "$prev/VERSION")" + AETHER_ABI_OLD_HOME="$prev" sh tests/integration/abi_matrix/test_abi_matrix.sh + + - name: Download the previous release (Windows, ABI matrix) + if: matrix.os == 'windows-latest' + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + if (-not (Test-Path tests/integration/abi_matrix/test_abi_matrix.sh)) { exit 0 } + gh release download --repo "${{ github.repository }}" --pattern "aether-*-windows-x86_64.zip" --dir "$env:RUNNER_TEMP" + $zip = Get-ChildItem "$env:RUNNER_TEMP" -Filter "aether-*-windows-x86_64.zip" | Select-Object -First 1 + Expand-Archive -Path $zip.FullName -DestinationPath "$env:RUNNER_TEMP/aether-prev" + + - name: ABI matrix against the previous release (Windows) + if: matrix.os == 'windows-latest' + shell: msys2 {0} + run: | + [ -f tests/integration/abi_matrix/test_abi_matrix.sh ] || exit 0 + prev="$(cygpath -u "$RUNNER_TEMP")/aether-prev" + echo "against $(cat "$prev/VERSION")" + AETHER_ABI_OLD_HOME="$prev" CC=gcc sh tests/integration/abi_matrix/test_abi_matrix.sh + - name: Strip (Windows) if: matrix.os == 'windows-latest' shell: msys2 {0} diff --git a/Makefile b/Makefile index 745f5fcec..923756e44 100644 --- a/Makefile +++ b/Makefile @@ -880,8 +880,8 @@ endif COMPILER_SRC = compiler/aetherc.c compiler/parser/lexer.c compiler/parser/parser.c compiler/ast.c compiler/analysis/typechecker.c compiler/analysis/contract_eval.c compiler/analysis/derive.c compiler/analysis/actor_reply.c compiler/analysis/missing_return.c compiler/analysis/actor_protocol.c compiler/analysis/free_check.c compiler/analysis/slice_escape.c compiler/analysis/slice_own.c compiler/analysis/hoist.c compiler/analysis/slice_coerce.c compiler/analysis/sandbox_trust.c compiler/analysis/quote.c compiler/aether_defines.c compiler/codegen/codegen.c compiler/codegen/codegen_expr.c compiler/codegen/codegen_stmt.c compiler/codegen/codegen_actor.c compiler/codegen/codegen_func.c compiler/codegen/codegen_schema.c compiler/codegen/codegen_own.c compiler/aether_error.c compiler/aether_module.c compiler/aether_aea.c compiler/analysis/type_inference.c compiler/codegen/optimizer.c compiler/aether_diagnostics.c compiler/aether_strmap.c runtime/actors/aether_message_registry.c lsp/aether_lsp.c COMPILER_LIB_SRC = compiler/parser/lexer.c compiler/parser/parser.c compiler/ast.c compiler/analysis/typechecker.c compiler/analysis/contract_eval.c compiler/analysis/derive.c compiler/analysis/actor_reply.c compiler/analysis/missing_return.c compiler/analysis/actor_protocol.c compiler/analysis/free_check.c compiler/analysis/slice_escape.c compiler/analysis/slice_own.c compiler/analysis/hoist.c compiler/analysis/slice_coerce.c compiler/analysis/sandbox_trust.c compiler/analysis/quote.c compiler/aether_defines.c compiler/codegen/codegen.c compiler/codegen/codegen_expr.c compiler/codegen/codegen_stmt.c compiler/codegen/codegen_actor.c compiler/codegen/codegen_func.c compiler/codegen/codegen_schema.c compiler/codegen/codegen_own.c compiler/aether_error.c compiler/aether_module.c compiler/aether_aea.c compiler/analysis/type_inference.c compiler/codegen/optimizer.c compiler/aether_diagnostics.c compiler/aether_strmap.c runtime/actors/aether_message_registry.c lsp/aether_lsp.c -RUNTIME_SRC = $(SCHEDULER_SRC) runtime/scheduler/scheduler_optimizations.c runtime/scheduler/aether_io_poller_epoll.c runtime/scheduler/aether_io_poller_kqueue.c runtime/scheduler/aether_io_poller_poll.c runtime/config/aether_optimization_config.c runtime/memory/aether_arena.c runtime/memory/aether_pool.c runtime/memory/aether_memory_stats.c runtime/utils/aether_trace.c runtime/utils/aether_spawn.c runtime/utils/aether_bounds_check.c runtime/utils/aether_test.c runtime/memory/aether_arena_optimized.c runtime/aether_runtime_types.c runtime/aether_locale_num.c runtime/utils/aether_cpu_detect.c runtime/utils/aether_simd_vectorized.c runtime/aether_runtime.c runtime/aether_numa.c runtime/aether_sandbox.c runtime/sandbox/spawn_sandboxed_linux.c runtime/sandbox/spawn_sandboxed_bsd.c runtime/sandbox/spawn_sandboxed_stub.c runtime/sandbox/capsicum_autosandbox.c runtime/sandbox/aether_audit.c runtime/aether_shared_map.c runtime/aether_observe.c runtime/aether_schema.c runtime/aether_process_mem.c runtime/aether_host.c runtime/aether_resource_caps.c runtime/libaether_caps.c runtime/actors/aether_send_buffer.c runtime/actors/aether_send_message.c runtime/actors/aether_actor_thread.c runtime/actors/aether_panic.c runtime/actors/aether_unwind.c runtime/aether_callbacks.c -STD_SRC = std/string/aether_string.c std/math/aether_math.c std/net/aether_http.c std/net/aether_http_server.c std/net/aether_http_pool.c std/net/aether_http_park.c std/net/aether_http_evloop.c std/net/aether_net.c std/udp/aether_udp.c std/collections/aether_collections.c std/intmap/aether_intmap.c std/json/aether_json.c std/yaml/aether_yaml.c std/xml/aether_xml.c std/fs/aether_fs.c std/log/aether_log.c std/io/aether_io.c std/os/aether_os.c std/ipc/aether_ipc.c std/mem/aether_mem.c std/cryptography/aether_cryptography.c std/cryptography/aes/aether_aes.c std/zlib/aether_zlib.c std/brotli/aether_brotli.c std/zstd/aether_zstd.c std/lzf/lzf_c.c std/lzf/lzf_d.c std/lzf/aether_lzf.c std/dl/aether_dl.c std/http/middleware/aether_middleware.c std/http/server/h2/aether_h2.c std/http/proxy/aether_proxy_pool.c std/http/proxy/aether_proxy_lb.c std/http/proxy/aether_proxy_breaker.c std/http/proxy/aether_proxy_health.c std/http/proxy/aether_proxy_cache.c std/http/proxy/aether_proxy_opts.c std/http/proxy/aether_proxy_metrics.c std/http/proxy/aether_proxy_middleware.c std/http/script_gateway/aether_script_gateway.c std/bytes/aether_bytes.c std/bytes/cursor/aether_bytes_cursor.c std/strbuilder/aether_strbuilder.c std/config/aether_config.c std/actors/aether_actor_registry.c std/regex/aether_regex.c std/regex/aether_pcre2_vendored.c std/capsicum/aether_capsicum.c std/casper/aether_casper.c std/snapshot/aether_snapshot.c std/sync/aether_sync.c std/audio/aether_audio.c std/worker/aether_worker.c std/alloc/aether_alloc.c std/tracking/aether_tracking.c std/tar/aether_tar.c std/quote/aether_quote.c std/unicode/aether_unicode.c std/unicode/utf8proc/utf8proc.c +RUNTIME_SRC = $(SCHEDULER_SRC) runtime/scheduler/scheduler_optimizations.c runtime/scheduler/aether_io_poller_epoll.c runtime/scheduler/aether_io_poller_kqueue.c runtime/scheduler/aether_io_poller_poll.c runtime/config/aether_optimization_config.c runtime/memory/aether_arena.c runtime/memory/aether_pool.c runtime/memory/aether_memory_stats.c runtime/utils/aether_trace.c runtime/utils/aether_spawn.c runtime/utils/aether_bounds_check.c runtime/utils/aether_test.c runtime/memory/aether_arena_optimized.c runtime/aether_runtime_types.c runtime/aether_locale_num.c runtime/utils/aether_cpu_detect.c runtime/utils/aether_simd_vectorized.c runtime/aether_runtime.c runtime/aether_numa.c runtime/aether_sandbox.c runtime/sandbox/spawn_sandboxed_linux.c runtime/sandbox/spawn_sandboxed_bsd.c runtime/sandbox/spawn_sandboxed_stub.c runtime/sandbox/capsicum_autosandbox.c runtime/sandbox/aether_audit.c runtime/aether_shared_map.c runtime/aether_observe.c runtime/aether_schema.c runtime/aether_process_mem.c runtime/aether_host.c runtime/aether_exec.c runtime/aether_resource_caps.c runtime/libaether_caps.c runtime/actors/aether_send_buffer.c runtime/actors/aether_send_message.c runtime/actors/aether_actor_thread.c runtime/actors/aether_panic.c runtime/actors/aether_unwind.c runtime/actors/aether_supervision.c runtime/aether_callbacks.c +STD_SRC = std/string/aether_string.c std/math/aether_math.c std/net/aether_http.c std/net/aether_tls_trust.c std/net/aether_http_server.c std/net/aether_http_pool.c std/net/aether_http_park.c std/net/aether_http_evloop.c std/net/aether_net.c std/udp/aether_udp.c std/collections/aether_collections.c std/intmap/aether_intmap.c std/json/aether_json.c std/yaml/aether_yaml.c std/xml/aether_xml.c std/fs/aether_fs.c std/log/aether_log.c std/io/aether_io.c std/os/aether_os.c std/ipc/aether_ipc.c std/mem/aether_mem.c std/cryptography/aether_cryptography.c std/cryptography/aes/aether_aes.c std/zlib/aether_zlib.c std/brotli/aether_brotli.c std/zstd/aether_zstd.c std/lzf/lzf_c.c std/lzf/lzf_d.c std/lzf/aether_lzf.c std/dl/aether_dl.c std/http/middleware/aether_middleware.c std/http/server/h2/aether_h2.c std/http/proxy/aether_proxy_pool.c std/http/proxy/aether_proxy_lb.c std/http/proxy/aether_proxy_breaker.c std/http/proxy/aether_proxy_health.c std/http/proxy/aether_proxy_cache.c std/http/proxy/aether_proxy_opts.c std/http/proxy/aether_proxy_metrics.c std/http/proxy/aether_proxy_middleware.c std/http/script_gateway/aether_script_gateway.c std/bytes/aether_bytes.c std/bytes/cursor/aether_bytes_cursor.c std/strbuilder/aether_strbuilder.c std/config/aether_config.c std/actors/aether_actor_registry.c std/regex/aether_regex.c std/regex/aether_pcre2_vendored.c std/capsicum/aether_capsicum.c std/casper/aether_casper.c std/snapshot/aether_snapshot.c std/sync/aether_sync.c std/audio/aether_audio.c std/worker/aether_worker.c std/alloc/aether_alloc.c std/tracking/aether_tracking.c std/tar/aether_tar.c std/quote/aether_quote.c std/unicode/aether_unicode.c std/unicode/utf8proc/utf8proc.c # Stdlib sources that reference scheduler internals (scheduler_io_register, # g_sync_step_actor, current_core_id). Excluded from the compiler binary # because aetherc does not link the runtime scheduler, but included in @@ -938,6 +938,10 @@ TEST_OBJS = $(TEST_SRC:%.c=$(OBJ_DIR)/%.o) $(OBJ_DIR)/tools/ae_version.o: $(VERSION_HEADER) $(OBJ_DIR)/tools/ae.o: $(VERSION_HEADER) $(OBJ_DIR)/compiler/aetherc.o: $(VERSION_HEADER) +# aether_abi.h includes it (#2957): the runtime ABI id the runtime reports +# and the one the code generator writes into each library. +$(OBJ_DIR)/runtime/aether_exec.o: $(VERSION_HEADER) +$(OBJ_DIR)/compiler/codegen/codegen.o: $(VERSION_HEADER) # Test files using TEST() macro system (exclude standalone tests) TEST_SRC = tests/runtime/test_harness.c \ @@ -968,9 +972,18 @@ TEST_SRC = tests/runtime/test_harness.c \ tests/runtime/test_spsc_queue.c \ tests/runtime/test_worksteal_race.c \ tests/runtime/test_ask_relief.c \ + tests/runtime/test_ask_message.c \ + tests/runtime/test_wait_inside_actor.c \ + tests/runtime/test_step_context.c \ + tests/runtime/test_exec_accounts.c \ + tests/runtime/test_abi_layout.c \ tests/runtime/test_sender_move_order.c \ tests/runtime/test_actor_thread_sends.c \ tests/runtime/test_overflow_reentry.c \ + tests/runtime/test_actor_death.c \ + tests/runtime/test_actor_rebirth.c \ + tests/runtime/test_mailbox_bound.c \ + tests/runtime/test_win_mutex_no_debug_info.c \ tests/runtime/test_http_server.c \ tests/runtime/test_http_resolver.c \ tests/runtime/test_resource_caps.c \ @@ -1214,19 +1227,36 @@ compiler: $(BUILD_DIR)/aetherc$(EXE_EXT) $(BUILD_TARGET_STAMP) # the binary other tests were executing at that moment: macOS kills a process # whose text file changed under it, which is a SIGKILL in a test that never # went near the build system. -$(BUILD_DIR)/aetherc$(EXE_EXT): $(COMPILER_OBJS) $(STD_OBJS) $(COLLECTIONS_OBJS) $(OBJ_DIR)/runtime/utils/aether_spawn.o $(OBJ_DIR)/runtime/aether_sandbox.o $(OBJ_DIR)/runtime/aether_resource_caps.o $(OBJ_DIR)/runtime/aether_locale_num.o $(OBJ_DIR)/runtime/aether_callbacks.o $(IO_POLLER_OBJS) $(MANIFEST_OBJ) | $(VERSION_HEADER) $(STDLIB_SYMS_HEADER) $(FRONTEND_ID_HEADER) +# The runtime pieces aetherc links, for this rule, compiler-fast and the +# release compiler alike: compiler-fast kept a list of its own, which lost +# aether_spawn.c and aether_sandbox.c and could not link (#3351), and so +# did the release compiler's, which lost aether_unwind.c once the caps code +# read the execution frames, so `make install` could not link. std.dl keeps +# what every Aether library binds to (aether_runtime_abi), and aether_exec.c +# keeps the host API with it (#3439): the compiler links the std objects +# whole, so it links those three files too. +AETHERC_RUNTIME_SRC = runtime/utils/aether_spawn.c runtime/aether_sandbox.c runtime/sandbox/aether_audit.c runtime/actors/aether_panic.c runtime/actors/aether_unwind.c runtime/aether_resource_caps.c runtime/aether_locale_num.c runtime/aether_callbacks.c runtime/aether_exec.c runtime/aether_host.c runtime/libaether_caps.c +AETHERC_RUNTIME_OBJS = $(patsubst %.c,$(OBJ_DIR)/%.o,$(AETHERC_RUNTIME_SRC)) + +$(BUILD_DIR)/aetherc$(EXE_EXT): $(COMPILER_OBJS) $(STD_OBJS) $(COLLECTIONS_OBJS) $(AETHERC_RUNTIME_OBJS) $(IO_POLLER_OBJS) $(MANIFEST_OBJ) | $(VERSION_HEADER) $(STDLIB_SYMS_HEADER) $(FRONTEND_ID_HEADER) @echo "Linking compiler..." - @$(CC) $(COMPILER_OBJS) $(STD_OBJS) $(COLLECTIONS_OBJS) $(OBJ_DIR)/runtime/utils/aether_spawn.o $(OBJ_DIR)/runtime/aether_sandbox.o $(OBJ_DIR)/runtime/aether_resource_caps.o $(OBJ_DIR)/runtime/aether_locale_num.o $(OBJ_DIR)/runtime/aether_callbacks.o $(IO_POLLER_OBJS) $(MANIFEST_OBJ) -o $(BUILD_DIR)/aetherc$(EXE_EXT) $(AETHER_REQUIRED_LDFLAGS) $(LDFLAGS) $(AETHERC_STACK_LDFLAGS) + @$(CC) $(COMPILER_OBJS) $(STD_OBJS) $(COLLECTIONS_OBJS) $(AETHERC_RUNTIME_OBJS) $(IO_POLLER_OBJS) $(MANIFEST_OBJ) -o $(BUILD_DIR)/aetherc$(EXE_EXT) $(AETHER_REQUIRED_LDFLAGS) $(LDFLAGS) $(AETHERC_STACK_LDFLAGS) @echo "Compiler built successfully" -# Fast compiler target (monolithic, for clean builds) -compiler-fast: $(FRONTEND_ID_HEADER) +# Fast compiler target (monolithic, for clean builds): the same sources and +# generated headers as the rule above, compiled in one command. The sources +# compiled with flags of their own (the vendored lzf and miniaudio, which +# relax warnings for them alone) come from their own rules; one command +# cannot give one file different flags. +COMPILER_FAST_OWN_SRC = std/lzf/lzf_c.c std/lzf/lzf_d.c std/lzf/aether_lzf.c std/audio/aether_audio.c +COMPILER_FAST_OWN_OBJS = $(patsubst %.c,$(OBJ_DIR)/%.o,$(filter $(COMPILER_FAST_OWN_SRC),$(STD_SRC))) +compiler-fast: $(COMPILER_FAST_OWN_OBJS) $(MANIFEST_OBJ) | $(VERSION_HEADER) $(STDLIB_SYMS_HEADER) $(FRONTEND_ID_HEADER) ifdef WINDOWS_NATIVE @if not exist "$(BUILD_DIR)" mkdir "$(BUILD_DIR)" else @$(MKDIR) $(BUILD_DIR) endif - $(CC) $(AETHER_REQUIRED_CFLAGS) $(CFLAGS) $(COMPILER_SRC) $(STD_SRC) $(COLLECTIONS_SRC) $(IO_POLLER_SRC) runtime/aether_resource_caps.c runtime/aether_locale_num.c runtime/aether_callbacks.c -o $(BUILD_DIR)/aetherc$(EXE_EXT) $(AETHER_REQUIRED_LDFLAGS) $(LDFLAGS) $(AETHERC_STACK_LDFLAGS) + $(CC) $(AETHER_REQUIRED_CFLAGS) $(CFLAGS) $(COMPILER_SRC) $(filter-out $(COMPILER_FAST_OWN_SRC),$(STD_SRC)) $(COMPILER_FAST_OWN_OBJS) $(COLLECTIONS_SRC) $(AETHERC_RUNTIME_SRC) $(IO_POLLER_SRC) $(MANIFEST_OBJ) -o $(BUILD_DIR)/aetherc$(EXE_EXT) $(AETHER_REQUIRED_LDFLAGS) $(LDFLAGS) $(AETHERC_STACK_LDFLAGS) test: $(TEST_OBJS) $(COMPILER_LIB_OBJS) $(RUNTIME_OBJS) $(STD_OBJS) $(STD_REACTOR_OBJS) $(COLLECTIONS_OBJS) @echo "===================================" @@ -1617,6 +1647,7 @@ test-release-archive: compiler ae stdlib check-archive-exports test -f "$$verdir/include/aether/$$(basename "$$h")" || { echo " FAIL: include/aether/$$(basename "$$h") missing (#1420)"; exit 1; }; \ done && \ test -d "$$verdir/share/aether/runtime" || (echo " FAIL: share/aether/runtime missing"; exit 1) && \ + test -f "$$verdir/share/aether/runtime/coop_runtime_sources.txt" || (echo " FAIL: the runtime list ae build --target=wasm reads is missing (#3383)"; exit 1) && \ test -d "$$verdir/share/aether/std" || (echo " FAIL: share/aether/std missing"; exit 1) && \ test -f "$$verdir/share/aether/tools/ae-fetch/main.ae" || (echo " FAIL: the package fetcher's source is missing (#1362)"; exit 1) && \ test -f "$$verdir/share/aether/contrib/sqlite/amalgamation/sqlite3.c" || (echo " FAIL: the SQLite amalgamation is missing (#1372)"; exit 1) && \ @@ -1759,6 +1790,21 @@ bench-json-compare: $(BUILD_DIR)/run_json_bench $(BUILD_DIR)/run_yyjson_bench # ≥4.8 and every Clang (Linux + macOS). # test-json-valgrind — same under Valgrind on Linux (macOS skips if no # valgrind installed). +# The TLS conformance corpora (#1298), fetched at pinned commits into +# $(CONFORMANCE_CACHE) (default ~/.cache/aether-conformance), never committed. +# conformance-x509: x509-limbo and the bettertls suite it imports, against +# std.cryptography.tls13_cert (Linux CI runs it in ci-crypto-ct). +# conformance-bogo: BoringSSL's ssl/test/runner (BoGo) against the pure TLS +# client and server through tests/conformance/bogo/shim.ae; needs Go 1.27 and +# git, takes about five minutes, and is not run in CI. BOGO_FLAGS passes runner +# flags, e.g. BOGO_FLAGS="-test 'Basic-*'". +.PHONY: conformance-x509 conformance-bogo +conformance-x509: compiler ae stdlib + @sh tests/conformance/x509_limbo/run.sh all + +conformance-bogo: compiler ae stdlib + @sh tests/conformance/bogo/run.sh $(BOGO_FLAGS) + # test-json-conformance — (Phase 4a) run JSONTestSuite if committed. .PHONY: test-json-asan test-json-valgrind test-json-conformance @@ -2042,6 +2088,7 @@ $(BUILD_DIR)/libaether_main.a: runtime/windows/aether_lib_main.c $(BUILD_FLAGS_S $(BUILD_DIR)/aether_manifest.o: runtime/windows/aether.rc runtime/windows/aether.manifest @echo "Compiling the Windows application manifest..." + @mkdir -p $(BUILD_DIR) @$(WINDRES) -I runtime/windows runtime/windows/aether.rc -O coff -o $@ # The archive is a FILE target, not a phony one. As a phony `stdlib` it was @@ -2280,7 +2327,7 @@ release-build: $(BUILD_DIR)/aetherc-release$(EXE_EXT) # A file target as well, so `make install` on a current tree does not spend a # minute recompiling the whole compiler in one LTO link before copying. -$(BUILD_DIR)/aetherc-release$(EXE_EXT): $(COMPILER_SRC) $(STD_SRC) $(COLLECTIONS_SRC) runtime/aether_resource_caps.c runtime/aether_locale_num.c runtime/aether_callbacks.c $(STDLIB_SYMS_HEADER) $(FRONTEND_ID_HEADER) $(VERSION_HEADER) $(MANIFEST_OBJ) Makefile +$(BUILD_DIR)/aetherc-release$(EXE_EXT): $(COMPILER_SRC) $(STD_SRC) $(COLLECTIONS_SRC) $(AETHERC_RUNTIME_SRC) $(STDLIB_SYMS_HEADER) $(FRONTEND_ID_HEADER) $(VERSION_HEADER) $(MANIFEST_OBJ) Makefile @echo "===================================" @echo "Building Optimized Release" @echo "===================================" @@ -2301,11 +2348,10 @@ $(BUILD_DIR)/aetherc-release$(EXE_EXT): $(COMPILER_SRC) $(STD_SRC) $(COLLECTIONS @# regex.compile in the SHIPPED aetherc failed at runtime while the build @# stayed clean. std.regex now refuses to compile stubs unless asked, which @# is what surfaced this. The matching LDFLAGS are already in $(LDFLAGS). - @$(CC) -O3 -DNDEBUG $(LTO_FLAG) -Werror -Icompiler -Iruntime -Istd -Istd/collections \ + @$(CC) -O3 -DNDEBUG $(LTO_FLAG) -Werror -Iinclude -Icompiler -Iruntime -Istd -Istd/collections \ -DAETHER_VERSION=\"$(VERSION)\" \ $(OPENSSL_CFLAGS) $(ZLIB_CFLAGS) $(NGHTTP2_CFLAGS) $(PCRE2_CFLAGS) $(YAML_CFLAGS) $(BROTLI_CFLAGS) $(ZSTD_CFLAGS) \ - $(COMPILER_SRC) $(STD_SRC) $(COLLECTIONS_SRC) runtime/aether_resource_caps.c \ - runtime/aether_locale_num.c runtime/aether_callbacks.c $(MANIFEST_OBJ) \ + $(COMPILER_SRC) $(STD_SRC) $(COLLECTIONS_SRC) $(AETHERC_RUNTIME_SRC) $(MANIFEST_OBJ) \ -o $(BUILD_DIR)/aetherc-release$(EXE_EXT) $(AETHER_REQUIRED_LDFLAGS) $(LDFLAGS) ifeq ($(DETECTED_OS),Linux) @echo "Stripping debug symbols..." @@ -2834,6 +2880,8 @@ help: @echo " make check-warnings - Every .ae file under std and contrib type-checks without a warning" @echo " make check-examples - Every example builds clean and prints the output it says it prints" @echo " make check-contrib-modules - Type-check every non-host contrib module" + @echo " make conformance-x509 - x509-limbo and bettertls against tls13_cert (fetched, pinned)" + @echo " make conformance-bogo - BoringSSL BoGo against the pure TLS stack (needs Go 1.27; not in CI)" @echo " make check-changelog - Validate changelog fragments, catch a release-fold" @echo " make add-changelog - Write a new_changelogs/ fragment (SECTION=, SLUG=)" @echo " make collect-changelogs - Fold settled fragments into CHANGELOG.md" @@ -3026,6 +3074,7 @@ ci: clean @$(CI_STAGE) 900 check-tests $(MAKE) check-tests @$(CI_STAGE) 900 check-extern-widths $(MAKE) check-extern-widths @$(CI_STAGE) 600 check-raw-calls $(MAKE) check-raw-calls + @$(CI_STAGE) 600 check-crypto-wipe $(MAKE) check-crypto-wipe @$(CI_STAGE) 900 check-warnings $(MAKE) check-warnings @echo "" @echo "[5/11] Running .ae integration tests..." @@ -3144,6 +3193,22 @@ check-raw-calls: compiler ae stdlib { cat $(BUILD_DIR)/raw_calls/self_test.log; exit 1; } @./$(RAW_CALLS_CHECKER) --exclude tests/raw_calls/fixture std contrib tests examples +# Every buffer the cryptography modules free is wiped first (#3402, #3414): +# a module under std/cryptography frees a std.bytes buffer with +# bytes.wipe_free, never bytes.free, since a rule decided call by call +# missed one secret after another. The checker is Aether +# (tests/crypto_wipe/check.ae); its self-test first, over a fixture with +# exactly 3 plain frees. +CRYPTO_WIPE_CHECKER := $(BUILD_DIR)/crypto_wipe/check$(EXE_EXT) +.PHONY: check-crypto-wipe +check-crypto-wipe: compiler ae stdlib + @mkdir -p $(BUILD_DIR)/crypto_wipe + @./$(BUILD_DIR)/ae$(EXE_EXT) build tests/crypto_wipe/check.ae -o $(CRYPTO_WIPE_CHECKER) + @./$(CRYPTO_WIPE_CHECKER) --expect 3 tests/crypto_wipe/fixture \ + > $(BUILD_DIR)/crypto_wipe/self_test.log 2>&1 || \ + { cat $(BUILD_DIR)/crypto_wipe/self_test.log; exit 1; } + @./$(CRYPTO_WIPE_CHECKER) std/cryptography + # ----------------------------------------------------------------- # Examples (#3105): examples/ is the path a learner follows, so each example # must still be true. Every example builds without a warning; each one that @@ -3607,6 +3672,13 @@ ci-coop: clean compiler ae examples/02-actors/04-actor-refs.ae \ examples/02-actors/03-ask-reply.ae \ examples/02-actors/07-worker-pool.ae \ + tests/regression/test_actor_down_notices.ae \ + tests/regression/test_actor_links.ae \ + tests/regression/test_ask_failure_reasons.ae \ + tests/regression/test_actor_step_reentry.ae \ + tests/regression/test_owned_panic_unwind.ae \ + tests/regression/test_step_panic_drop.ae \ + tests/regression/test_step_drop_context.ae \ examples/01-language/01-hello.ae; do \ printf " %-50s " "$$src"; \ if AETHER_HOME="" ./$(BUILD_DIR)/ae run "$$src" >/tmp/ae_coop_out.txt 2>&1; then \ @@ -3627,6 +3699,15 @@ ci-coop: clean compiler ae cat /tmp/ae_coop_out.txt | head -10; \ fail=$$((fail + 1)); \ fi; \ + printf " %-50s " "an export's ask steps actors outside it (#3392)"; \ + if sh tests/integration/emit_lib_actor_step_context/test_emit_lib_actor_step_context.sh >/tmp/ae_coop_out.txt 2>&1; then \ + echo "PASS"; \ + pass=$$((pass + 1)); \ + else \ + echo "FAIL"; \ + cat /tmp/ae_coop_out.txt | head -10; \ + fail=$$((fail + 1)); \ + fi; \ echo ""; \ echo " $$pass passed, $$fail failed"; \ if [ "$$fail" -gt 0 ]; then exit 1; fi @@ -3664,7 +3745,7 @@ ci-wasm: clean compiler ae @echo " WebAssembly (Emscripten) CI" @echo "===================================" @echo "" - @echo "[1/3] Generating C from test programs..." + @echo "[1/4] Generating C from test programs..." @mkdir -p $(BUILD_DIR)/wasm @pass=0; fail=0; \ for src in $(WASM_CI_PROGRAMS); do \ @@ -3686,9 +3767,9 @@ ci-wasm: clean compiler ae # signature (an int64_t where the definition takes a 32-bit size_t), and # links a stub that traps when the call runs: --fatal-warnings makes that # the build error it is. - @echo "[2/3] Compiling runtime + generated C with emcc..." + @echo "[2/4] Compiling runtime + generated C with emcc..." @WASM_CFLAGS="-O2 -DAETHER_NO_THREADING -DAETHER_NO_FILESYSTEM -DAETHER_NO_NETWORKING \ - -Iruntime -Iruntime/actors -Iruntime/scheduler -Iruntime/utils -Iruntime/memory \ + -Iinclude -Iruntime -Iruntime/actors -Iruntime/scheduler -Iruntime/utils -Iruntime/memory \ -Iruntime/config -Istd -Istd/string -Istd/io -Istd/math -Istd/net -Istd/collections -Istd/json \ -Wall -Wextra -Wno-unused-parameter -Wno-unused-function \ -Wno-unused-variable -Wno-missing-field-initializers -Wno-unused-label"; \ @@ -3710,7 +3791,7 @@ ci-wasm: clean compiler ae echo " $$pass compiled, $$fail failed"; \ if [ "$$fail" -gt 0 ]; then exit 1; fi @echo "" - @echo "[3/3] Running WASM programs with Node.js..." + @echo "[3/4] Running WASM programs with Node.js..." @pass=0; fail=0; \ for src in $(WASM_CI_PROGRAMS); do \ name=$$(basename $$src .ae); \ @@ -3727,6 +3808,24 @@ ci-wasm: clean compiler ae echo " $$pass passed, $$fail failed"; \ if [ "$$fail" -gt 0 ]; then exit 1; fi @echo "" +# The steps above compile with this Makefile's own command; a user's +# `ae build --target=wasm` composes its own, from the runtime list ae reads +# (#3383: ae's copy of the list had drifted, and nothing here built a +# program the way a user does). + @echo "[4/4] ae build --target=wasm, as a user builds..." + @if ! command -v emcc > /dev/null 2>&1; then echo " emcc not on PATH, skipped"; exit 0; fi; \ + printf ' %-40s ' "ae build 02-state"; \ + if ./$(BUILD_DIR)/ae build --target=wasm examples/02-actors/02-state.ae \ + -o "$(BUILD_DIR)/wasm/ae_state" > /tmp/ae_wasm_build.txt 2>&1 && \ + node "$(BUILD_DIR)/wasm/ae_state.js" > /tmp/ae_wasm_run.txt 2>&1 && \ + grep -q "counter: Current value: 18 after 3 changes" /tmp/ae_wasm_run.txt; then \ + echo "PASS"; \ + else \ + echo "FAIL"; \ + cat /tmp/ae_wasm_build.txt /tmp/ae_wasm_run.txt | head -20; \ + exit 1; \ + fi + @echo "" @echo "===================================" @echo " WebAssembly CI PASSED" @echo "===================================" diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index a265898e5..11fe5179a 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -98,6 +98,50 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` +## BearSSL (MIT License) + +Used by `std.cryptography.aes` (issue +[#2600](https://github.com/aether-lang-dev/aether/issues/2600)). The AES core +in `std/cryptography/aes/aether_aes.c` is the 64-bit bitsliced, constant-time +implementation of BearSSL's `aes_ct64` (the Boyar-Peralta S-box circuit, the +orthogonalization and interleaving, ShiftRows, MixColumns and the key +schedule), and carries the attribution header: + +``` +/* MIT License (https://opensource.org/licenses/MIT) + * + * Portions copyright (c) 2016 Thomas Pornin (BearSSL, https://bearssl.org) + * + * Portions copyright (c) 2026 Aether Developers. + */ +``` + +### License text + +``` +Copyright (c) 2016 Thomas Pornin + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + ## PCRE2 — BSD-3-Clause WITH PCRE2-exception (binary-redistribution exemption) Used by `std.regex` (issue diff --git a/compiler/analysis/typechecker.c b/compiler/analysis/typechecker.c index 5bdcb4d63..7533c2850 100644 --- a/compiler/analysis/typechecker.c +++ b/compiler/analysis/typechecker.c @@ -454,6 +454,10 @@ static Symbol* own_extern_symbol(Symbol* found, const char* merged_from, const c static int is_program_own_decl(const Symbol* s) { const ASTNode* n = s ? s->node : NULL; if (!n || n->origin_module || n->is_imported) return 0; + /* The compiler's own `message Down` (#2974) is every file's: a module's + * Down arm reads its field types too. */ + if (n->type == AST_MESSAGE_DEFINITION && n->annotation && strcmp(n->annotation, "system") == 0) + return 0; switch (n->type) { case AST_FUNCTION_DEFINITION: case AST_BUILDER_FUNCTION: case AST_STRUCT_DEFINITION: case AST_SUM_TYPE_DEF: case AST_ENUM_DEFINITION: @@ -4137,6 +4141,11 @@ Type* infer_type(ASTNode* expr, SymbolTable* table) { case AST_LITERAL: return clone_type(expr->node_type); + case AST_SEND_FIRE_FORGET: + /* #2974: try_send(ref, Msg { ... }) says whether it sent. */ + return create_type(expr->annotation && strcmp(expr->annotation, "try_send") == 0 + ? TYPE_STRING : TYPE_UNKNOWN); + case AST_NONE_LITERAL: // `none` — optional with as-yet-unknown inner; context pins the // concrete T (assignment / `??` / `==`). If already pinned, use it. @@ -5680,6 +5689,11 @@ static ASTNode* g_tc_fn_body = NULL; static ASTNode* g_tc_return_owner = NULL; static void declare_if_branch_hoists(ASTNode* body, SymbolTable* table); +/* #3331: how many trailing DSL blocks enclose the code being checked. In + * one, a builder's `_ctx` is injected and hidden from the caller + * (closures-and-builder-dsl.md, "Invisible Context Injection"). */ +static int g_tc_trailing_depth = 0; + /* #3025: how many times `n` binds `name`: a declaration or a re-bind, a * `=` to it, a pattern's name, closures' included. */ static int count_bindings_of(const ASTNode* n, const char* name) { @@ -6903,6 +6917,55 @@ static void typecheck_pending_callees(ASTNode* n, PendingResults* p, SymbolTable typecheck_pending_calls(n, p, global_table, 1); } +/* #2974: the death notice. The runtime writes its payload, so the compiler + * owns its shape: `message Down { ref: actor_ref, reason: string }` is + * declared here, with the reserved id 254 (aether_message_registry.c), for a + * program that names it (an arm, a literal), and a program's own `message + * Down` is refused. A program that never names it drops a Down it is sent, + * as any type it has no arm for. */ +static int names_down_message(const ASTNode* n) { + if (!n) return 0; + if ((n->type == AST_MESSAGE_PATTERN || n->type == AST_MESSAGE_CONSTRUCTOR) && + n->value && strcmp(n->value, "Down") == 0) + return 1; + for (int i = 0; i < n->child_count; i++) + if (names_down_message(n->children[i])) return 1; + return 0; +} + +static void declare_down_message(ASTNode* program) { + int declared = 0; + for (int i = 0; i < program->child_count; i++) { + ASTNode* c = program->children[i]; + if (!c || c->type != AST_MESSAGE_DEFINITION || !c->value || strcmp(c->value, "Down") != 0) + continue; + if (c->annotation && strcmp(c->annotation, "system") == 0) { + declared = 1; // a second check of the same program + continue; + } + type_error_hint("'Down' is the runtime's death notice, declared by the compiler as " + "`message Down { ref: actor_ref, reason: string }`: a program cannot " + "declare it", + "rename this message; a `Down(ref, reason)` arm takes the runtime's", + c->line, c->column); + return; + } + if (declared || !names_down_message(program)) return; + ASTNode* def = create_ast_node(AST_MESSAGE_DEFINITION, "Down", 0, 0); + def->annotation = strdup("system"); + ASTNode* ref = create_ast_node(AST_MESSAGE_FIELD, "ref", 0, 0); + ref->node_type = create_type(TYPE_ACTOR_REF); + ASTNode* reason = create_ast_node(AST_MESSAGE_FIELD, "reason", 0, 0); + reason->node_type = create_type(TYPE_STRING); + add_child(def, ref); + add_child(def, reason); + // First, ahead of every use. + add_child(program, def); + memmove(&program->children[1], &program->children[0], + (size_t)(program->child_count - 1) * sizeof(ASTNode*)); + program->children[0] = def; +} + int typecheck_program(ASTNode* program) { note_program_globals(program); if (!program || program->type != AST_PROGRAM) return 0; @@ -6930,6 +6993,7 @@ int typecheck_program(ASTNode* program) { error_count = 0; warning_count = 0; namespace_count = 0; // Reset imported namespaces + declare_down_message(program); // #2974, counted with this check's errors user_explicit_namespace_count = 0; // Reset user-explicit namespaces (issue #243) // (#878: per-module selective-import filter removed — nothing to reset) @@ -17519,6 +17583,10 @@ int typecheck_expression(ASTNode* expr, SymbolTable* table) { g_tc_return_type = NULL; g_tc_return_owner = expr; } + /* A trailing DSL block is where a builder's `_ctx` is injected + * (#3331): calls in it are checked knowing so. */ + int dsl_block = !ast_is_own_function(expr); + if (dsl_block) g_tc_trailing_depth++; for (int i = 0; i < expr->child_count; i++) { ASTNode* child = expr->children[i]; if (child && child->type == AST_BLOCK) { @@ -17527,6 +17595,7 @@ int typecheck_expression(ASTNode* expr, SymbolTable* table) { } } } + if (dsl_block) g_tc_trailing_depth--; g_tc_return_type = enclosing_ret; g_tc_return_owner = enclosing_owner; @@ -18061,6 +18130,28 @@ int typecheck_expression(ASTNode* expr, SymbolTable* table) { return 0; } + /* #3333: a bare `actor_ref` names no actor, so no field of + * one can be read or written through it; the C of `w.n` on + * a `void*` did not compile. A typed reference + * (`actor_ref[Counter]`, a spawn result) names the actor. */ + if (base_type && base_type->kind == TYPE_ACTOR_REF && + !(base_type->element_type && base_type->element_type->kind == TYPE_STRUCT && + base_type->element_type->struct_name)) { + char error_msg[320]; + char hint[320]; + snprintf(error_msg, sizeof(error_msg), + "a bare `actor_ref` has no field '%s': it names no actor, " + "so its state is not known here", + expr->value); + snprintf(hint, sizeof(hint), + "give the reference its actor's type, `actor_ref[ActorName]`, " + "to reach the actor's state field '%s'", + expr->value); + free_type(base_type); + type_error_hint(error_msg, hint, expr->line, expr->column); + return 0; + } + // Handle Message type member access if (base_type && base_type->kind == TYPE_MESSAGE) { if (strcmp(expr->value, "type") == 0 || @@ -18075,18 +18166,31 @@ int typecheck_expression(ASTNode* expr, SymbolTable* table) { else if (base_type && base_type->kind == TYPE_ACTOR_REF && base_type->element_type && base_type->element_type->kind == TYPE_STRUCT && base_type->element_type->struct_name) { Symbol* actor_sym2 = lookup_symbol(table, base_type->element_type->struct_name); - if (actor_sym2 && actor_sym2->node) { + if (actor_sym2 && actor_sym2->node && actor_sym2->node->type == AST_ACTOR_DEFINITION) { ASTNode* actor_def2 = actor_sym2->node; + int found_state = 0; for (int fi = 0; fi < actor_def2->child_count; fi++) { ASTNode* field = actor_def2->children[fi]; if (field && field->type == AST_STATE_DECLARATION && field->value && strcmp(field->value, expr->value) == 0) { + found_state = 1; if (field->node_type && field->node_type->kind != TYPE_UNKNOWN) { set_node_type(expr, clone_type(field->node_type)); } break; } } + /* #3333: a name the actor has no state of reached + * the C compiler as a member of its struct. */ + if (!found_state) { + char error_msg[320]; + snprintf(error_msg, sizeof(error_msg), + "actor '%s' has no state field '%s'", + actor_def2->value ? actor_def2->value : "?", expr->value); + free_type(base_type); + type_error(error_msg, expr->line, expr->column); + return 0; + } } // Fallback to general inference if (!expr->node_type || expr->node_type->kind == TYPE_UNKNOWN) { @@ -18216,6 +18320,17 @@ int typecheck_expression(ASTNode* expr, SymbolTable* table) { ASTNode* message = expr->children[1]; typecheck_expression(actor_ref, table); + if (expr->annotation && strcmp(expr->annotation, "try_send") == 0) { + /* #2974: as a send statement checks its target. */ + Type* at = infer_type(actor_ref, table); + int bad = at && at->kind != TYPE_ACTOR_REF && at->kind != TYPE_UNKNOWN; + free_type(at); + if (bad) { + type_error("try_send(ref, Msg { ... }): ref is not an actor reference", + actor_ref->line, actor_ref->column); + return 0; + } + } // Validate that the message type is a registered message definition if (message->type == AST_MESSAGE_CONSTRUCTOR && message->value) { @@ -18233,7 +18348,9 @@ int typecheck_expression(ASTNode* expr, SymbolTable* table) { typecheck_message_constructor(message, table); reject_unhandled_send(actor_ref, message, table); /* #385 */ } - set_node_type(expr, create_type(TYPE_VOID)); + /* #2974: try_send(ref, Msg { ... }) says whether it sent. */ + set_node_type(expr, create_type(expr->annotation && strcmp(expr->annotation, "try_send") == 0 + ? TYPE_STRING : TYPE_VOID)); return 1; } @@ -21286,9 +21403,79 @@ static int typecheck_function_call_inner(ASTNode* call, SymbolTable* table) { } } + /* #3331: inside a trailing DSL block a builder's `_ctx` is injected + * and hidden from the caller, so the call gives the parameters + * after it, and the defaults among those fill what it leaves out. + * Counting `_ctx` among the given arguments refused `item("a")` for + * `item(_ctx, name, kind = "plain")`, and read `item("b", "fancy")` + * as `_ctx = "b"` with `kind` defaulted. A call that passes the + * context itself (`_ctx`, or `builder_context()`) first, or gives + * every parameter, passes it explicitly. Codegen injects when the + * call is one argument short of the parameters, which the fill + * below leaves it. */ + int ctx_injected = 0; + if (ctx_first && g_tc_trailing_depth > 0 && expected > 0) { + int user = 0; + for (int i = 0; i < call->child_count; i++) { + ASTNode* a = call->children[i]; + if (a && a->type == AST_CLOSURE && a->value && strcmp(a->value, "trailing") == 0) continue; + user++; + } + ASTNode* first = call->child_count > 0 ? call->children[0] : NULL; + int passes_ctx = first && + ((first->type == AST_IDENTIFIER && first->value && strcmp(first->value, "_ctx") == 0) || + (first->type == AST_FUNCTION_CALL && first->value && + strcmp(first->value, "builder_context") == 0)); + if (!passes_ctx && user < expected) { + if (user < required - 1) { + char error_msg[256]; + if (required < expected) + snprintf(error_msg, sizeof(error_msg), + "Function '%s' expects %d-%d argument(s) in a builder block (its _ctx is given there), got %d", + call->value, required - 1, expected - 1, user); + else + snprintf(error_msg, sizeof(error_msg), + "Function '%s' expects %d argument(s) in a builder block (its _ctx is given there), got %d", + call->value, expected - 1, user); + type_error(error_msg, call->line, call->column); + return 0; + } + int filled = user; + int param_idx = 0; + for (int i = 0; i < symbol->node->child_count - 1 && filled < expected - 1; i++) { + ASTNode* p = symbol->node->children[i]; + if (!is_param_node(p)) continue; + /* _ctx (index 0) and the parameters the call gives. */ + if (param_idx <= user) { + param_idx++; + continue; + } + if (!param_has_default(p)) { + char err[256]; + snprintf(err, sizeof(err), + "Function '%s' parameter %d has no default, caller must supply it", + call->value, param_idx); + type_error(err, call->line, call->column); + return 0; + } + ASTNode* default_clone = clone_ast_node(p->children[0]); + if (!default_clone) { + type_error("internal: failed to clone default expression", + call->line, call->column); + return 0; + } + rewrite_caller_site_intrinsics(default_clone, call->line, call->column); + add_child(call, default_clone); + filled++; + param_idx++; + } + ctx_injected = 1; + } + } + // If mismatch, try excluding trailing closures (for functions that // don't accept fn params but have trailing blocks for DSL syntax) - if (got != expected && !(ctx_first && got == expected - 1)) { + if (!ctx_injected && got != expected && !(ctx_first && got == expected - 1)) { int non_closure = 0; for (int i = 0; i < call->child_count; i++) { if (call->children[i] && call->children[i]->type != AST_CLOSURE) { @@ -21317,7 +21504,7 @@ static int typecheck_function_call_inner(ASTNode* call, SymbolTable* table) { (ctx_first && got == expected - 1) || (got >= required && got < expected) || (is_variadic_callee && got >= expected); - if (!arity_ok) { + if (!ctx_injected && !arity_ok) { char error_msg[256]; if (required < expected) { snprintf(error_msg, sizeof(error_msg), @@ -21339,7 +21526,7 @@ static int typecheck_function_call_inner(ASTNode* call, SymbolTable* table) { // codegen sees a fully-populated call and emits the right C. // Defaults trail required, so the missing slots are always // the trailing tail. - if (got >= required && got < expected) { + if (!ctx_injected && got >= required && got < expected) { int param_idx = 0; for (int i = 0; i < symbol->node->child_count - 1 && call->child_count < expected; i++) { @@ -21844,10 +22031,22 @@ static int typecheck_function_call_inner(ASTNode* call, SymbolTable* table) { if (sa) free_type(sa); } + /* #3116, #3450: `free(xs)` of a slice is the builtin release + * whatever `free` the program declares as an extern taking a + * pointer (`extern free(p: ptr)`), in its own code and in every + * module it imports. Decayed to its pointer here, the slice went + * to C's free while slice ownership still released it at scope + * exit (a double free), and its elements' references were never + * given back. A `free` that takes a slice is its own business. */ + int builtin_free = symbol->node->type == AST_EXTERN_FUNCTION && call->value && + strcmp(call->value, "free") == 0 && call->child_count == 1 && + call->children[arg_slot] && + type_is_slice(call->children[arg_slot]->node_type) && + !type_is_slice(param_type); /* #1286: a `T[N]` array into a `T[]` parameter becomes a slice; a * slice into a `ptr` parameter, or into any parameter of an * extern C function, decays to its pointer. */ - if (param_type) + if (param_type && !builtin_free) slice_coerce_slot(&call->children[arg_slot], param_type, symbol->node->type == AST_EXTERN_FUNCTION); diff --git a/compiler/codegen/codegen.c b/compiler/codegen/codegen.c index 9c8afa129..706dfa9ce 100644 --- a/compiler/codegen/codegen.c +++ b/compiler/codegen/codegen.c @@ -14,6 +14,8 @@ #include "../analysis/actor_reply.h" #include "../analysis/sandbox_trust.h" #include "../analysis/hoist.h" +#include "../../runtime/aether_abi.h" /* #2957: the runtime ABI the C it writes is for */ +#include "../../include/libaether.h" /* #2957: AETHER_HOST_ABI */ /* The AetherString header the emitted C inspects, from the definition the * runtime uses (std/string/aether_string_abi.h): its magic's bytes in @@ -883,6 +885,9 @@ void code_generator_release(CodeGenerator* gen) { gen->hoist_decls = NULL; free(gen->defer_thunks); gen->defer_thunks = NULL; + free(gen->owned_thunks); /* #3413: the names are interned */ + gen->owned_thunks = NULL; + gen->owned_thunk_count = gen->owned_thunk_cap = 0; gen->hoist_decls_len = gen->hoist_decls_cap = 0; /* The emitted-typedef registries: one strdup'd name per distinct * tuple / optional / sum shape in the program (#1667). */ @@ -1572,9 +1577,14 @@ static int try_emit_owned_exit_drop(CodeGenerator* gen, ASTNode* deferred) { const char* rest = deferred->annotation + plen; const char* sep = strchr(rest, ':'); if (!sep || sep == rest || !sep[1]) return 0; + /* The place's journal entry goes first, when it has one (#3406, + * push_owned_place_drop: _aeo_ says so). */ + int nl = (int)(sep - rest); print_indent(gen); - fprintf(gen->output, "/* deferred */ if (%.*s) { %s((void*)%.*s); %.*s = NULL; }\n", - (int)(sep - rest), rest, sep + 1, (int)(sep - rest), rest, (int)(sep - rest), rest); + fprintf(gen->output, + "/* deferred */ if (_aeo_%.*s) aether_unwind_forget((const void*)&%.*s); " + "if (%.*s) { %s((void*)%.*s); %.*s = NULL; }\n", + nl, rest, nl, rest, nl, rest, sep + 1, nl, rest, nl, rest); return 1; } @@ -3211,13 +3221,12 @@ static int lib_const_identity(ASTNode* cd, const char** name, const char** modul // Functions with unsupported types (tuples, structs, closures) are skipped // with a compile-time warning so the user knows the function won't be // exposed across the FFI boundary. -/* #2931: the function a caller gives a string an export returned back to, - * so the library's own runtime, which made it, releases it. Named for the - * library (the name it is given, else its package, else its entry file's - * stem, so every target's build agrees), and recorded in the - * catalog, so an importer never derives it. */ -const char* lib_string_release_symbol_for(const char* lib_id, const char* package, - const char* source_file) { +/* aether__: the library's own name for a function an + * importer binds. Named for the library (the name it is given, else its + * package, else its entry file's stem, so every target's build agrees), + * and recorded in the catalog, so an importer never derives it. */ +static const char* lib_symbol_for(const char* lib_id, const char* package, + const char* source_file, const char* suffix) { const char* base = lib_id ? lib_id : package; const char* end = NULL; if (!base) { @@ -3232,13 +3241,131 @@ const char* lib_string_release_symbol_for(const char* lib_id, const char* packag for (const char* p = base; p < end && n + 1 < sizeof(id); p++) id[n++] = isalnum((unsigned char)*p) ? *p : '_'; id[n] = '\0'; - return cg_internf("aether_%s_string_release", n ? id : "lib"); + return cg_internf("aether_%s_%s", n ? id : "lib", suffix); +} + +/* #2931: the function a caller gives a string an export returned back to, + * so the library's own runtime, which made it, releases it. */ +const char* lib_string_release_symbol_for(const char* lib_id, const char* package, + const char* source_file) { + return lib_symbol_for(lib_id, package, source_file, "string_release"); +} + +/* #2956: how the library's last call on this thread ended, read by an + * Aether importer after each call (the library may run on a runtime of its + * own, whose outcome the importer's aether_last_outcome cannot see), with + * _text for the reason. */ +static const char* lib_outcome_symbol(CodeGenerator* gen) { + return lib_symbol_for(gen->lib_id, module_lib_package(), gen->source_file, "outcome"); } static const char* lib_string_release_symbol(CodeGenerator* gen) { return lib_string_release_symbol_for(gen->lib_id, module_lib_package(), gen->source_file); } +/* #2956: an export that can neither panic, allocate, loop nor call anything + * needs no execution frame: nothing can end it from outside its code, and + * it changes nothing a frame gives back. Its stub calls it and records the + * OK outcome (one runtime call) instead of saving the context for a panic + * (about 5 ns a call on Windows MinGW). Kept narrow on purpose: scalar + * locals and parameters, literals, + - * and comparisons (division can + * trap), `!` and unary `-`, if/else, declarations, assignments to a local + * and return. Anything else, and any doubt, gets the frame. */ +static int lib_scalar_type(const Type* t) { + if (!t) return 1; + switch (t->kind) { + case TYPE_INT: case TYPE_INT64: case TYPE_UINT64: case TYPE_UINT32: case TYPE_UINT16: + case TYPE_UINT8: case TYPE_FLOAT: case TYPE_FLOAT32: case TYPE_BOOL: case TYPE_BYTE: + case TYPE_VOID: + return 1; + default: + return 0; + } +} + +static int lib_node_cannot_fail(const ASTNode* n) { + if (!n) return 1; + switch (n->type) { + case AST_BLOCK: case AST_RETURN_STATEMENT: case AST_IF_STATEMENT: + case AST_IF_EXPRESSION: case AST_EXPRESSION_STATEMENT: case AST_VARIABLE_DECLARATION: + case AST_IDENTIFIER: case AST_LITERAL: + break; + case AST_ASSIGNMENT: + if (n->child_count < 1 || !n->children[0] || n->children[0]->type != AST_IDENTIFIER) return 0; + break; + case AST_UNARY_EXPRESSION: + if (!n->value || (strcmp(n->value, "-") != 0 && strcmp(n->value, "!") != 0)) return 0; + break; + case AST_BINARY_EXPRESSION: { + static const char* const ok_ops[] = { "+", "-", "*", "<", ">", "<=", ">=", "==", "!=", + "&&", "||" }; + int ok = 0; + for (size_t k = 0; n->value && k < sizeof(ok_ops) / sizeof(ok_ops[0]); k++) + if (strcmp(n->value, ok_ops[k]) == 0) ok = 1; + if (!ok) return 0; + break; + } + default: + return 0; + } + if (!lib_scalar_type(n->node_type)) return 0; + for (int i = 0; i < n->child_count; i++) + if (!lib_node_cannot_fail(n->children[i])) return 0; + return 1; +} + +static int lib_export_frameless(CodeGenerator* gen, ASTNode* program, ASTNode* fn, const Type* result) { + if (fn_c_callback_def(gen, fn)) return 0; + if (!lib_scalar_type(result)) return 0; + /* A clause set's members dispatch through generated code: framed. */ + int same_name = 0; + for (int i = 0; i < program->child_count; i++) { + ASTNode* c = program->children[i]; + if (c && c->type == AST_EXPORT_STATEMENT && c->child_count > 0) c = c->children[0]; + if (c && c->type == AST_FUNCTION_DEFINITION && c->value && strcmp(c->value, fn->value) == 0) + same_name++; + } + if (same_name != 1) return 0; + ASTNode* body = NULL; + for (int i = 0; i < fn->child_count; i++) { + ASTNode* c = fn->children[i]; + if (!c) continue; + if (c->type == AST_VARIABLE_DECLARATION || c->type == AST_PATTERN_VARIABLE) { + /* A named parameter; a literal or struct pattern makes a clause. */ + if (!lib_scalar_type(c->node_type)) return 0; + } else if (c->type == AST_BLOCK && i == fn->child_count - 1) { + body = c; + } else { + return 0; /* guards, contracts, pattern parameters */ + } + } + return body && lib_node_cannot_fail(body); +} + +/* #3388: the end of a stub whose function returned. A call can still have + * failed: a stop noted after its last safe point (an allocation that failed + * and that the code survived, the deadline's flag, a JSON parse that + * stopped) gives it an outcome other than AETHER_OK at the leave. Such a + * call returns its zero value, as every failed call does (libaether.h), so + * what it would have returned is released here: the string's reference the + * caller would have had, an owning struct's or closure's parts, a string + * sequence. A `ptr` or a `*Struct` owns nothing (R4): what it points to is + * not reached, as on a panic. `RET` is the stub's ABI spelling of `result`. */ +static void emit_lib_stub_return(CodeGenerator* gen, Type* result, const char* ret_abi) { + FILE* o = gen->output; + fprintf(o, " if (aether_exec_leave_outcome() != 0) {\n"); + if (result && !result->c_alias && result->kind == TYPE_STRING) { + fprintf(o, " string_release((const char*)_aether_r);\n"); + } else if (result && !result->c_alias && result->kind == TYPE_PTR) { + if (is_string_seq_ptr_type(result)) + fprintf(o, " string_seq_free((StringSeq*)(void*)_aether_r);\n"); + } else if (result && !result->c_alias && own_type_owns(gen, result)) { + fprintf(o, " "); + own_emit_part(gen, result, "_aether_r", OWN_DROP); + } + fprintf(o, " return (%s){0};\n }\n return _aether_r;\n}\n", ret_abi); +} + static void emit_lib_alias_stubs(CodeGenerator* gen, ASTNode* program) { if (!gen || !gen->emit_lib || !program) return; @@ -3307,6 +3434,93 @@ static void emit_lib_alias_stubs(CodeGenerator* gen, ASTNode* program) { "size_t aether_string_length(const void* s);\n" "void %s(const void* s);\n\n", release_sym); + /* #2955, #2956: every export runs inside an execution frame + * (runtime/aether_exec.h). An uncaught panic ends the call, not the + * process, and the export returns its result's zero value; the caller + * info, deadline and sandbox levels a nested call changes are back when + * it returns; the outcome says how it ended. Through the generated + * functions below an Aether importer reads the outcome of the library's + * own runtime, which may not be its own. */ + const char* outcome_sym = lib_outcome_symbol(gen); + /* The entry every stub makes, `fail` being how it returns its zero + * value: the context saved with __builtin_setjmp in the stub's own + * frame where the compiler has it (0.57 ns, against 2.85 for the C + * library's setjmp on MinGW: aether_panic.h), else a frame's buf. */ + fprintf(gen->output, + "extern AetherJmpFrame* aether_exec_enter(void);\n" + "extern void aether_exec_leave(void);\n" + "extern int aether_exec_leave_outcome(void);\n" + "extern void aether_exec_failed(void);\n" + "extern void aether_exec_ok(void);\n" + "#ifndef AETHER_EXEC_STUB_ENTER\n" + "#if AETHER_EXEC_BUILTIN_SJLJ\n" + "extern void aether_exec_enter_at(void** buf);\n" + "#define AETHER_EXEC_STUB_ENTER(fail) void* _aether_xb[5]; \\\n" + " if (__builtin_setjmp(_aether_xb)) { aether_exec_failed(); fail; } \\\n" + " aether_exec_enter_at(_aether_xb)\n" + "#else\n" + "#define AETHER_EXEC_STUB_ENTER(fail) AetherJmpFrame* _aether_x = aether_exec_enter(); \\\n" + " if (AETHER_SIGSETJMP(_aether_x->buf, 1)) { aether_exec_failed(); fail; }\n" + "#endif\n" + "#endif\n" + "extern int aether_last_outcome(void);\n" + "extern const char* aether_last_outcome_text(void);\n" + "AETHER_WEAK_DEF int %s(void) { return aether_last_outcome(); }\n" + "AETHER_WEAK_DEF const char* %s_text(void) { return aether_last_outcome_text(); }\n\n", + outcome_sym, outcome_sym); + /* #2957: the runtime ABI this C was made for. Compiled against the + * headers of another Aether it does not build; linked or loaded against + * another runtime, its first call finds out (aether_runtime_abi), and + * every call then returns its zero value with AETHER_INCOMPATIBLE, its + * code never run. A host can ask first, through _check. */ + const char* check_sym = lib_symbol_for(gen->lib_id, module_lib_package(), gen->source_file, "check"); + fprintf(gen->output, + "#include \"aether_abi.h\"\n" + "#if !defined(AETHER_RUNTIME_ABI_REV) || AETHER_VERSION_NUM != %d || AETHER_RUNTIME_ABI_REV != %d\n" + "#error \"this C was generated by aetherc %s (runtime ABI %s), and is compiled against the " + "runtime headers of another Aether: generate it again with that Aether's aetherc\"\n" + "#endif\n" + "extern void aether_exec_note_outcome(int outcome, const char* text);\n" + "static _Atomic int _aether_lib_abi = 0; /* 0 not yet asked, 1 the same, 2 another */\n" + "static char _aether_lib_abi_why[320];\n" + "static int _aether_lib_abi_check(void) {\n" + " const char* rt = aether_runtime_abi();\n" + " if (rt && strcmp(rt, AETHER_RUNTIME_ABI_ID) == 0) {\n" + " atomic_store_explicit(&_aether_lib_abi, 1, memory_order_relaxed);\n" + " return 1;\n" + " }\n" + " snprintf(_aether_lib_abi_why, sizeof(_aether_lib_abi_why),\n" + " \"the library was made for Aether runtime %%s and runs on %%s: rebuild it with the Aether \"\n" + " \"of the runtime it runs on\", AETHER_RUNTIME_ABI_ID, rt ? rt : \"(unknown)\");\n" + " if (atomic_exchange_explicit(&_aether_lib_abi, 2, memory_order_relaxed) != 2)\n" + " fprintf(stderr, \"aether: %%s\\n\", _aether_lib_abi_why);\n" + " return 0;\n" + "}\n" + "#define AETHER_LIB_ABI_GUARD(fail) \\\n" + " if (atomic_load_explicit(&_aether_lib_abi, memory_order_relaxed) != 1 && \\\n" + " (atomic_load_explicit(&_aether_lib_abi, memory_order_relaxed) == 2 || !_aether_lib_abi_check())) { \\\n" + " aether_exec_note_outcome(6 /* AETHER_INCOMPATIBLE */, _aether_lib_abi_why); fail; }\n" + "AETHER_WEAK_DEF int %s(const char** why) {\n" + " int ok = atomic_load_explicit(&_aether_lib_abi, memory_order_relaxed) == 1 ||\n" + " (atomic_load_explicit(&_aether_lib_abi, memory_order_relaxed) == 0 && _aether_lib_abi_check());\n" + " if (why) *why = ok ? \"\" : _aether_lib_abi_why;\n" + " return ok ? 0 : -1;\n" + "}\n\n", + AETHER_VERSION_NUM, AETHER_RUNTIME_ABI_REV, AETHER_VERSION, AETHER_RUNTIME_ABI_ID, check_sym); + /* The host API a C host reaches the library's runtime through (the + * deadline, memory cap, caller, outcome and accounts) is kept by the + * runtime itself, in aether_exec.c, which every library with a runtime + * of its own links through these stubs (#3439). Kept here, it made a + * library meant to bind to its host's runtime reference objects that + * host never linked. */ + if (gen->csrc_header_file) + fprintf(gen->csrc_header_file, + "/* How the last call into an export on this thread ended: a zero\n" + " * result with an outcome other than AETHER_OK (0) is not a result.\n" + " * The constants are in libaether.h; see docs/embedding.md. */\n" + "int aether_last_outcome(void);\n" + "const char* aether_last_outcome_text(void);\n\n"); + for (int i = 0; i < program->child_count; i++) { ASTNode* child = program->children[i]; ASTNode* fn = child; @@ -3417,7 +3631,13 @@ static void emit_lib_alias_stubs(CodeGenerator* gen, ASTNode* program) { fprintf(gen->csrc_header_file, ");\n"); } - // Emit: RET aether_NAME(PARAMS) { [return] NAME(args); } + /* Emit, in an execution frame (#2955, #2956): + * RET aether_NAME(PARAMS) { + * AETHER_EXEC_STUB_ENTER(return (RET){0}); + * RET _aether_r = NAME(args); + * aether_exec_leave(); + * return _aether_r; + * } */ fprintf(gen->output, "%s %s(", ret_abi, alias); if (param_count == 0) { fprintf(gen->output, "void"); @@ -3427,8 +3647,20 @@ static void emit_lib_alias_stubs(CodeGenerator* gen, ASTNode* program) { fprintf(gen->output, "%s %s", param_types[k], param_names[k]); } } - fprintf(gen->output, ") {\n "); - if (strcmp(ret_abi, "void") != 0) fprintf(gen->output, "return "); + fprintf(gen->output, ") {\n"); + int stub_returns = strcmp(ret_abi, "void") != 0; + int frameless = lib_export_frameless(gen, program, fn, result); + /* #2957: none of its code runs on another runtime than its own. */ + if (stub_returns) fprintf(gen->output, " AETHER_LIB_ABI_GUARD(return (%s){0});\n", ret_abi); + else fprintf(gen->output, " AETHER_LIB_ABI_GUARD(return);\n"); + if (frameless) + ; /* nothing can end it from outside its code */ + else if (stub_returns) + fprintf(gen->output, " AETHER_EXEC_STUB_ENTER(return (%s){0});\n", ret_abi); + else + fprintf(gen->output, " AETHER_EXEC_STUB_ENTER(return);\n"); + fprintf(gen->output, " "); + if (stub_returns) fprintf(gen->output, "%s _aether_r = ", ret_abi); /* #2297: a typed struct pointer returns as the opaque AetherValue*; * cast, as the parameter side does, or GCC 14+ rejects the * incompatible pointer under -Werror. */ @@ -3452,7 +3684,13 @@ static void emit_lib_alias_stubs(CodeGenerator* gen, ASTNode* program) { fprintf(gen->output, "%s", param_names[k]); } } - fprintf(gen->output, ");\n}\n"); + fprintf(gen->output, ");\n"); + if (frameless) + fprintf(gen->output, " aether_exec_ok();\n%s}\n", stub_returns ? " return _aether_r;\n" : ""); + else if (stub_returns) + emit_lib_stub_return(gen, result, ret_abi); + else + fprintf(gen->output, " aether_exec_leave();\n}\n"); } /* #2349: an exported builder of a package module. A builder's ABI is its @@ -3491,9 +3729,11 @@ static void emit_lib_alias_stubs(CodeGenerator* gen, ASTNode* program) { Type* rt = b->node_type; int unannotated = (!rt || rt->kind == TYPE_VOID || rt->kind == TYPE_UNKNOWN); int returns = 1; - if (unannotated && has_return_value(b)) fprintf(gen->output, "int"); - else if (unannotated) { fprintf(gen->output, "void"); returns = 0; } - else fprintf(gen->output, "%s", c_result_type(rt)); + const char* bret = "void"; + if (unannotated && has_return_value(b)) bret = "int"; + else if (unannotated) returns = 0; + else bret = c_result_type(rt); + fprintf(gen->output, "%s", bret); fprintf(gen->output, " %s(", sym); int np = 0; for (int p = 0; p < b->child_count; p++) { @@ -3508,15 +3748,29 @@ static void emit_lib_alias_stubs(CodeGenerator* gen, ASTNode* program) { fprintf(gen->output, " %s", c->value); } } - fprintf(gen->output, "%svoid* _builder) {\n %s%s(", np ? ", " : "", - returns ? "return " : "", b->value); + /* In an execution frame, as the alias stubs (#2955, #2956). */ + fprintf(gen->output, "%svoid* _builder) {\n", np ? ", " : ""); + if (returns) fprintf(gen->output, " AETHER_LIB_ABI_GUARD(return (%s){0});\n", bret); + else fprintf(gen->output, " AETHER_LIB_ABI_GUARD(return);\n"); + if (returns) + fprintf(gen->output, " AETHER_EXEC_STUB_ENTER(return (%s){0});\n", bret); + else + fprintf(gen->output, " AETHER_EXEC_STUB_ENTER(return);\n"); + if (returns) fprintf(gen->output, " %s _aether_r = %s(", bret, b->value); + else fprintf(gen->output, " %s(", b->value); for (int p = 0; p < b->child_count; p++) { ASTNode* c = b->children[p]; if (!c || c->type == AST_GUARD_CLAUSE) continue; if (c->type == AST_BLOCK) break; fprintf(gen->output, "%s, ", c->value); } - fprintf(gen->output, "_builder);\n}\n"); + fprintf(gen->output, "_builder);\n"); + if (returns && unannotated) + emit_lib_stub_return(gen, NULL, bret); + else if (returns) + emit_lib_stub_return(gen, rt, bret); + else + fprintf(gen->output, " aether_exec_leave();\n}\n"); } /* #996 --emit=csrc: close the header guard. */ @@ -4226,7 +4480,9 @@ static void emit_lib_metadata(CodeGenerator* gen, ASTNode* program) { " const char* const* closure_modules;\n" " int actors;\n" " const unsigned char* closure_config_unused;\n" - " const char* string_release; };\n\n"); + " const char* string_release;\n" + " const char* outcome;\n" + " int host_abi; const char* runtime_abi; const char* requires; };\n\n"); fprintf(gen->output, "static const struct _AetherLibFn _aether_lib_fns[] = {\n"); @@ -4595,8 +4851,6 @@ static void emit_lib_metadata(CodeGenerator* gen, ASTNode* program) { /* #2349: in a package library each closure record belongs to a module * too (a package builder's wrapper is aether___); * parallel to the closure table, "" for the entry's own. */ - int pkg_closures = 0; - for (int r = 0; r < clo_count; r++) if (rec_mod[r] && rec_mod[r][0]) pkg_closures = 1; if (pkg_lib && clo_count > 0) { fprintf(gen->output, "static const char* const _aether_lib_closure_modules[] = {\n"); for (int r = 0; r < clo_count; r++) { @@ -4638,16 +4892,15 @@ static void emit_lib_metadata(CodeGenerator* gen, ASTNode* program) { } fprintf(gen->output, "static const struct _AetherLibMeta _aether_lib_meta = {\n"); - /* Schema is the highest feature level present: "1.2" once any constant - * record exists, else "1.1" with closures, else "1.0" — which keeps a - * function-only artifact byte-identical to v1 for existing readers. The - * trailing slots are always written (the struct always has them); older - * readers stop at the count/pointer they know. */ - /* "1.5": the library links the shared runtime (#2297). */ - /* "1.6": a package library's builder records carry their module. */ - /* "1.7": the library runs actors, its own or an imported library's. */ - /* "1.8": a builder record says its builder never uses its config. */ - /* "1.9": an export returns a string, given back through string_release. */ + /* The schema was the highest feature level present ("1.0" functions, + * "1.1" closures, "1.2" constants, "1.3" structs or source signatures, + * "1.4" a package, "1.5" the shared runtime, "1.6" package builders, + * "1.7" actors, "1.8" a builder that never uses its config, "1.9" a + * returned string, "1.10" the outcome accessor, #2956). Every library + * now has the last slots, `host_abi`, `runtime_abi` and `requires` + * (#2957), so every catalog is "1.11". The trailing slots are always + * written (the struct always has them); older readers stop at the + * count/pointer they know. */ int strings_returned = 0; for (int i = 0; i < fn_count && !strings_returned; i++) { LibSig rs; @@ -4663,18 +4916,21 @@ static void emit_lib_metadata(CodeGenerator* gen, ASTNode* program) { rs.result->kind == TYPE_STRING) strings_returned = 1; } int lib_runs_actors = gen->actor_count > 0 || gen->lib_actors; - const char* schema = strings_returned ? "1.9" - : config_unused_any ? "1.8" - : lib_runs_actors ? "1.7" - : (pkg_lib && pkg_closures) ? "1.6" - : gen->lib_shared_runtime ? "1.5" - : pkg_lib ? "1.4" - : (struct_count > 0 || src_sig_needed) ? "1.3" - : (const_count > 0) ? "1.2" - : (clo_count > 0) ? "1.1" - : "1.0"; + /* #2957: "1.11" adds host_abi, runtime_abi and requires, the duties an + * importer must perform to use this library, each one it knows by name; + * one it does not know makes it refuse the library. The 1.10 duty is + * the outcome check after every call; a returned string is given back; + * a shared-runtime library makes the program link the shared runtime, + * whose runtime ABI must be the library's; a library that runs actors + * has the program run the scheduler. */ + const char* schema = "1.11"; + char requires[160]; + snprintf(requires, sizeof(requires), "outcome_check%s%s%s", + strings_returned ? ",string_release" : "", + gen->lib_shared_runtime ? ",shared_runtime,runtime_abi" : "", + lib_runs_actors ? ",actors" : ""); fprintf(gen->output, " \"%s\", \"", schema); - fprintf(gen->output, "%s", "0.0.0-dev"); /* version string filled in by build glue if available */ + fprintf(gen->output, "%s", AETHER_VERSION); /* the aetherc that wrote it */ fprintf(gen->output, "\", "); emit_lib_metadata_c_string_literal(gen->output, primary_src); fprintf(gen->output, ", %d, _aether_lib_fns, ", fn_count); @@ -4695,8 +4951,13 @@ static void emit_lib_metadata(CodeGenerator* gen, ASTNode* program) { else fprintf(gen->output, "NULL, "); fprintf(gen->output, "%d, %s, ", lib_runs_actors ? 1 : 0, config_unused_any ? "_aether_lib_closure_config_unused" : "NULL"); - if (strings_returned) fprintf(gen->output, "\"%s\"\n};\n\n", lib_string_release_symbol(gen)); - else fprintf(gen->output, "NULL\n};\n\n"); + if (strings_returned) fprintf(gen->output, "\"%s\", ", lib_string_release_symbol(gen)); + else fprintf(gen->output, "NULL, "); + fprintf(gen->output, "\"%s\",\n", lib_outcome_symbol(gen)); + /* #2957: the host ABI level, the runtime ABI of the headers this C is + * compiled against (aether_abi.h, the one the stubs check at their + * first call), and the duties. */ + fprintf(gen->output, " %d, AETHER_RUNTIME_ABI_ID, \"%s\"\n};\n\n", AETHER_HOST_ABI, requires); /* #996 --emit=csrc: serialize the identical catalog as JSON alongside the * C struct. Driven by the same fns[]/closure/const tables emitted above, so @@ -4707,12 +4968,26 @@ static void emit_lib_metadata(CodeGenerator* gen, ASTNode* program) { FILE* j = gen->csrc_catalog_file; fputs("{\n", j); fputs(" \"schema_version\": ", j); emit_json_string(j, schema); fputs(",\n", j); - fputs(" \"aether_version\": ", j); emit_json_string(j, "0.0.0-dev"); fputs(",\n", j); + fputs(" \"aether_version\": ", j); emit_json_string(j, AETHER_VERSION); fputs(",\n", j); fputs(" \"primary_source\": ", j); emit_json_string(j, primary_src); fputs(",\n", j); + fprintf(j, " \"host_abi\": %d,\n", AETHER_HOST_ABI); + fputs(" \"runtime_abi\": ", j); emit_json_string(j, AETHER_RUNTIME_ABI_ID); fputs(",\n", j); + fputs(" \"requires\": [", j); + for (const char* p = requires; *p;) { + const char* e = strchr(p, ','); + size_t n = e ? (size_t)(e - p) : strlen(p); + if (p != requires) fputs(", ", j); + emit_json_string(j, cg_intern_n(p, n)); + p += n + (e ? 1 : 0); + } + fputs("],\n", j); if (gen->lib_shared_runtime) { fputs(" \"runtime\": ", j); emit_json_string(j, "shared"); fputs(",\n", j); } if (lib_runs_actors) fputs(" \"actors\": true,\n", j); + fputs(" \"outcome\": ", j); + emit_json_string(j, lib_outcome_symbol(gen)); + fputs(",\n", j); if (strings_returned) { fputs(" \"string_release\": ", j); emit_json_string(j, lib_string_release_symbol(gen)); @@ -5597,8 +5872,11 @@ static void emit_main_prologue(CodeGenerator* gen, int runs_scheduler, int needs * carries neither the table nor the call. */ emit_trace_message_name_table(gen); print_line(gen, ""); + /* #3375: the runtime prints the configuration when AETHER_VERBOSE + * is in the environment; a program compiled with -DAETHER_VERBOSE + * asks too, and it is printed once either way. */ print_line(gen, "#ifdef AETHER_VERBOSE"); - print_line(gen, "aether_print_config();"); + print_line(gen, "aether_print_config_once();"); print_line(gen, "#endif"); print_line(gen, ""); print_line(gen, "scheduler_start();"); @@ -5654,27 +5932,8 @@ static void emit_main_epilogue(CodeGenerator* gen, int runs_scheduler) { print_line(gen, ""); print_line(gen, "// Wait for quiescence, stop scheduler threads, and join them"); print_line(gen, "scheduler_shutdown();"); - - // Print message pool statistics (only for actor programs) - print_line(gen, ""); - print_line(gen, "// Message pool statistics"); - print_line(gen, "{"); - indent(gen); - print_line(gen, "uint64_t pool_hits = 0, pool_misses = 0, too_large = 0;"); - print_line(gen, "aether_message_pool_stats(&pool_hits, &pool_misses, &too_large);"); - print_line(gen, "if (pool_hits + pool_misses + too_large > 0) {"); - indent(gen); - print_line(gen, "printf(\"\\n=== Message Pool Statistics ===\\n\");"); - print_line(gen, "printf(\"Pool hits: %%llu\\n\", (unsigned long long)pool_hits);"); - print_line(gen, "printf(\"Pool misses: %%llu (exhausted)\\n\", (unsigned long long)pool_misses);"); - print_line(gen, "printf(\"Too large: %%llu (>256 bytes)\\n\", (unsigned long long)too_large);"); - print_line(gen, "uint64_t total = pool_hits + pool_misses + too_large;"); - print_line(gen, "double hit_rate = (double)pool_hits / total * 100.0;"); - print_line(gen, "printf(\"Hit rate: %%.1f%%%%\\n\", hit_rate);"); - unindent(gen); - print_line(gen, "}"); - unindent(gen); - print_line(gen, "}"); + /* #3374: no message pool statistics after it. There is no pool; its + * counters were never incremented, so the block never printed. */ print_line(gen, ""); } @@ -7188,7 +7447,7 @@ static void emit_sandbox_trust_wrappers(CodeGenerator* gen, ASTNode* program) { } if (pc == 0) fprintf(gen->output, "void"); fprintf(gen->output, ") {\n"); - fprintf(gen->output, " int _aether_sbx_saved = _aether_sandbox_trust_enter(%d);\n", site); + fprintf(gen->output, " int _aether_sbx_saved = aether_sandbox_trust_enter(&_aether_sandbox_image, %d);\n", site); fprintf(gen->output, " "); if (returns) { emit_trust_return_type(gen, def); @@ -7199,7 +7458,7 @@ static void emit_sandbox_trust_wrappers(CodeGenerator* gen, ASTNode* program) { fprintf(gen->output, "%s_aether_sbx_p%d", k ? ", " : "", k); } fprintf(gen->output, ");\n"); - fprintf(gen->output, " _aether_sandbox_depth = _aether_sbx_saved;\n"); + fprintf(gen->output, " aether_sandbox_restore(_aether_sbx_saved);\n"); if (returns) fprintf(gen->output, " return _aether_sbx_r;\n"); fprintf(gen->output, "}\n"); } @@ -8239,6 +8498,156 @@ static int program_uses_lanes(const ASTNode* n) { return 0; } +/* One message's C: its struct, the release of the fields it owns, its + * registry entry (the id its sends and its receive arms use) and its + * --emit-header entry. generate_program emits every message this way + * ahead of the actors and functions, as it does the structs, so the order + * of the merged program does not matter: an actor generated before a + * message it handled had no id for it, so the arm was dropped, and an + * imported message, which the merge puts before main, could not be + * handled by the program's own actors at all (#3341). */ +static void emit_message_definition(CodeGenerator* gen, ASTNode* child) { + // Generate optimized message struct with field packing + if (child && child->value) { + int field_count = 0; + for (int i = 0; i < child->child_count; i++) { + if (child->children[i] && child->children[i]->type == AST_MESSAGE_FIELD) { + field_count++; + } + } + + print_line(gen, "// Message: %s (%d fields)", child->value, field_count); + + // Natural alignment only (#2509). Messages with more than + // four fields used to be declared aligned(64), but a + // payload lives in a malloc'd copy (aether_send_message), + // which promises 16 bytes, and the receiver reads it + // through a pointer of this type: undefined behaviour, + // and aligned vector moves the compiler may pick for it + // fault. One reader per copy, so the cache line bought + // nothing, and it padded every such message to 64 bytes. + print_line(gen, "typedef struct %s {", child->value); + indent(gen); + // The field order and types are the ones the embedding + // header gets (#2517). + emit_message_struct_body(child, gen->output, gen->indent_level); + + MessageFieldDef* first_field = NULL; + MessageFieldDef* last_field = NULL; + + // Build field list for registry. We store the resolved + // C type for each field so downstream codegen (receive + // destructuring, struct-literal send-side) can emit the + // correct type without re-deriving it from type_kind, + // which loses information for composite types like + // `string[]` (element type) and structs (struct name). + for (int i = 0; i < child->child_count; i++) { + ASTNode* field = child->children[i]; + if (field && field->type == AST_MESSAGE_FIELD) { + MessageFieldDef* field_def = (MessageFieldDef*)malloc(sizeof(MessageFieldDef)); + field_def->name = strdup(field->value); + field_def->type_kind = field->node_type ? field->node_type->kind : TYPE_UNKNOWN; + field_def->c_type = NULL; + field_def->element_c_type = NULL; + field_def->owned = tc_type_is_owned(field->node_type); /* #3142 */ + if (field->node_type) { + const char* resolved = get_c_type(field->node_type); + if (resolved) { + field_def->c_type = strdup(resolved); + } + if (field->node_type->kind == TYPE_ARRAY && + field->node_type->element_type) { + const char* elem = get_c_type(field->node_type->element_type); + if (elem) { + field_def->element_c_type = strdup(elem); + } + } + } + field_def->next = NULL; + + if (!first_field) { + first_field = field_def; + last_field = field_def; + } else { + last_field->next = field_def; + last_field = field_def; + } + } + } + unindent(gen); + print_line(gen, "} %s;", child->value); + print_line(gen, ""); + /* The death notice the runtime writes (#2974): this + * struct must be its AetherDownMsg. */ + if (child->annotation && strcmp(child->annotation, "system") == 0 && + strcmp(child->value, "Down") == 0) { + print_line(gen, "#if defined(__STDC_VERSION__) && __STDC_VERSION__ >= 201112L"); + print_line(gen, "_Static_assert(sizeof(Down) == sizeof(AetherDownMsg) &&"); + print_line(gen, " offsetof(Down, ref) == offsetof(AetherDownMsg, ref) &&"); + print_line(gen, " offsetof(Down, reason) == offsetof(AetherDownMsg, reason),"); + print_line(gen, " \"Down must be the runtime's AetherDownMsg\");"); + print_line(gen, "#endif"); + print_line(gen, ""); + } + + /* Per-message heap-string-field release function + * (#466). Walked by the receive-handler dispatch + * before `aether_free_message` so the deep- + * copied AetherString allocations the sender + * stamped in (codegen_expr.c send-side) are + * reclaimed when the recipient is done with the + * message. No-op for messages with no string + * fields; the codegen_actor.c dispatch skips + * the call in that case via + * `message_has_string_field`. */ + if (message_owns_fields(gen, child->value)) { + /* Forward-declare `string_release` here so the + * generated release_fields function compiles + * even on tests that don't `import std.string` + * (and therefore don't get the extern + * registry's prototype). The duplicate-but- + * matching declaration is tolerated by C — + * matches the registry's + * `void string_release(const char*)` exactly + * when std.string IS imported. */ + print_line(gen, "extern void string_release(const char*);"); + print_line(gen, "static inline void _aether_msg_release_%s(%s* m) {", + child->value, child->value); + indent(gen); + print_line(gen, "if (!m) return;"); + /* Each field the message owns: a string the sender + * copied, a closure or struct it took (#2525, #2921). */ + for (int fi = 0; fi < child->child_count; fi++) { + ASTNode* f = child->children[fi]; + if (f && f->type == AST_MESSAGE_FIELD && own_type_owns(gen, f->node_type)) + own_emit_part(gen, f->node_type, cg_internf("m->%s", f->value), OWN_DROP); + /* #3142: an owned pointer the handler did not take + * (its binding empties the field), or a message + * that was never handled. */ + else if (f && f->type == AST_MESSAGE_FIELD) + emit_owned_ptr_drop(gen, f->node_type, cg_internf("m->%s", f->value)); + } + unindent(gen); + print_line(gen, "}"); + print_line(gen, ""); + } + + // Generate type-specific memory pool for this message type + print_line(gen, "// Type-specific memory pool for %s", child->value); + print_line(gen, "// DECLARE_TYPE_POOL(%s)", child->value); + print_line(gen, "// DECLARE_TLS_POOL(%s)", child->value); + print_line(gen, ""); + + register_message_type(gen->message_registry, child->value, first_field); + + // The --emit-header file; #996 had gated this on the + // --emit=csrc catalog header, which left it empty (#2517). + if (gen->header_file) { + emit_message_to_header(gen, child); + } + } +} + void generate_program(CodeGenerator* gen, ASTNode* program) { if (!program || program->type != AST_PROGRAM) return; gen->program = program; @@ -8324,6 +8733,78 @@ void generate_program(CodeGenerator* gen, ASTNode* program) { print_line(gen, "#endif"); emit_libc_extern_headers(gen, program); print_line(gen, "#include \"aether_panic.h\""); + /* #2956: a `try` that catches an interruption (a library call's deadline + * or cancel) re-raises it after its own cleanup. */ + print_line(gen, "extern int aether_exec_interrupting(void);"); + print_line(gen, "extern void aether_exec_reraise(void);"); + /* Imported wrappers and trailing-underscore privates are emitted with + internal linkage, so a translation unit that imports a module and calls + only part of it has unused statics left over. Downstream builds with + -Wall -Werror would fail on generated code they did not write, so the + storage class carries the attribute rather than the consumer carrying a + -Wno flag. The allocation helpers below carry it too (#3425): clang + warns of an unused static inline function in a .c file, gcc does not. */ + print_line(gen, "#ifndef AETHER_MAYBE_UNUSED"); + print_line(gen, "# if defined(__GNUC__) || defined(__clang__)"); + print_line(gen, "# define AETHER_MAYBE_UNUSED __attribute__((unused))"); + print_line(gen, "# else"); + print_line(gen, "# define AETHER_MAYBE_UNUSED"); + print_line(gen, "# endif"); + print_line(gen, "#endif"); + /* #3377: every allocation the generated code makes for itself (a + * captured variable's cell, a ref, a closure's environment or box, a + * thunk, heap.new) goes through _aether_alloc. Out of memory, it ends a + * library call with AETHER_OUT_OF_MEMORY and is a panic naming what ran + * out elsewhere (aether_out_of_memory); the result used to be used + * unchecked, a NULL dereference. A malloc the program calls itself keeps + * returning NULL (_aether_alloc_or_null) and ends a library call at its + * next safe point. The flag and the refusal are the runtime's test hook + * (aether_test_fail_alloc_after): one load while no test armed it. The + * names are parenthesized where the helpers are defined, so a test can + * wrap the call sites in a macro of the same name (closure_local_env_free + * counts closure environments and cells that way). + * + * #3455: a library's references to the runtime's data are weak on ELF + * and Mach-O, as its callback registry's is (below): a library linked + * with its runtime left to the host (a raw `cc -shared`, a gateway + * script) binds a function at its first call, but a datum when it is + * loaded, so with the flags strong it did not load at all where no + * runtime was yet (`ae lib-info`). A flag that is absent reads as + * unarmed. A PE DLL is always fully linked, and a weak reference does + * not bind to a DLL import, so there they stay strong (Cygwin's and + * MSYS2's gcc make PE too, and define no _WIN32: a weak flag read as + * absent there would have left a shared-runtime library's loops + * unpolled); so do they in an executable, which links its runtime. + * Loaded before any runtime with lazy binding, such a library now runs + * with the flags absent, so without deadline polls, where it would not + * have loaded at all: a load order no host Aether makes uses. */ + if (gen->emit_lib) { + print_line(gen, "#if !defined(_WIN32) && !defined(__CYGWIN__) && (defined(__GNUC__) || defined(__clang__))"); + print_line(gen, "#define AETHER_RT_DATA __attribute__((weak))"); + print_line(gen, "#define AETHER_RT_DATA_PRESENT(s) (&(s) != 0)"); + print_line(gen, "#endif"); + } + print_line(gen, "#ifndef AETHER_RT_DATA"); + print_line(gen, "#define AETHER_RT_DATA"); + print_line(gen, "#define AETHER_RT_DATA_PRESENT(s) 1"); + print_line(gen, "#endif"); + print_line(gen, "extern _Atomic int aether_alloc_test_armed AETHER_RT_DATA;"); + print_line(gen, "extern int aether_alloc_test_refuses(void);"); + print_line(gen, "extern void aether_alloc_failed(int why);"); + print_line(gen, "static inline AETHER_MAYBE_UNUSED void* (_aether_alloc)(size_t size, int zeroed, const char* what) {"); + print_line(gen, " void* p = (AETHER_RT_DATA_PRESENT(aether_alloc_test_armed) &&"); + print_line(gen, " atomic_load_explicit(&aether_alloc_test_armed, memory_order_relaxed) &&"); + print_line(gen, " aether_alloc_test_refuses()) ? (void*)0 : zeroed ? calloc(1, size) : malloc(size);"); + print_line(gen, " if (!p) aether_out_of_memory(what);"); + print_line(gen, " return p;"); + print_line(gen, "}"); + print_line(gen, "static inline AETHER_MAYBE_UNUSED void* (_aether_alloc_or_null)(size_t size) {"); + print_line(gen, " void* p = (AETHER_RT_DATA_PRESENT(aether_alloc_test_armed) &&"); + print_line(gen, " atomic_load_explicit(&aether_alloc_test_armed, memory_order_relaxed) &&"); + print_line(gen, " aether_alloc_test_refuses()) ? (void*)0 : calloc(1, size);"); + print_line(gen, " if (!p) aether_alloc_failed(5); /* AETHER_STOP_OUT_OF_MEMORY */"); + print_line(gen, " return p;"); + print_line(gen, "}"); print_line(gen, "#include \"aether_slice.h\""); /* #1286 `T[]` fat pointer */ /* Cons-cell sequence type — std.collections.string_seq. * @@ -8357,14 +8838,21 @@ void generate_program(CodeGenerator* gen, ASTNode* program) { * --emit=exe builds — the gate elides both the extern decls and * the per-loop checks. */ if (gen->emit_lib) { - /* aether_caps_armed is a plain global, nonzero once any thread has - * armed (or tripped) a deadline. Testing it first keeps the loop-head - * check to one load when no deadline is in use: the call and its two - * thread-local reads were ~22% of mquickjs-ae's run time on macOS, - * where each TLS read goes through _tlv_get_addr. */ - print_line(gen, "extern int aether_caps_armed;"); + /* aether_caps_armed is a global, nonzero once any thread has armed + * (or tripped) a deadline or cancelled a call. Testing it first keeps + * the loop-head check to one load when no deadline is in use: the + * call and its two thread-local reads were ~22% of mquickjs-ae's run + * time on macOS, where each TLS read goes through _tlv_get_addr. An + * atomic, loaded relaxed (a plain load on x86 and ARM), so a loop + * sees a cancel another thread makes while it runs (#2956): as a + * plain int, clang loaded it once before a loop that calls nothing. */ + print_line(gen, "extern _Atomic int aether_caps_armed AETHER_RT_DATA; /* weak: see _aether_alloc */"); print_line(gen, "extern int aether_caps_deadline_tripped(void);"); print_line(gen, "extern void __aether_abort_call(void);"); + /* #2956: the safe point. Inside an execution a passed deadline or a + * cancel ends it (runtime/aether_exec.h); outside one the loop + * breaks as before. */ + print_line(gen, "extern int aether_exec_poll(void);"); } print_line(gen, "#ifdef _WIN32"); /* The program TU does not include (#2673): mingw-w64's @@ -8414,19 +8902,8 @@ void generate_program(CodeGenerator* gen, ASTNode* program) { print_line(gen, "#endif"); } /* GCC/Clang vs MSVC: guards for statement expressions ({...}) and computed goto */ - /* Imported wrappers and trailing-underscore privates are emitted with - internal linkage, so a translation unit that imports a module and calls - only part of it has unused statics left over. Downstream builds with - -Wall -Werror would fail on generated code they did not write, so the - storage class carries the attribute rather than the consumer carrying a - -Wno flag. */ - print_line(gen, "#ifndef AETHER_MAYBE_UNUSED"); - print_line(gen, "# if defined(__GNUC__) || defined(__clang__)"); - print_line(gen, "# define AETHER_MAYBE_UNUSED __attribute__((unused))"); - print_line(gen, "# else"); - print_line(gen, "# define AETHER_MAYBE_UNUSED"); - print_line(gen, "# endif"); - print_line(gen, "#endif"); + /* AETHER_MAYBE_UNUSED is defined above, before the first helper that + * carries it. */ /* #3142: `move x` of an owned pointer: its value, and the place left * holding nothing, so the release at the place's exit skips it. */ print_line(gen, "static inline AETHER_MAYBE_UNUSED void* aether_owned_take(void** p) " @@ -8926,6 +9403,7 @@ void generate_program(CodeGenerator* gen, ASTNode* program) { print_line(gen, "typedef void (*AetherUnwindFree)(const void*);"); print_line(gen, "extern void aether_unwind_track(const void*, AetherUnwindFree);"); print_line(gen, "extern void aether_unwind_track_place(const void*, const void*, AetherUnwindFree);"); + print_line(gen, "extern int aether_unwind_track_owned_place(const void*, void (*)(void*));"); print_line(gen, "extern void aether_unwind_forget(const void*);"); print_line(gen, "extern void aether_teardown(void*);"); /* Is `s` a counted AetherString, the one shape string_release frees? @@ -9001,8 +9479,7 @@ void generate_program(CodeGenerator* gen, ASTNode* program) { * releases included. */ print_line(gen, "typedef union { atomic_long _refs; long double _ld; void* _p; long long _ll; } _AeCellHeader;"); print_line(gen, "static inline void* _aether_cell_new(size_t size) {"); - print_line(gen, " _AeCellHeader* h = (_AeCellHeader*)calloc(1, sizeof(_AeCellHeader) + size);"); - print_line(gen, " if (!h) aether_panic(\"out of memory allocating a captured variable\");"); + print_line(gen, " _AeCellHeader* h = (_AeCellHeader*)_aether_alloc(sizeof(_AeCellHeader) + size, 1, \"out of memory allocating a captured variable\");"); print_line(gen, " atomic_init(&h->_refs, 1);"); print_line(gen, " return (void*)(h + 1);"); print_line(gen, "}"); @@ -9349,7 +9826,7 @@ void generate_program(CodeGenerator* gen, ASTNode* program) { print_line(gen, "extern void aether_sleep_ms(int ms);"); // Ref cells: heap-allocated mutable values for shared state in closures print_line(gen, "#if !AETHER_GCC_COMPAT"); - print_line(gen, "static void* _aether_ref_new(intptr_t val) { intptr_t* r = malloc(sizeof(intptr_t)); *r = val; return (void*)r; }"); + print_line(gen, "static void* _aether_ref_new(intptr_t val) { intptr_t* r = (intptr_t*)_aether_alloc(sizeof(intptr_t), 0, \"out of memory allocating a ref\"); *r = val; return (void*)r; }"); print_line(gen, "#endif"); // Closure support: generic closure struct (function pointer + captured environment) print_line(gen, "typedef struct { void (*fn)(void); void* env; } _AeClosure;"); @@ -9432,8 +9909,7 @@ void generate_program(CodeGenerator* gen, ASTNode* program) { print_line(gen, "typedef struct { void (*fn)(void); void* env; unsigned long long tag; } _AeClosureBox;"); print_line(gen, "#define _AE_CLOSURE_TAG 0xAEC105EDB0CEDULL"); print_line(gen, "static inline void* _aether_box_closure(_AeClosure c) {"); - print_line(gen, " _AeClosureBox* p = (_AeClosureBox*)malloc(sizeof(_AeClosureBox));"); - print_line(gen, " if (!p) return (void*)0;"); + print_line(gen, " _AeClosureBox* p = (_AeClosureBox*)_aether_alloc(sizeof(_AeClosureBox), 0, \"out of memory boxing a closure\");"); print_line(gen, " p->fn = c.fn; p->env = c.env; p->tag = _AE_CLOSURE_TAG;"); print_line(gen, " return (void*)p;"); print_line(gen, "}"); @@ -9474,7 +9950,7 @@ void generate_program(CodeGenerator* gen, ASTNode* program) { print_line(gen, "}"); // Lazy evaluation: thunks (deferred computation with memoization) print_line(gen, "typedef struct { _AeClosure compute; intptr_t value; int evaluated; } _AeThunk;"); - print_line(gen, "static inline void* _aether_thunk_new(_AeClosure c) { _AeThunk* t = malloc(sizeof(_AeThunk)); t->compute = c; t->value = 0; t->evaluated = 0; return (void*)t; }"); + print_line(gen, "static inline void* _aether_thunk_new(_AeClosure c) { _AeThunk* t = (_AeThunk*)_aether_alloc(sizeof(_AeThunk), 0, \"out of memory allocating a lazy value\"); t->compute = c; t->value = 0; t->evaluated = 0; return (void*)t; }"); print_line(gen, "static inline intptr_t _aether_thunk_force(void* p) { _AeThunk* t = (_AeThunk*)p; if (!t->evaluated) { t->value = (intptr_t)((intptr_t(*)(void*))t->compute.fn)(t->compute.env); t->evaluated = 1; } return t->value; }"); // thunk_free: free the thunk struct. The closure env is owned by // the closure variable (auto-deferred), not the thunk. The thunk @@ -9523,100 +9999,30 @@ void generate_program(CodeGenerator* gen, ASTNode* program) { * grant that worked in a plain function was refused inside a * `spec.describe` block. Two different things were sharing one stack. */ // Only emit sandbox bridge code if the program actually uses sandbox builtins. - // This avoids preamble bloat and the list_size/list_get dependency for programs - // that don't use sandboxing. bool has_sandbox = uses_sandbox(program); gen->uses_sandbox = has_sandbox ? 1 : 0; - /* Per thread: an enforce block contains the code that runs on its own - * thread. With one stack for the process, every other thread (an actor's, - * a std.worker's) was checked against the block's grants while it ran, - * and the stack itself was raced. These live in this translation unit, - * written and read only by its own code, so the TLS model never crosses - * an archive boundary (#1751). */ - print_line(gen, "static AETHER_TLS void* _aether_sandbox_stack[64];"); - print_line(gen, "static AETHER_TLS int _aether_sandbox_depth = 0;"); - if (has_sandbox) { - /* Trusted names in `sandbox.enforce(perms, foo) { ... }`: each level - * of the stack records which enforce site pushed it (0 for any other - * push). A trusted call runs with the authority of the code that - * wrote the enforce: it pushes a barrier, a level whose skip_to - * names the level its own site was entered at, and the checker - * walking down from the top jumps from the barrier to below that - * level, so the levels between (the block's grants, and any - * enforce inside it) do not apply. Never higher: a site is looked - * for only among the levels in force (not ones an earlier barrier - * hides), and one found nowhere on the live stack (a block that - * escaped its enforce, a level a panic abandoned) pushes nothing. - * - * It used to drop the depth to that level instead, and put it back - * after. An enforce inside the trusted function then pushed into the - * level it had dropped, over the block's grants, and freed what it - * pushed when it ended: the block was checked against freed memory - * from then on (allowing or refusing whatever that held), and with - * the depth at 0 the inner enforce's end uninstalled the checker for - * the rest of the block. A barrier only ever adds a level above the - * ones it hides, so nothing pushed inside reaches them, and a panic - * unwound by a `try`, which puts the depth back, leaves them as - * they were. */ - print_line(gen, "static AETHER_TLS int _aether_sandbox_site_at[64];"); - print_line(gen, "static AETHER_TLS int _aether_sandbox_skip_to[64];"); - print_line(gen, "static AETHER_TLS int _aether_sandbox_pending_site = 0;"); - print_line(gen, "static inline void _aether_sandbox_push(void* ctx) { if (_aether_sandbox_depth < 64) { _aether_sandbox_site_at[_aether_sandbox_depth] = _aether_sandbox_pending_site; _aether_sandbox_skip_to[_aether_sandbox_depth] = -1; _aether_sandbox_stack[_aether_sandbox_depth++] = ctx; } _aether_sandbox_pending_site = 0; }"); - print_line(gen, "static inline AETHER_MAYBE_UNUSED int _aether_sandbox_trust_enter(int site) { int saved = _aether_sandbox_depth; for (int l = _aether_sandbox_depth - 1; l >= 0; l--) { if (_aether_sandbox_skip_to[l] >= 0) { l = _aether_sandbox_skip_to[l]; continue; } if (_aether_sandbox_site_at[l] == site) { if (_aether_sandbox_depth < 64) { _aether_sandbox_site_at[_aether_sandbox_depth] = 0; _aether_sandbox_skip_to[_aether_sandbox_depth] = l; _aether_sandbox_stack[_aether_sandbox_depth++] = (void*)0; } break; } } return saved; }"); - } else { - print_line(gen, "static inline void _aether_sandbox_push(void* ctx) { if (_aether_sandbox_depth < 64) _aether_sandbox_stack[_aether_sandbox_depth++] = ctx; }"); - } - print_line(gen, "static inline void _aether_sandbox_pop(void) { if (_aether_sandbox_depth > 0) _aether_sandbox_depth--; }"); + /* #2955: the grant levels are each thread's, kept by the runtime + * (runtime/aether_sandbox.c) and reached only through its functions, so + * every program and library on one runtime image shares a thread's + * levels: a library's export called inside another's enforce block is + * held to that block's grants, and can only narrow them. They used to + * live here, a stack per translation unit, with one process-wide + * checker pointer each enforce installed and cleared: a thread leaving + * its block switched off another thread's (or that thread called the + * pointer just cleared), and a nested library's stack was empty, or + * replaced the outer checker and then cleared it. A caught panic, in + * any code, leaves the levels pushed inside its try frame (the runtime + * does that in aether_panic); the per-thread stack was #1751's fix for + * other threads checked against a block's grants, and stays so. */ if (has_sandbox) { - // Sandbox bridge: connects compiler-generated context stack to runtime checks. - print_line(gen, "typedef int (*aether_sandbox_check_fn)(const char*, const char*);"); - print_line(gen, "extern aether_sandbox_check_fn _aether_sandbox_checker;"); - print_line(gen, "extern int list_size(void*);"); - print_line(gen, "extern void* list_get_raw(void*, int);"); - print_line(gen, "#include \"aether_sandbox_match.h\""); - print_line(gen, "static int _aether_perms_allow(void* ctx, const char* category, const char* resource) {"); - print_line(gen, " if (!ctx) return 1;"); - print_line(gen, " int n = list_size(ctx);"); - print_line(gen, " if (n == 0) return 0;"); - print_line(gen, " for (int i = 0; i < n; i += 2) {"); - /* An element is either string shape (a plain char* or an - * AetherString, as a grant copied by a wrapper that keeps its - * parameter is): read it through aether_string_data. */ - print_line(gen, " const void* cat_v = list_get_raw(ctx, i);"); - print_line(gen, " const void* pat_v = list_get_raw(ctx, i + 1);"); - print_line(gen, " if (!cat_v || !pat_v) continue;"); - print_line(gen, " const char* cat = aether_string_data(cat_v);"); - print_line(gen, " const char* pat = aether_string_data(pat_v);"); - print_line(gen, " if (cat[0] == '*' && pat[0] == '*') return 1;"); - /* The shared matcher (runtime/aether_sandbox_match.h): globs, ports - * for tcp/udp, IPv4-mapped addresses. */ - print_line(gen, " if (strcmp(cat, category) == 0 && aether_grant_match(category, pat, resource)) return 1;"); - print_line(gen, " }"); - print_line(gen, " return 0;"); - print_line(gen, "}"); - print_line(gen, "extern void aether_sandbox_audit(const char*, const char*, int);"); - print_line(gen, "static int _aether_sandbox_check_impl(const char* category, const char* resource) {"); - print_line(gen, " if (_aether_sandbox_depth <= 0) return 1;"); - print_line(gen, " int _allowed = 1;"); - /* From the top down, a trusted call's barrier jumping over the - * levels it hides. With none left in force the check is the one an - * unsandboxed program makes: allowed, and not audited. */ - print_line(gen, " int _in_force = 0;"); - print_line(gen, " for (int level = _aether_sandbox_depth - 1; level >= 0; level--) {"); - print_line(gen, " if (_aether_sandbox_skip_to[level] >= 0) { level = _aether_sandbox_skip_to[level]; continue; }"); - print_line(gen, " _in_force = 1;"); - print_line(gen, " if (!_aether_perms_allow(_aether_sandbox_stack[level], category, resource)) { _allowed = 0; break; }"); - print_line(gen, " }"); - print_line(gen, " if (!_in_force) return 1;"); - // Audit every in-process permission check — allowed and denied. - // The sink is opt-in (AETHER_SANDBOX_AUDIT) and the ring buffer - // is cheap, so this is unconditional. Note this runs only when a - // sandbox is active (_aether_sandbox_depth > 0). - print_line(gen, " aether_sandbox_audit(category, resource, _allowed);"); - print_line(gen, " return _allowed;"); - print_line(gen, "}"); - print_line(gen, "static void _aether_sandbox_install(void) { _aether_sandbox_checker = _aether_sandbox_check_impl; }"); - print_line(gen, "static void _aether_sandbox_uninstall(void) { if (_aether_sandbox_depth <= 0) _aether_sandbox_checker = 0; }"); + /* This code's identity for trusted names: a site number is unique + * only within one program or library, and the levels are shared. */ + print_line(gen, "static AETHER_MAYBE_UNUSED char _aether_sandbox_image;"); + print_line(gen, "extern void aether_sandbox_push(void* grants);"); + print_line(gen, "extern void aether_sandbox_pop(void);"); + print_line(gen, "extern void aether_sandbox_tag_site(const void* image, int site);"); + print_line(gen, "extern int aether_sandbox_trust_enter(const void* image, int site);"); + print_line(gen, "extern void aether_sandbox_restore(int depth);"); print_line(gen, "extern int aether_spawn_sandboxed(void* grant_list, const char* program, const char* arg);"); } print_line(gen, ""); @@ -10457,6 +10863,14 @@ void generate_program(CodeGenerator* gen, ASTNode* program) { if (program_releases_messages(gen)) print_line(gen, "static void _aether_release_message(int type, void* payload);"); + /* Every message before the actors and functions that send it or + * handle it (#3341). */ + for (int i = 0; i < program->child_count; i++) { + ASTNode* child = program->children[i]; + if (child && child->type == AST_MESSAGE_DEFINITION) + emit_message_definition(gen, child); + } + for (int i = 0; i < program->child_count; i++) { ASTNode* child = program->children[i]; if (!child) continue; @@ -10527,133 +10941,8 @@ void generate_program(CodeGenerator* gen, ASTNode* program) { } break; case AST_MESSAGE_DEFINITION: - // Generate optimized message struct with field packing - if (child && child->value) { - int field_count = 0; - for (int i = 0; i < child->child_count; i++) { - if (child->children[i] && child->children[i]->type == AST_MESSAGE_FIELD) { - field_count++; - } - } - - print_line(gen, "// Message: %s (%d fields)", child->value, field_count); - - // Natural alignment only (#2509). Messages with more than - // four fields used to be declared aligned(64), but a - // payload lives in a malloc'd copy (aether_send_message), - // which promises 16 bytes, and the receiver reads it - // through a pointer of this type: undefined behaviour, - // and aligned vector moves the compiler may pick for it - // fault. One reader per copy, so the cache line bought - // nothing, and it padded every such message to 64 bytes. - print_line(gen, "typedef struct %s {", child->value); - indent(gen); - // The field order and types are the ones the embedding - // header gets (#2517). - emit_message_struct_body(child, gen->output, gen->indent_level); - - MessageFieldDef* first_field = NULL; - MessageFieldDef* last_field = NULL; - - // Build field list for registry. We store the resolved - // C type for each field so downstream codegen (receive - // destructuring, struct-literal send-side) can emit the - // correct type without re-deriving it from type_kind, - // which loses information for composite types like - // `string[]` (element type) and structs (struct name). - for (int i = 0; i < child->child_count; i++) { - ASTNode* field = child->children[i]; - if (field && field->type == AST_MESSAGE_FIELD) { - MessageFieldDef* field_def = (MessageFieldDef*)malloc(sizeof(MessageFieldDef)); - field_def->name = strdup(field->value); - field_def->type_kind = field->node_type ? field->node_type->kind : TYPE_UNKNOWN; - field_def->c_type = NULL; - field_def->element_c_type = NULL; - field_def->owned = tc_type_is_owned(field->node_type); /* #3142 */ - if (field->node_type) { - const char* resolved = get_c_type(field->node_type); - if (resolved) { - field_def->c_type = strdup(resolved); - } - if (field->node_type->kind == TYPE_ARRAY && - field->node_type->element_type) { - const char* elem = get_c_type(field->node_type->element_type); - if (elem) { - field_def->element_c_type = strdup(elem); - } - } - } - field_def->next = NULL; - - if (!first_field) { - first_field = field_def; - last_field = field_def; - } else { - last_field->next = field_def; - last_field = field_def; - } - } - } - unindent(gen); - print_line(gen, "} %s;", child->value); - print_line(gen, ""); - - /* Per-message heap-string-field release function - * (#466). Walked by the receive-handler dispatch - * before `aether_free_message` so the deep- - * copied AetherString allocations the sender - * stamped in (codegen_expr.c send-side) are - * reclaimed when the recipient is done with the - * message. No-op for messages with no string - * fields; the codegen_actor.c dispatch skips - * the call in that case via - * `message_has_string_field`. */ - if (message_owns_fields(gen, child->value)) { - /* Forward-declare `string_release` here so the - * generated release_fields function compiles - * even on tests that don't `import std.string` - * (and therefore don't get the extern - * registry's prototype). The duplicate-but- - * matching declaration is tolerated by C — - * matches the registry's - * `void string_release(const char*)` exactly - * when std.string IS imported. */ - print_line(gen, "extern void string_release(const char*);"); - print_line(gen, "static inline void _aether_msg_release_%s(%s* m) {", - child->value, child->value); - indent(gen); - print_line(gen, "if (!m) return;"); - /* Each field the message owns: a string the sender - * copied, a closure or struct it took (#2525, #2921). */ - for (int fi = 0; fi < child->child_count; fi++) { - ASTNode* f = child->children[fi]; - if (f && f->type == AST_MESSAGE_FIELD && own_type_owns(gen, f->node_type)) - own_emit_part(gen, f->node_type, cg_internf("m->%s", f->value), OWN_DROP); - /* #3142: an owned pointer the handler did not take - * (its binding empties the field), or a message - * that was never handled. */ - else if (f && f->type == AST_MESSAGE_FIELD) - emit_owned_ptr_drop(gen, f->node_type, cg_internf("m->%s", f->value)); - } - unindent(gen); - print_line(gen, "}"); - print_line(gen, ""); - } - - // Generate type-specific memory pool for this message type - print_line(gen, "// Type-specific memory pool for %s", child->value); - print_line(gen, "// DECLARE_TYPE_POOL(%s)", child->value); - print_line(gen, "// DECLARE_TLS_POOL(%s)", child->value); - print_line(gen, ""); - - register_message_type(gen->message_registry, child->value, first_field); - - // The --emit-header file; #996 had gated this on the - // --emit=csrc catalog header, which left it empty (#2517). - if (gen->header_file) { - emit_message_to_header(gen, child); - } - } + // Emitted ahead of this loop with every other message + // (emit_message_definition, #3341). break; case AST_BUILDER_FUNCTION: case AST_FUNCTION_DEFINITION: @@ -10742,7 +11031,7 @@ void generate_program(CodeGenerator* gen, ASTNode* program) { fprintf(gen->output, "\n/* @c_callback registry (#2297): hooks reach a shared runtime by name. */\n" "#if defined(__GNUC__) || defined(__clang__)\n" - "#if defined(_WIN32)\n" + "#if defined(_WIN32) || defined(__CYGWIN__)\n" /* PE: Cygwin and MSYS2's gcc define no _WIN32 */ "extern void aether_callback_register(const char*, void*);\n" "#define AE_CALLBACK_REGISTRY_PRESENT 1\n" "#else\n" diff --git a/compiler/codegen/codegen.h b/compiler/codegen/codegen.h index 7ec820412..4569a3f14 100644 --- a/compiler/codegen/codegen.h +++ b/compiler/codegen/codegen.h @@ -675,6 +675,12 @@ typedef struct { size_t defer_thunks_cap; int defer_thunk_count; long defer_proto_pos; + /* #3413: the drop functions of owned pointer types the unwind journal + * runs through a thunk (owned_drop_thunk), each named once; the thunks + * are written with the defer thunks. */ + const char** owned_thunks; + int owned_thunk_count; + int owned_thunk_cap; int hoist_output_ok; /* Was a `goto main_exit;` written (a `return` in main)? Its label is * written only then. */ diff --git a/compiler/codegen/codegen_actor.c b/compiler/codegen/codegen_actor.c index 4f03e4650..0bcb30434 100644 --- a/compiler/codegen/codegen_actor.c +++ b/compiler/codegen/codegen_actor.c @@ -1,5 +1,14 @@ #include "codegen_internal.h" #include +#include + +/* #2974: the N of an actor's `@mailbox(bound: N)` (the parser's + * "mailbox_bound:N" annotation), 0 for an actor with no bound. */ +static int actor_mailbox_bound(const ASTNode* actor) { + const char* a = actor ? actor->annotation : NULL; + if (!a || strncmp(a, "mailbox_bound:", 14) != 0) return 0; + return atoi(a + 14); +} /* The message name of the `_` arm's handler, `_handle__aether_any`: * no message can be named so, `_aether` names being the compiler's. */ @@ -384,6 +393,33 @@ const char* state_struct_array_owned(CodeGenerator* gen, ASTNode* state_decl, in return sname; } +/* #3386: the state declaration of `field` in the actor the reference `ref` + * names (`r.field`, `r` an actor reference to a Sink), when that state is a + * place that owns what it holds: of an owning type, not widened to a + * pointer, so destroy_state drops it (emit_actor_destroy_state). NULL for + * anything else, a protocol's reference included (no one actor). */ +ASTNode* actor_ref_owned_state(CodeGenerator* gen, ASTNode* ref, const char* field) { + const char* name = referenced_actor(ref); + if (!gen || !gen->program || !name || !field) return NULL; + for (int i = 0; i < gen->program->child_count; i++) { + ASTNode* actor = gen->program->children[i]; + if (actor && actor->type == AST_EXPORT_STATEMENT && actor->child_count > 0) + actor = actor->children[0]; + if (!actor || actor->type != AST_ACTOR_DEFINITION || !actor->value || + strcmp(actor->value, name) != 0) continue; + for (int j = 0; j < actor->child_count; j++) { + ASTNode* sd = actor->children[j]; + if (!sd || sd->type != AST_STATE_DECLARATION || !sd->value || + strcmp(sd->value, field) != 0) continue; + if (!sd->node_type || !own_type_owns(gen, sd->node_type) || + state_field_is_ptr(gen, actor, field)) return NULL; + return sd; + } + return NULL; + } + return NULL; +} + /* #2528: `_aether_state_drop_(void*)`, the scheduler's hook for what the * state fields own, run once by the free path that ends a scheduler-owned * actor, after it can no longer be stepped. Each state field of an owning @@ -734,14 +770,11 @@ void generate_actor_definition(CodeGenerator* gen, ASTNode* actor) { * releases nothing of it after the arm. */ if (tc_type_is_owned(fsig)) { print_line(gen, "_pattern->%s = NULL;", field->value); - ASTNode* carrier = create_ast_node(AST_EXPRESSION_STATEMENT, NULL, - field->line, field->column); - if (carrier) { - carrier->annotation = strdup(cg_internf("owned_exit_drop:%s:%s", var_name, - tc_owned_drop_c(fsig))); - codegen_own_node(gen, carrier); - push_defer(gen, carrier); - } + /* Journaled too (#3406): a panic in the + * arm skips its exits, and the message, + * emptied, no longer holds it. */ + push_owned_place_drop(gen, var_name, tc_owned_drop_c(fsig), + field->line, field->column); continue; } /* The binding is a view of the message's @@ -952,6 +985,8 @@ void generate_actor_definition(CodeGenerator* gen, ASTNode* actor) { print_line(gen, "return;"); unindent(gen); print_line(gen, "}"); + if (actor_mailbox_bound(actor) > 0) + print_line(gen, "aether_mailbox_took(&self->_aether_base, msg.type); /* @mailbox (#2974) */"); if (timeout_arm) { // A message disarms the timeout; its handling's end arms it again. print_line(gen, "self->_aether_base.last_activity_ns = 0;"); @@ -1101,6 +1136,17 @@ void generate_actor_definition(CodeGenerator* gen, ASTNode* actor) { print_line(gen, "handle_%s:", pattern->value); indent(gen); + /* A Down from a monitor this actor has since + * dropped is not handled (#2974). */ + if (strcmp(pattern->value, "Down") == 0) { + print_line(gen, "if (!aether_down_accept(&self->_aether_base, &msg)) {"); + indent(gen); + print_line(gen, "aether_message_drop(&self->_aether_base, &msg);"); + emit_timeout_rearm(gen, timeout_arm); + print_line(gen, "return;"); + unindent(gen); + print_line(gen, "}"); + } if (single_int) { // Single-field inline: reconstruct on stack from payload_int const char* cast = ""; @@ -1208,8 +1254,12 @@ void generate_actor_definition(CodeGenerator* gen, ASTNode* actor) { print_line(gen, "if (!actor) return NULL;"); print_line(gen, "atomic_init(&actor->_aether_base.active, 0); // inactive until first message send"); print_line(gen, "atomic_init(&actor->_aether_base.migrate_to, -1);"); - print_line(gen, "atomic_init(&actor->_aether_base.dead, 0);"); + /* Not `dead`: a fresh block is alive from scheduler_spawn_actor, and a + * restart's block (#2974) stays dead until it is ready, below. */ print_line(gen, "actor->_aether_base.auto_process = 0;"); + if (actor_mailbox_bound(actor) > 0) + print_line(gen, "atomic_store_explicit(&actor->_aether_base.mailbox_bound, %d, memory_order_relaxed); /* @mailbox (#2974) */", + actor_mailbox_bound(actor)); print_line(gen, ""); for (int i = 0; i < actor->child_count; i++) { @@ -1313,6 +1363,9 @@ void generate_actor_definition(CodeGenerator* gen, ASTNode* actor) { print_line(gen, "atomic_store_explicit(&actor->_aether_base.active, 1, memory_order_release); // Activate for timeout polling"); } + /* Initialized: an actor restarted in place (#2974) comes alive here, under + * the ref its clients hold; a fresh one already is. */ + print_line(gen, "scheduler_spawn_ready(&actor->_aether_base);"); print_line(gen, ""); print_line(gen, "return actor;"); unindent(gen); diff --git a/compiler/codegen/codegen_expr.c b/compiler/codegen/codegen_expr.c index 6df160d3b..7c39f3d9c 100644 --- a/compiler/codegen/codegen_expr.c +++ b/compiler/codegen/codegen_expr.c @@ -5264,7 +5264,7 @@ void emit_closure_definitions(CodeGenerator* gen) { fprintf(gen->output, "static void* _aether_make_env_%d(", id); emit_make_closure_params(gen, ci); fprintf(gen->output, ") {\n"); - fprintf(gen->output, " _closure_env_%d* _e = malloc(sizeof(_closure_env_%d));\n", id, id); + fprintf(gen->output, " _closure_env_%d* _e = (_closure_env_%d*)_aether_alloc(sizeof(_closure_env_%d), 0, \"out of memory allocating a closure environment\");\n", id, id, id); fprintf(gen->output, " _e->_dtor = _closure_env_%d_free;\n", id); fprintf(gen->output, " atomic_init(&_e->_refs, 1);\n"); for (int i = 0; i < cap_count; i++) { @@ -6431,6 +6431,9 @@ static Type* match_operand_type(ASTNode* m) { * as an operand (#2993) is a fourth: it is a statement whatever its form. */ int is_control_value(CodeGenerator* gen, ASTNode* e) { if (!e) return 0; + /* #2974: try_send(ref, Msg { ... }), a send written as statements. */ + if (e->type == AST_SEND_FIRE_FORGET) + return e->annotation && strcmp(e->annotation, "try_send") == 0; if (e->type == AST_MATCH_STATEMENT) return ast_match_is_operand(e) && match_operand_type(e); if (e->type == AST_OR_ELSE) { if (e->child_count < 2 || !e->children[1] || e->children[1]->type != AST_BLOCK) return 0; @@ -7716,7 +7719,7 @@ void generate_expression(CodeGenerator* gen, ASTNode* expr) { "in heap.new", AETHER_ERR_NONE); struct_c = "void"; } - fprintf(gen->output, "((%s*)calloc(1, sizeof(%s)))", + fprintf(gen->output, "((%s*)_aether_alloc(sizeof(%s), 1, \"out of memory in heap.new\"))", struct_c, struct_c); break; } @@ -9534,7 +9537,7 @@ void generate_expression(CodeGenerator* gen, ASTNode* expr) { if (site) { const ASTNode* saved_site = g_sandbox_site_wrapping; g_sandbox_site_wrapping = expr; - fprintf(gen->output, "(_aether_sandbox_pending_site = %d, ", site); + fprintf(gen->output, "(aether_sandbox_tag_site(&_aether_sandbox_image, %d), ", site); generate_expression(gen, expr); fprintf(gen->output, ")"); g_sandbox_site_wrapping = saved_site; @@ -9549,9 +9552,11 @@ void generate_expression(CodeGenerator* gen, ASTNode* expr) { * pointer later reaches its type (`malloc(n) as *T`, or a `ptr` * bound first and cast after), then has its fields read by a * field store, which releases the value it replaces; malloc left - * them garbage (#1873). */ + * them garbage (#1873). It still returns NULL when out of + * memory, as the program's own malloc does, and a library call + * then ends at its next safe point (#3377). */ if (call_is_c_malloc(gen, expr)) { - fprintf(gen->output, "calloc(1, (size_t)("); + fprintf(gen->output, "_aether_alloc_or_null((size_t)("); generate_expression(gen, expr->children[0]); fprintf(gen->output, "))"); break; @@ -10295,7 +10300,7 @@ void generate_expression(CodeGenerator* gen, ASTNode* expr) { // ref(value) — create a heap-allocated mutable cell else if (strcmp(func_name, "ref") == 0 && expr->child_count == 1) { fprintf(gen->output, "\n#if AETHER_GCC_COMPAT\n"); - fprintf(gen->output, "({ intptr_t* _r = malloc(sizeof(intptr_t)); *_r = (intptr_t)("); + fprintf(gen->output, "({ intptr_t* _r = (intptr_t*)_aether_alloc(sizeof(intptr_t), 0, \"out of memory allocating a ref\"); *_r = (intptr_t)("); generate_expression(gen, expr->children[0]); fprintf(gen->output, "); (void*)_r; })"); fprintf(gen->output, "\n#else\n"); @@ -10514,22 +10519,23 @@ void generate_expression(CodeGenerator* gen, ASTNode* expr) { } fprintf(gen->output, ")"); } - // ctx_push(ptr) / ctx_pop() — explicit context stack manipulation + // sandbox_push(grants) / sandbox_pop(): a grant level on this + // thread, kept by the runtime (#2955) else if (strcmp(func_name, "sandbox_push") == 0 && expr->child_count == 1) { - fprintf(gen->output, "_aether_sandbox_push((void*)(intptr_t)"); + fprintf(gen->output, "aether_sandbox_push((void*)(intptr_t)"); generate_expression(gen, expr->children[0]); fprintf(gen->output, ")"); } else if (strcmp(func_name, "sandbox_pop") == 0 && expr->child_count == 0) { - fprintf(gen->output, "_aether_sandbox_pop()"); + fprintf(gen->output, "aether_sandbox_pop()"); } - // sandbox_install() — activate runtime sandbox checking - else if (strcmp(func_name, "sandbox_install") == 0 && expr->child_count == 0) { - fprintf(gen->output, "_aether_sandbox_install()"); - } - // sandbox_uninstall() — deactivate runtime sandbox checking - else if (strcmp(func_name, "sandbox_uninstall") == 0 && expr->child_count == 0) { - fprintf(gen->output, "_aether_sandbox_uninstall()"); + // sandbox_install() / sandbox_uninstall(): nothing to do + // since #2955. The check reads the thread's levels, so a push + // is all it takes; the process-wide checker these set and + // cleared is gone. Kept so code that calls them still builds. + else if ((strcmp(func_name, "sandbox_install") == 0 || + strcmp(func_name, "sandbox_uninstall") == 0) && expr->child_count == 0) { + fprintf(gen->output, "((void)0)"); } // builder_depth() — returns the current builder nesting depth else if (strcmp(func_name, "builder_depth") == 0) { @@ -12020,7 +12026,26 @@ void generate_expression(CodeGenerator* gen, ASTNode* expr) { if (expr->child_count >= 2) { ASTNode* target = expr->children[0]; ASTNode* message = expr->children[1]; - + /* #2974: try_send(ref, Msg { ... }) is the same send, made + * by scheduler_try_send (aether_try_send_message for a + * payload), never batched, its answer a string. Run ahead + * of its statement (is_control_value), its answer a + * temporary the statement reads; a statement expression + * where nothing can be declared ahead. */ + const char* try_var = NULL; + int try_as_stmts = 0; + if (expr->annotation && strcmp(expr->annotation, "try_send") == 0) { + static int try_send_seq = 0; + try_var = cg_internf("_ts%d", try_send_seq++); + try_as_stmts = g_ctl_emitting == expr; + if (try_as_stmts) { + hoist_declare(gen, "const char*", try_var); + print_indent(gen); + } else { + fprintf(gen->output, "({ const char* %s = \"\"; ", try_var); + } + } + if (message && message->type == AST_MESSAGE_CONSTRUCTOR) { MessageDef* msg_def = lookup_message(gen->message_registry, message->value); if (msg_def) { @@ -12050,7 +12075,11 @@ void generate_expression(CodeGenerator* gen, ASTNode* expr) { } fprintf(gen->output, ", NULL, {NULL, 0, 0}}; "); - if (gen->in_main_loop) { + if (try_var) { + fprintf(gen->output, "%s = scheduler_try_send(", try_var); + emit_send_target(gen, target, "ActorBase*", "send"); + fprintf(gen->output, ", _imsg); }"); + } else if (gen->in_main_loop) { fprintf(gen->output, "scheduler_send_batch_add("); emit_send_target(gen, target, "ActorBase*", "send"); fprintf(gen->output, ", _imsg); }"); @@ -12114,8 +12143,11 @@ void generate_expression(CodeGenerator* gen, ASTNode* expr) { /* #3117: in a batched loop every send joins the * batch, so a payload message keeps its place * among the loop's one-int ones. */ - fprintf(gen->output, gen->in_main_loop ? "aether_send_message_batch(" - : "aether_send_message("); + if (try_var) + fprintf(gen->output, "%s = aether_try_send_message(", try_var); + else + fprintf(gen->output, gen->in_main_loop ? "aether_send_message_batch(" + : "aether_send_message("); emit_send_target(gen, target, "void*", "send"); fprintf(gen->output, ", &_msg, sizeof(%s)); }", message->value); } @@ -12123,6 +12155,12 @@ void generate_expression(CodeGenerator* gen, ASTNode* expr) { fprintf(gen->output, "/* ERROR: unknown message type %s */", message->value ? message->value : ""); } } + if (try_var && try_as_stmts) { + fprintf(gen->output, "\n"); + ctl_bind(expr, try_var); + } else if (try_var) { + fprintf(gen->output, " %s; })", try_var); + } } break; @@ -12181,6 +12219,8 @@ void generate_expression(CodeGenerator* gen, ASTNode* expr) { case TYPE_UINT64: c_type = "uint64_t"; c_zero = "0"; break; case TYPE_DURATION: c_type = "int64_t"; c_zero = "0"; break; case TYPE_PTR: c_type = "void*"; c_zero = "NULL"; break; + /* #3332: a reference is a pointer; as an int it lost its high half. */ + case TYPE_ACTOR_REF: c_type = "void*"; c_zero = "NULL"; break; case TYPE_FUNCTION: c_type = "_AeClosure"; c_zero = "(_AeClosure){0}"; break; default: c_type = "int"; c_zero = "0"; break; } @@ -12372,7 +12412,7 @@ void generate_expression(CodeGenerator* gen, ASTNode* expr) { } else { // Heap-allocate the environment (portable, no use-after-free) fprintf(gen->output, "\n#if AETHER_GCC_COMPAT\n"); - fprintf(gen->output, "({ _closure_env_%d* _e = malloc(sizeof(_closure_env_%d)); _e->_dtor = _closure_env_%d_free; atomic_init(&_e->_refs, 1); ", id, id, id); + fprintf(gen->output, "({ _closure_env_%d* _e = (_closure_env_%d*)_aether_alloc(sizeof(_closure_env_%d), 0, \"out of memory allocating a closure environment\"); _e->_dtor = _closure_env_%d_free; atomic_init(&_e->_refs, 1); ", id, id, id, id); for (int i = 0; i < cap_count; i++) { const char* src = capture_source(gen, captures[i]); /* #2890 */ if (capture_is_closure_value(gen, captures[i], cl_parent_func)) { diff --git a/compiler/codegen/codegen_internal.h b/compiler/codegen/codegen_internal.h index 3c184a48f..0f7fe7c9a 100644 --- a/compiler/codegen/codegen_internal.h +++ b/compiler/codegen/codegen_internal.h @@ -554,6 +554,9 @@ int state_array_owned(ASTNode* state_decl, int* len); /* #2688: a `T[N]` state field whose struct T owns strings or closures: T's * name (each element is the actor's own), or NULL. */ const char* state_struct_array_owned(CodeGenerator* gen, ASTNode* state_decl, int* len); +/* #3386: the owning state declaration `field` of the actor the reference + * `ref` names (`r.field`), or NULL. */ +ASTNode* actor_ref_owned_state(CodeGenerator* gen, ASTNode* ref, const char* field); /* #2528 (codegen_stmt.c): `v` as the value an owned string element takes. */ void emit_owned_string_element(CodeGenerator* gen, ASTNode* v); @@ -640,6 +643,10 @@ int tc_type_is_owned(const Type* t); const char* tc_owned_drop_c(const Type* t); int emit_owned_ptr_drop(CodeGenerator* gen, Type* t, const char* lv); void hoist_owned_locals(CodeGenerator* gen, ASTNode* body); +/* #3406: the release and the unwind journal entry of a place of an owned + * pointer type, for every such place (codegen_stmt.c). */ +void push_owned_place_drop(CodeGenerator* gen, const char* name, const char* drop_c, + int line, int column); int program_releases_messages(CodeGenerator* gen); int expr_is_c_view_of_slice(CodeGenerator* gen, const ASTNode* expr); void emit_array_fill(CodeGenerator* gen, const char* dst, ASTNode* e, Type* at); diff --git a/compiler/codegen/codegen_stmt.c b/compiler/codegen/codegen_stmt.c index c6c58d5c4..b53950dc5 100644 --- a/compiler/codegen/codegen_stmt.c +++ b/compiler/codegen/codegen_stmt.c @@ -2734,13 +2734,27 @@ static int value_path_through_pointer(ASTNode* obj) { while (obj && (obj->type == AST_MEMBER_ACCESS || obj->type == AST_ARRAY_ACCESS) && obj->child_count > 0 && obj->children[0]) { Type* ht = obj->children[0]->node_type; - if (ht && (ht->kind == TYPE_PTR || (obj->type == AST_ARRAY_ACCESS && type_is_slice(ht)))) + /* An actor's state reached through its reference is read by the + * actor's thread, as a box through a pointer may be (#3386). */ + if (ht && (ht->kind == TYPE_PTR || ht->kind == TYPE_ACTOR_REF || + (obj->type == AST_ARRAY_ACCESS && type_is_slice(ht)))) return 1; obj = obj->children[0]; } return 0; } +/* #3398: does a store into the place `lv` publish what it takes? One rule + * for every store emitter (#2919): a place reached through a pointer, a + * slice or an actor's reference is memory another actor can reach, so the + * strings stored there, whichever part of the place takes them (a string, + * a struct, an element, a whole array, an optional, at any depth), count + * atomically from then on. Only the string field paths published; the + * other parts took their references plain. */ +static int store_publishes(ASTNode* lv) { + return value_path_through_pointer(lv); +} + static int struct_array_local_owns(CodeGenerator* gen, const char* name); static ASTNode* actor_state_decl(CodeGenerator* gen, const char* name); @@ -2793,7 +2807,9 @@ const char* struct_replace_fn(const char* sname, int is_volatile) { * An element of an array of such structs (`items[0]`, `o.items[0]`) is its * array's: a local array that owns its elements starts zeroed or * initialised (#2528), an actor's state starts zeroed (#2688), and a field - * array is its holder's. */ + * array is its holder's. So is a state field of another actor reached + * through its reference (`r.kept.name`, `r.items[0].name`), which its spawn + * set and which the actor owns (#3386). */ static int value_path_trackers_are_initialised(CodeGenerator* gen, ASTNode* obj) { while (obj) { if (obj->type == AST_ARRAY_ACCESS && obj->child_count >= 2 && obj->children[0] && @@ -2808,6 +2824,8 @@ static int value_path_trackers_are_initialised(CodeGenerator* gen, ASTNode* obj) !obj->children[0]->node_type) break; ASTNode* holder = obj->children[0]; if (holder->node_type->kind == TYPE_PTR) return 1; + if (holder->node_type->kind == TYPE_ACTOR_REF) + return actor_ref_owned_state(gen, holder, obj->value) != NULL; if (holder->node_type->kind != TYPE_STRUCT) return 0; obj = holder; } @@ -2873,7 +2891,7 @@ static int emit_nested_field_heap_assign(CodeGenerator* gen, ASTNode* lhs, emit_string_take_owned(gen, rhs, 0); fprintf(gen->output, ";"); if (release_old) fprintf(gen->output, " aether_heap_str_free(_tmp_old);"); - if (!by_value || value_path_through_pointer(obj)) + if (store_publishes(lhs)) fprintf(gen->output, " aether_string_publish(%s->%s);", tgt, lhs->value); fprintf(gen->output, " }\n"); return 1; @@ -2934,19 +2952,24 @@ static int emit_struct_valued_field_store(CodeGenerator* gen, ASTNode* lhs, ASTN } else { return 0; } + /* The place is evaluated once, and through a pointer what it takes is + * published (#3398). */ + int vol = lvalue_may_be_volatile(gen, lhs); print_indent(gen); + fprintf(gen->output, "{ %s%s* _ae_sv = &(", get_c_type(lhs->node_type), vol ? " volatile" : ""); + generate_expression(gen, lhs); + fprintf(gen->output, "); "); if (trusted) { - fprintf(gen->output, "%s(&(", struct_replace_fn(fs, lvalue_may_be_volatile(gen, lhs))); - generate_expression(gen, lhs); - fprintf(gen->output, "), "); + fprintf(gen->output, "%s(_ae_sv, ", struct_replace_fn(fs, vol)); emit_struct_take(gen, rhs, fs, NULL); - fprintf(gen->output, ");\n"); + fprintf(gen->output, ");"); } else { - generate_expression(gen, lhs); - fprintf(gen->output, " = "); + fprintf(gen->output, "*_ae_sv = "); emit_struct_take(gen, rhs, fs, NULL); - fprintf(gen->output, ";\n"); + fprintf(gen->output, ";"); } + if (store_publishes(lhs)) fprintf(gen->output, " %s_publish((%s*)_ae_sv);", fs, get_c_type(lhs->node_type)); + fprintf(gen->output, " }\n"); return 1; } @@ -3161,21 +3184,30 @@ static void emit_whole_array_store_begin(CodeGenerator* gen, int kind, const cha } static void emit_whole_array_store_end(CodeGenerator* gen, int kind, const char* es, Type* at, - int release) { + int release, int publish) { fprintf(gen->output, "); for (int _ae_k = 0; _ae_k < %d; _ae_k++) ", at->array_size); if (!release || kind == AF_PLAIN) { - fprintf(gen->output, "_ae_fdst[_ae_k] = _ae_farr[_ae_k]; }\n"); + fprintf(gen->output, "_ae_fdst[_ae_k] = _ae_farr[_ae_k];"); } else if (kind == AF_STRING) { fprintf(gen->output, "{ const char* _ae_old = _ae_fdst[_ae_k]; _ae_fdst[_ae_k] = _ae_farr[_ae_k]; " - "if (_ae_old) aether_heap_str_free(_ae_old); } }\n"); + "if (_ae_old) aether_heap_str_free(_ae_old); }"); } else if (kind == AF_CLOSURE) { fprintf(gen->output, "{ void* _ae_old = _ae_fdst[_ae_k].env; _ae_fdst[_ae_k] = _ae_farr[_ae_k]; " - "_aether_closure_env_release(_ae_old); } }\n"); + "_aether_closure_env_release(_ae_old); }"); } else { /* The destination is reached through a volatile pointer, which * accepts a field of a volatile local (#2690) as well. */ - fprintf(gen->output, "_aether_vstore_%s(&_ae_fdst[_ae_k], _ae_farr[_ae_k]); }\n", es); + fprintf(gen->output, "_aether_vstore_%s(&_ae_fdst[_ae_k], _ae_farr[_ae_k]);", es); } + /* #3398: through a pointer, the elements it took are published. */ + if (publish && kind == AF_STRING) { + fprintf(gen->output, " for (int _ae_k = 0; _ae_k < %d; _ae_k++) " + "aether_string_publish(_ae_fdst[_ae_k]);", at->array_size); + } else if (publish && kind == AF_STRUCT) { + fprintf(gen->output, " for (int _ae_k = 0; _ae_k < %d; _ae_k++) %s_publish((%s*)&_ae_fdst[_ae_k]);", + at->array_size, es, es); + } + fprintf(gen->output, " }\n"); } /* #2688: `names = arr` / `names = [...]` into an actor's fixed-size array @@ -3195,7 +3227,7 @@ static int emit_state_array_store(CodeGenerator* gen, ASTNode* sdecl, ASTNode* r int kind = oak == 1 ? AF_STRING : oak == 2 ? AF_CLOSURE : es ? AF_STRUCT : AF_PLAIN; emit_whole_array_store_begin(gen, kind, es, sdecl->node_type, rhs); fprintf(gen->output, "self->%s", sdecl->value); - emit_whole_array_store_end(gen, kind, es, sdecl->node_type, 1); + emit_whole_array_store_end(gen, kind, es, sdecl->node_type, 1, 0); return 1; } @@ -3225,7 +3257,7 @@ static int emit_array_field_store(CodeGenerator* gen, ASTNode* lhs, ASTNode* rhs int kind = array_field_elem_kind(gen, sname, fdef, &es); emit_whole_array_store_begin(gen, kind, es, fdef->node_type, rhs); generate_expression(gen, lhs); - emit_whole_array_store_end(gen, kind, es, fdef->node_type, trusted); + emit_whole_array_store_end(gen, kind, es, fdef->node_type, trusted, store_publishes(lhs)); return 1; } @@ -3252,7 +3284,7 @@ static int emit_struct_element_store(CodeGenerator* gen, ASTNode* lhs, ASTNode* generate_expression(gen, lhs); fprintf(gen->output, ");\n"); print_indent(gen); - emit_owning_store(gen, "(*_ae_ep)", et, rhs, 0); + emit_owning_store(gen, "(*_ae_ep)", et, rhs, store_publishes(lhs)); print_indent(gen); fprintf(gen->output, "}\n"); return 1; @@ -3302,38 +3334,87 @@ static int emit_struct_element_store(CodeGenerator* gen, ASTNode* lhs, ASTNode* return 0; } if (!trusted) return 0; + /* The element's address is taken once (its index evaluated once), and + * through a pointer what it takes is published (#3398). */ + int vol = lvalue_may_be_volatile(gen, lhs); print_indent(gen); + fprintf(gen->output, "{ %s%s* _ae_ep = &(", get_c_type(et), vol ? " volatile" : ""); + generate_expression(gen, lhs); + fprintf(gen->output, "); "); if (kind == 3) { - fprintf(gen->output, "%s(&(", struct_replace_fn(es, lvalue_may_be_volatile(gen, lhs))); - generate_expression(gen, lhs); - fprintf(gen->output, "), "); + fprintf(gen->output, "%s(_ae_ep, ", struct_replace_fn(es, vol)); emit_struct_take(gen, rhs, es, NULL); - fprintf(gen->output, ");\n"); + fprintf(gen->output, ");"); + if (store_publishes(lhs)) fprintf(gen->output, " %s_publish((%s*)_ae_ep);", es, es); } else if (kind == 1) { - fprintf(gen->output, "{ const char* _ae_old = "); - generate_expression(gen, lhs); - fprintf(gen->output, "; "); - generate_expression(gen, lhs); - fprintf(gen->output, " = "); + fprintf(gen->output, "const char* _ae_old = *_ae_ep; *_ae_ep = "); emit_owned_string_element(gen, rhs); - fprintf(gen->output, "; if (_ae_old) aether_heap_str_free(_ae_old); }\n"); + fprintf(gen->output, "; if (_ae_old) aether_heap_str_free(_ae_old);"); + if (store_publishes(lhs)) fprintf(gen->output, " aether_string_publish(*_ae_ep);"); } else { - fprintf(gen->output, "{ _AeClosure _ae_cv = "); + /* A closure's environment counts atomically already. */ + fprintf(gen->output, "_AeClosure _ae_cv = "); emit_closure_take(gen, rhs); - fprintf(gen->output, "; void* _ae_old = ("); - generate_expression(gen, lhs); - fprintf(gen->output, ").env; "); - generate_expression(gen, lhs); - fprintf(gen->output, " = _ae_cv; _aether_closure_env_release(_ae_old); }\n"); + fprintf(gen->output, "; void* _ae_old = _ae_ep->env; *_ae_ep = _ae_cv; " + "_aether_closure_env_release(_ae_old);"); } + fprintf(gen->output, " }\n"); return 1; } static int emit_slice_element_store(CodeGenerator* gen, ASTNode* lhs, ASTNode* rhs); static int emit_composite_field_store(CodeGenerator* gen, ASTNode* lhs, ASTNode* rhs); +/* #3386: a store into another actor's state through its reference, `r.f = v` + * (a whole fixed-size array too) or `r.f[i] = v` into an element of one. + * The state is a place + * (R2) the actor owns: destroy_state drops what it holds, and its spawn set + * it, so what a store replaces can be read. So the store is R3's, as the + * actor's own `f = v` is: it takes `v` (a fresh value moved, anything else + * retained) and drops what the place held, and since the actor reads the + * place on its own thread, what it takes is published (#2919), as through + * a pointer. It was a plain C assignment: the state shared what the source + * still owned (std.supervise's `sup.kids[i] = s.kids[i]`, whose spec was + * then freed, left the supervisor holding released names and factories) + * and leaked what it replaced. A deeper path (`r.f.name = v`, + * `r.f[i].name = v`) is a struct's, trusted through the reference by + * value_path_trackers_are_initialised. The place is evaluated once. */ +static int emit_actor_ref_state_store(CodeGenerator* gen, ASTNode* lhs, ASTNode* rhs) { + ASTNode* field = lhs; + int element = 0; + if (lhs->type == AST_ARRAY_ACCESS && lhs->child_count >= 2 && lhs->children[0]) { + field = lhs->children[0]; + element = 1; + } + if (field->type != AST_MEMBER_ACCESS || !field->value || field->child_count != 1 || + !field->children[0]) return 0; + ASTNode* sd = actor_ref_owned_state(gen, field->children[0], field->value); + if (!sd) return 0; + Type* st = sd->node_type; + int sized = st->kind == TYPE_ARRAY && st->array_size > 0 && st->element_type; + if (element && !sized) return 0; + Type* t = element ? st->element_type : st; + if (!own_type_owns(gen, t)) return 0; + print_indent(gen); + fprintf(gen->output, "{ %s* _ae_sp = %s(", get_c_type(sized && !element ? st->element_type : t), + sized && !element ? "" : "&"); + generate_expression(gen, lhs); + fprintf(gen->output, ");\n"); + print_indent(gen); + if (sized && !element) { + emit_owning_array_store(gen, "_ae_sp", st, rhs); + if (store_publishes(lhs)) own_emit_part(gen, st, "_ae_sp", OWN_PUBLISH); + } else { + emit_owning_store(gen, "(*_ae_sp)", t, rhs, store_publishes(lhs)); + } + print_indent(gen); + fprintf(gen->output, "}\n"); + return 1; +} + static int emit_struct_field_heap_assign(CodeGenerator* gen, ASTNode* lhs, ASTNode* rhs) { if (!gen || !lhs || !rhs) return 0; + if (emit_actor_ref_state_store(gen, lhs, rhs)) return 1; /* #3386 */ if (emit_slice_element_store(gen, lhs, rhs)) return 1; /* #2921 */ if (emit_composite_field_store(gen, lhs, rhs)) return 1; /* #2921 */ if (emit_struct_element_store(gen, lhs, rhs)) return 1; @@ -3435,7 +3516,7 @@ static int emit_struct_field_heap_assign(CodeGenerator* gen, ASTNode* lhs, ASTNo objs, acc, lhs->value, objs, acc, lhs->value); emit_string_take_owned(gen, rhs, 0); fprintf(gen->output, "; aether_heap_str_free(_tmp_old);"); - if (acc[0] == '-') + if (store_publishes(lhs)) fprintf(gen->output, " aether_string_publish(%s%s%s);", objs, acc, lhs->value); fprintf(gen->output, " }\n"); return 1; @@ -3708,7 +3789,7 @@ static int emit_composite_field_store(CodeGenerator* gen, ASTNode* lhs, ASTNode* generate_expression(gen, lhs); fprintf(gen->output, ");\n"); print_indent(gen); - emit_owning_store(gen, "(*_ae_fp)", ft, rhs, ot->kind == TYPE_PTR); + emit_owning_store(gen, "(*_ae_fp)", ft, rhs, store_publishes(lhs)); print_indent(gen); fprintf(gen->output, "}\n"); return 1; @@ -3744,7 +3825,7 @@ static int emit_slice_element_store(CodeGenerator* gen, ASTNode* lhs, ASTNode* r generate_expression(gen, lhs); fprintf(gen->output, ");\n"); print_indent(gen); - emit_owning_store(gen, "(*_ae_ep)", at->element_type, rhs, 0); + emit_owning_store(gen, "(*_ae_ep)", at->element_type, rhs, store_publishes(lhs)); print_indent(gen); fprintf(gen->output, "}\n"); return 1; @@ -5484,6 +5565,47 @@ static void collect_owned_locals(ASTNode* n, const char** names, Type** types, i for (int i = 0; i < n->child_count; i++) collect_owned_locals(n->children[i], names, types, count, cap); } +/* #3413: the function the unwind journal runs on a place of an owned pointer + * type, `void f(void*)` as aether_unwind_track_owned_place takes it. The + * checker accepts any one-parameter `@frees ptr` function as a drop, whatever + * it returns and however its C prototype spells the pointer (`int + * fclose(FILE*)`), so the journal holds a thunk that calls the drop as the + * exit release does, `f((void*)p)`. One per drop function, written at the end + * of the program with the defer thunks (emit_defer_thunks), where every drop + * function is declared, and declared where the function bodies start. */ +static const char* owned_drop_thunk(CodeGenerator* gen, const char* drop_c) { + int i = 0; + while (i < gen->owned_thunk_count && strcmp(gen->owned_thunks[i], drop_c) != 0) i++; + if (i == gen->owned_thunk_count) { + if (gen->owned_thunk_count == gen->owned_thunk_cap) { + gen->owned_thunk_cap = gen->owned_thunk_cap ? gen->owned_thunk_cap * 2 : 4; + gen->owned_thunks = aether_xrealloc((void*)gen->owned_thunks, + sizeof(char*) * (size_t)gen->owned_thunk_cap); + } + gen->owned_thunks[gen->owned_thunk_count++] = cg_intern(drop_c); + } + return cg_internf("_ae_owned_drop_%s", drop_c); +} + +/* #3406: the one rule for every place of an owned pointer type, a local + * (hoist_owned_locals) or an arm's binding of a message field (the + * handler's prologue): released at each exit of its scope, and journaled + * while it lives, so a panic that skips the exit releases it as the exit + * would. The journal writes nothing outside every frame (no panic can be + * caught there, so nothing can skip the exit and go on); the exit's release + * forgets the place first. The journal runs the drop through its thunk. */ +void push_owned_place_drop(CodeGenerator* gen, const char* name, const char* drop_c, + int line, int column) { + print_line(gen, "int _aeo_%s = aether_unwind_track_owned_place((const void*)&%s, %s);", name, name, + owned_drop_thunk(gen, drop_c)); + ASTNode* carrier = create_ast_node(AST_EXPRESSION_STATEMENT, NULL, line, column); + if (!carrier) return; + if (carrier->annotation) free(carrier->annotation); + carrier->annotation = heap_strf("owned_exit_drop:%s:%s", name, drop_c); + codegen_own_node(gen, carrier); + push_defer(gen, carrier); +} + /* #3142: an owned-pointer local is one variable for the whole function, as a * string local is: declared at its top holding nothing, and released at * every exit. A `move` of it (a `return` of it is one) leaves it holding @@ -5503,11 +5625,7 @@ void hoist_owned_locals(CodeGenerator* gen, ASTNode* body) { print_line(gen, "void* %s = NULL;", name); mark_var_declared(gen, name); } - ASTNode* carrier = create_ast_node(AST_EXPRESSION_STATEMENT, NULL, body->line, body->column); - if (!carrier) continue; - carrier->annotation = heap_strf("owned_exit_drop:%s:%s", name, drop); - codegen_own_node(gen, carrier); - push_defer(gen, carrier); + push_owned_place_drop(gen, name, drop, body->line, body->column); } } @@ -6071,7 +6189,7 @@ static void td_emit_dispatch(CodeGenerator* gen, int level, const char* fallback print_line(gen, "if (--_aether_reductions <= 0) { _aether_reductions = 10000; sched_yield(); }"); } if (gen->emit_lib) { - print_line(gen, "if (aether_caps_armed && aether_caps_deadline_tripped()) { __aether_abort_call(); goto _ae_td_exit_%d; }", id); + print_line(gen, "if (AETHER_RT_DATA_PRESENT(aether_caps_armed) && atomic_load_explicit(&aether_caps_armed, memory_order_relaxed) && aether_exec_poll()) { __aether_abort_call(); goto _ae_td_exit_%d; }", id); } g_td_in_head++; for (int i = 0; i < si; i++) { @@ -8874,11 +8992,19 @@ static void emit_defer_registration(CodeGenerator* gen, ASTNode* body) { /* The thunks, after everything else, and their prototypes where the * function bodies start (gen->defer_proto_pos): the file from there is - * written again after them. */ + * written again after them. The defer thunks, and the owned places' drop + * thunks (#3413, owned_drop_thunk). */ void emit_defer_thunks(CodeGenerator* gen) { - if (gen->defer_thunk_count == 0) return; - fputs("\n// Deferred statements a panic runs (#2923)\n", gen->output); - fputs(gen->defer_thunks, gen->output); + if (gen->defer_thunk_count == 0 && gen->owned_thunk_count == 0) return; + if (gen->defer_thunk_count > 0) { + fputs("\n// Deferred statements a panic runs (#2923)\n", gen->output); + fputs(gen->defer_thunks, gen->output); + } + if (gen->owned_thunk_count > 0) + fputs("\n// The drops of owned places a panic runs (#3413)\n", gen->output); + for (int i = 0; i < gen->owned_thunk_count; i++) + fprintf(gen->output, "static void _ae_owned_drop_%s(void* _p) { %s((void*)_p); }\n", + gen->owned_thunks[i], gen->owned_thunks[i]); fflush(gen->output); long end = ftell(gen->output); long pos = gen->defer_proto_pos; @@ -8891,6 +9017,8 @@ void emit_defer_thunks(CodeGenerator* gen) { } for (int i = 0; i < gen->defer_thunk_count; i++) fprintf(gen->output, "static void _ae_defer_%d(const void* _p);\n", i); + for (int i = 0; i < gen->owned_thunk_count; i++) + fprintf(gen->output, "static void _ae_owned_drop_%s(void* _p);\n", gen->owned_thunks[i]); fwrite(tail, 1, n, gen->output); fflush(gen->output); free(tail); @@ -9140,8 +9268,11 @@ static void generate_statement_body(CodeGenerator* gen, ASTNode* stmt) { "_ask_st%d == AETHER_ASK_DEAD ? \"the actor is dead\" : " "_ask_st%d == AETHER_ASK_DIED ? \"the actor died before it replied\" : " "_ask_st%d == AETHER_ASK_NO_REPLY ? \"the handler returned without replying\" : " + "_ask_st%d == AETHER_ASK_KILLED ? \"the asking actor was killed\" : " + "_ask_st%d == AETHER_ASK_FULL ? \"the mailbox is full\" : " "\"out of memory\" };\n", - tuple_type_name, tmp_id, tmp_id, tmp_id, tmp_id, to, tmp_id, tmp_id, tmp_id); + tuple_type_name, tmp_id, tmp_id, tmp_id, tmp_id, to, tmp_id, tmp_id, tmp_id, + tmp_id, tmp_id); } else { print_indent(gen); fprintf(gen->output, "%s _tup%d = ", tuple_type_name, tmp_id); @@ -11259,7 +11390,7 @@ static void generate_statement_body(CodeGenerator* gen, ASTNode* stmt) { // --emit=exe builds (the if (gen->emit_lib) gate elides // the print entirely). if (gen->emit_lib) { - print_line(gen, "if (aether_caps_armed && aether_caps_deadline_tripped()) { __aether_abort_call(); break; }"); + print_line(gen, "if (AETHER_RT_DATA_PRESENT(aether_caps_armed) && atomic_load_explicit(&aether_caps_armed, memory_order_relaxed) && aether_exec_poll()) { __aether_abort_call(); break; }"); } /* Issue #501: snapshot try_frame_depth at loop entry. * #893: record the loop's label / containing scope / C-label id. */ @@ -11379,7 +11510,7 @@ static void generate_statement_body(CodeGenerator* gen, ASTNode* stmt) { } // Issue #343 codegen tripwire — see AST_FOR_LOOP comment. if (gen->emit_lib) { - print_line(gen, "if (aether_caps_armed && aether_caps_deadline_tripped()) { __aether_abort_call(); break; }"); + print_line(gen, "if (AETHER_RT_DATA_PRESENT(aether_caps_armed) && atomic_load_explicit(&aether_caps_armed, memory_order_relaxed) && aether_exec_poll()) { __aether_abort_call(); break; }"); } /* Issue #501: snapshot try_frame_depth at loop entry so * `break` / `continue` inside the body drains only @@ -12750,12 +12881,10 @@ static void generate_statement_body(CodeGenerator* gen, ASTNode* stmt) { indent(gen); /* A panic that unwinds out of an enforced block (or out of a * trusted call inside one) skips the code that pops the sandbox - * or restores its depth. Catching it puts the depth back to what - * it was here, so a catch inside the block stays sandboxed and - * one outside it is no longer held to the block's grants. */ - if (gen->uses_sandbox) { - print_line(gen, "int _aether_try_sbx_%d = _aether_sandbox_depth;", uid); - } + * or restores its depth. The runtime leaves the levels pushed + * inside this frame as the panic reaches it (#2955), so a catch + * inside the block stays sandboxed and one outside it is no + * longer held to the block's grants, whichever code catches. */ /* #2810: so does a panic out of a trailing block: the builder * context stack goes back to its depth here, and the configs of * the builder blocks the panic abandoned are released. */ @@ -12771,10 +12900,12 @@ static void generate_statement_body(CodeGenerator* gen, ASTNode* stmt) { unindent(gen); print_line(gen, "} else {"); indent(gen); - if (gen->uses_sandbox) { - print_line(gen, "_aether_sandbox_depth = _aether_try_sbx_%d;", uid); - } print_line(gen, "_aether_ctx_unwind(_aether_try_ctx_%d);", uid); + /* #2956: an interruption (a library call's deadline or cancel) + * is not this try's to handle: its frame has done its cleanup + * (the journal drained, the builder contexts above), and the + * interruption goes on to the next frame, the execution's. */ + print_line(gen, "if (aether_exec_interrupting()) aether_exec_reraise();"); /* #2739: a binding a `try` of its handler writes is volatile: * the handler is outside that try, whose setjmp sees it. */ print_line(gen, "%s %s = _aether_try_%d->reason ? _aether_try_%d->reason : \"panic\";", @@ -13489,6 +13620,8 @@ static void generate_statement_body(CodeGenerator* gen, ASTNode* stmt) { case TYPE_UINT64: c_type = "uint64_t"; break; case TYPE_DURATION: c_type = "int64_t"; break; case TYPE_PTR: c_type = "void*"; break; + /* #3332: a reference is a pointer, as on the asker's side. */ + case TYPE_ACTOR_REF: c_type = "void*"; break; default: c_type = "int"; break; } fprintf(gen->output, "{ %s _reply_val = (%s)(", c_type, c_type); diff --git a/compiler/parser/parser.c b/compiler/parser/parser.c index 21767af8e..051a3cfff 100644 --- a/compiler/parser/parser.c +++ b/compiler/parser/parser.c @@ -1822,6 +1822,35 @@ static ASTNode* parse_primary_expression_inner(Parser* parser) { return create_ast_node(AST_NONE_LITERAL, NULL, token->line, token->column); } + /* #2974: `try_send(ref, Msg { ... })` sends as `ref ! Msg { ... }` does + * and says whether it did: "" when sent, "the mailbox is full" or "the + * actor is dead" when not. A send node marked "try_send", so the message + * is built, owned and released as a send's is. Contextual: only the name + * followed by `(` is it. */ + if (token->type == TOKEN_IDENTIFIER && token->value && + strcmp(token->value, "try_send") == 0 && peek_ahead(parser, 1) && + peek_ahead(parser, 1)->type == TOKEN_LEFT_PAREN) { + advance_token(parser); // try_send + advance_token(parser); // ( + ASTNode* target = parse_expression(parser); + if (!target) return NULL; + if (!expect_token(parser, TOKEN_COMMA)) return NULL; + Token* name = peek_token(parser); + if (!name || name->type != TOKEN_IDENTIFIER || !name->value || + name->value[0] < 'A' || name->value[0] > 'Z') { + parser_error(parser, "try_send(ref, Msg { ... }) takes a message, as `!` does"); + return NULL; + } + ASTNode* message = parse_message_constructor(parser); + if (!message) return NULL; + if (!expect_token(parser, TOKEN_RIGHT_PAREN)) return NULL; + ASTNode* send = create_ast_node(AST_SEND_FIRE_FORGET, NULL, token->line, token->column); + send->annotation = strdup("try_send"); + add_child(send, target); + add_child(send, message); + return send; + } + switch (token->type) { case TOKEN_NUMBER: case TOKEN_STRING_LITERAL: @@ -8368,6 +8397,47 @@ ASTNode* parse_top_level_decl(Parser* parser) { node = cdef; break; } + // @mailbox(bound: N) actor A { ... } (#2974): at most N + // messages accepted for A and not yet taken by its step; a + // send past it is refused, never blocked on. Carried as the + // actor's "mailbox_bound:N" annotation. + if (attr && attr->type == TOKEN_IDENTIFIER && attr->value && + strcmp(attr->value, "mailbox") == 0) { + advance_token(parser); // consume 'mailbox' + if (!expect_token(parser, TOKEN_LEFT_PAREN)) return NULL; + Token* key = peek_token(parser); + if (!key || key->type != TOKEN_IDENTIFIER || !key->value || + strcmp(key->value, "bound") != 0) { + parser_error(parser, "@mailbox takes a bound, as in @mailbox(bound: 1000)"); + return NULL; + } + advance_token(parser); // consume 'bound' + if (!expect_token(parser, TOKEN_COLON)) return NULL; + Token* num = peek_token(parser); + char* end = NULL; + long bound = (num && num->type == TOKEN_NUMBER && num->value) + ? strtol(num->value, &end, 10) : 0; + if (!end || *end || bound < 1 || bound > 1000000000L) { + parser_error(parser, "@mailbox(bound: N) takes a whole number N from 1 to 1000000000"); + return NULL; + } + advance_token(parser); // consume the number + if (!expect_token(parser, TOKEN_RIGHT_PAREN)) return NULL; + Token* next_tok = peek_token(parser); + if (!next_tok || next_tok->type != TOKEN_ACTOR) { + parser_error(parser, "@mailbox(bound: N) must precede an `actor` definition"); + return NULL; + } + ASTNode* ad = parse_actor_definition(parser); + if (ad) { + char tag[48]; + snprintf(tag, sizeof(tag), "mailbox_bound:%ld", bound); + free(ad->annotation); + ad->annotation = strdup(tag); + } + node = ad; + break; + } // @derive(eq[, format, clone, hash]) struct T { ... } // // Issue #338: synthesize trait-shaped helpers from @@ -8530,7 +8600,7 @@ ASTNode* parse_top_level_decl(Parser* parser) { break; } if (!attr || attr->type != TOKEN_EXTERN) { - parser_error(parser, "unknown attribute (expected @extern(\"...\"), @c_callback, @derive, @quotable, or an effect tag @pure/@no_fs/@no_net/@no_os)"); + parser_error(parser, "unknown attribute (expected @extern(\"...\"), @c_callback, @derive, @mailbox, @quotable, or an effect tag @pure/@no_fs/@no_net/@no_os)"); advance_token(parser); return NULL; } diff --git a/contrib/host/TODO.md b/contrib/host/TODO.md index 219f79566..fe0fd85b2 100644 --- a/contrib/host/TODO.md +++ b/contrib/host/TODO.md @@ -1,7 +1,7 @@ # contrib/host TODO ## All host modules -- [x] Bridges self-contained: each host maintains its own permission stack and registers its own checker via the public `_aether_sandbox_checker`. No longer pokes at the compiler-emitted preamble's `static` `_aether_ctx_stack`. Applies to python, lua, perl, ruby, js, tcl; the aether host is separate-process and unaffected. Demonstrated end-to-end in `examples/04-interop/05-hosts/tcl.ae` (compiles, links, runs). +- [x] Bridges self-contained: each host pushes its `perms` as a grant level of the calling thread with `aether_sandbox_push` / `aether_sandbox_pop` (runtime/aether_sandbox.h), so a bridge called inside an `enforce` block is held to both (#2955; each used to keep a process-wide stack and swap the process-wide checker pointer, which dropped the enclosing block's grants and raced other threads). No longer pokes at the compiler-emitted preamble's `static` `_aether_ctx_stack`. Applies to python, lua, perl, ruby, js, tcl; the aether host is separate-process and unaffected. Demonstrated end-to-end in `examples/04-interop/05-hosts/tcl.ae` (compiles, links, runs). - [x] Import path drift fixed: all READMEs and module.ae headers now document the working form `import contrib.host.` (previously said `std.host.`, which the compiler's stdlib resolver doesn't handle since the modules live under `contrib/host/`, not `std/host//`). `docs/containment-sandbox.md` already used the correct form. - [x] IPv6-mapped addresses normalized in `pattern_match`: a grant for `10.0.0.1` now matches a TCP resource reported as `::ffff:10.0.0.1` (and vice versa). Applied to all 6 in-process bridges, the LD_PRELOAD preload checker (`runtime/libaether_sandbox_preload.c`), and the Java-side `AetherGrantChecker`. - [x] CI + `make contrib-host-check` target added: syntax-checks every bridge in stub mode and runs per-language demos when the dev library is installed. Gracefully skips languages whose headers/libs aren't present. Wired into a Linux CI job (`ci-contrib-host`) that installs lua/python/ruby/perl/tcl/duktape/go dev packages. diff --git a/contrib/host/duktape/aether_host_duktape.c b/contrib/host/duktape/aether_host_duktape.c index c5d514f10..b77361c37 100644 --- a/contrib/host/duktape/aether_host_duktape.c +++ b/contrib/host/duktape/aether_host_duktape.c @@ -2,7 +2,7 @@ // // Unlike Python/Lua, Duktape has NO built-in filesystem or env access. // We provide native bindings (env, readFile, print) that go through -// the Aether sandbox checker. This is the purest containment model — +// the Aether sandbox check of the calling thread. This is the purest containment model — // the guest can ONLY do what we explicitly expose. // // LOADING MODEL: dlopen, not -lduktape. Mirrors contrib/host/python, @@ -162,8 +162,6 @@ static const char* dh_safe_to_string(duk_context* c, duk_idx_t idx) { static duk_context* ctx = NULL; -static void* duktape_perms_stack[64]; -static int duktape_perms_depth = 0; extern int list_size(void*); extern void* list_get_raw(void*, int); @@ -175,26 +173,11 @@ extern const char* aether_string_data(const void*); // The shared grant matcher (runtime/aether_sandbox_match.h). #include "aether_sandbox_match.h" -static int perms_allow(void* perms, const char* category, const char* resource) { - if (!perms) return 1; - int n = list_size(perms); - if (n == 0) return 0; - for (int i = 0; i < n; i += 2) { - const char* cat = aether_string_data(list_get_raw(perms, i)); - const char* pat = aether_string_data(list_get_raw(perms, i + 1)); - if (!cat || !pat) continue; - if (cat[0] == '*' && pat[0] == '*') return 1; - if (strcmp(cat, category) == 0 && aether_grant_match(category, pat, resource)) return 1; - } - return 0; -} - +/* What JS reaches goes through the natives below; each checks the calling + * thread's grant levels, the ones run_sandboxed pushed and any enforce block + * around the call, so a nested call can only narrow them (#2955). */ static int check_sandbox(const char* category, const char* resource) { - if (duktape_perms_depth <= 0) return 1; - for (int level = 0; level < duktape_perms_depth; level++) { - if (!perms_allow(duktape_perms_stack[level], category, resource)) return 0; - } - return 1; + return aether_sandbox_check(category, resource); } // --- Duktape native bindings (exposed to JS) ------------------------------- @@ -342,10 +325,8 @@ int duktape_run_sandboxed(void* perms, const char* code) { if (!code) return -1; if (duktape_init() != 0) return -1; - if (duktape_perms_depth >= 64) return -1; - duktape_perms_stack[duktape_perms_depth++] = perms; - aether_sandbox_check_fn prev = _aether_sandbox_checker; - _aether_sandbox_checker = NULL; // JS uses direct check_sandbox, not libc interception + if (aether_sandbox_depth() >= AETHER_SANDBOX_MAX_LEVELS) return -1; + aether_sandbox_push(perms); int result = 0; if (dh_peval_string(ctx, code) != 0) { @@ -356,8 +337,7 @@ int duktape_run_sandboxed(void* perms, const char* code) { g_duk.duk_pop(ctx); } - _aether_sandbox_checker = prev; - duktape_perms_depth--; + aether_sandbox_pop(); return result; } @@ -396,10 +376,8 @@ int duktape_run_sandboxed_with_map(void* perms, const char* code, uint64_t map_t aether_shared_map_freeze_inputs_by_token(map_token); duktape_current_map_token = map_token; - if (duktape_perms_depth >= 64) return -1; - duktape_perms_stack[duktape_perms_depth++] = perms; - aether_sandbox_check_fn prev = _aether_sandbox_checker; - _aether_sandbox_checker = NULL; + if (aether_sandbox_depth() >= AETHER_SANDBOX_MAX_LEVELS) return -1; + aether_sandbox_push(perms); int result = 0; if (dh_peval_string(ctx, code) != 0) { @@ -410,8 +388,7 @@ int duktape_run_sandboxed_with_map(void* perms, const char* code, uint64_t map_t g_duk.duk_pop(ctx); } - _aether_sandbox_checker = prev; - duktape_perms_depth--; + aether_sandbox_pop(); duktape_current_map_token = 0; return result; diff --git a/contrib/host/lua/aether_host_lua.c b/contrib/host/lua/aether_host_lua.c index be824c748..045ff4862 100644 --- a/contrib/host/lua/aether_host_lua.c +++ b/contrib/host/lua/aether_host_lua.c @@ -1,7 +1,7 @@ // aether_host_lua.c — Embedded Lua Language Host Module // // Embeds Lua in the Aether process. When run_sandboxed is called, -// installs the Aether sandbox checker so Lua's libc calls are +// pushes its grants as a level of the calling thread's (#2955), so Lua's libc calls are // intercepted and checked against the grant list. // // LOADING MODEL: dlopen, not -llua. Mirrors contrib/host/python and @@ -226,8 +226,6 @@ static void lh_pushcfunction(lua_State* L, lua_CFunction fn) { static lua_State* L = NULL; -static void* lua_perms_stack[64]; -static int lua_perms_depth = 0; extern int list_size(void*); extern void* list_get_raw(void*, int); @@ -239,28 +237,6 @@ extern const char* aether_string_data(const void*); // The shared grant matcher (runtime/aether_sandbox_match.h). #include "aether_sandbox_match.h" -static int perms_allow(void* ctx, const char* category, const char* resource) { - if (!ctx) return 1; - int n = list_size(ctx); - if (n == 0) return 0; - for (int i = 0; i < n; i += 2) { - const char* cat = aether_string_data(list_get_raw(ctx, i)); - const char* pat = aether_string_data(list_get_raw(ctx, i + 1)); - if (!cat || !pat) continue; - if (cat[0] == '*' && pat[0] == '*') return 1; - if (strcmp(cat, category) == 0 && aether_grant_match(category, pat, resource)) return 1; - } - return 0; -} - -static int host_lua_checker(const char* category, const char* resource) { - if (lua_perms_depth <= 0) return 1; - for (int level = 0; level < lua_perms_depth; level++) { - if (!perms_allow(lua_perms_stack[level], category, resource)) return 0; - } - return 1; -} - int lua_init(void) { if (L) return 0; if (load_liblua() != 0) return -1; @@ -291,11 +267,9 @@ int lua_run(const char* code) { int lua_run_sandboxed(void* perms, const char* code) { if (!code) return -1; if (lua_init() != 0) return -1; - if (lua_perms_depth >= 64) return -1; + if (aether_sandbox_depth() >= AETHER_SANDBOX_MAX_LEVELS) return -1; - lua_perms_stack[lua_perms_depth++] = perms; - aether_sandbox_check_fn prev = _aether_sandbox_checker; - _aether_sandbox_checker = host_lua_checker; + aether_sandbox_push(perms); int result = 0; if (lh_dostring(L, code) != 0) { @@ -304,8 +278,7 @@ int lua_run_sandboxed(void* perms, const char* code) { result = -1; } - _aether_sandbox_checker = prev; - lua_perms_depth--; + aether_sandbox_pop(); return result; } @@ -349,11 +322,9 @@ int lua_run_sandboxed_with_map(void* perms, const char* code, uint64_t map_token current_map_token = map_token; - if (lua_perms_depth >= 64) return -1; + if (aether_sandbox_depth() >= AETHER_SANDBOX_MAX_LEVELS) return -1; - lua_perms_stack[lua_perms_depth++] = perms; - aether_sandbox_check_fn prev = _aether_sandbox_checker; - _aether_sandbox_checker = host_lua_checker; + aether_sandbox_push(perms); int result = 0; if (lh_dostring(L, code) != 0) { @@ -362,8 +333,7 @@ int lua_run_sandboxed_with_map(void* perms, const char* code, uint64_t map_token result = -1; } - _aether_sandbox_checker = prev; - lua_perms_depth--; + aether_sandbox_pop(); current_map_token = 0; return result; @@ -419,7 +389,7 @@ static int ae_lua_trampoline(lua_State* state) { } /* Create a persistent, host-owned VM. Returns the handle (lua_State*) or NULL. - * libs are opened; the sandbox checker is NOT installed here (the host drives + * libs are opened; no grants are pushed here (the host drives * it per eval via lua_vm_eval_sandboxed if it wants the libc gate). */ void* lua_vm_new(void) { if (load_liblua() != 0) return NULL; diff --git a/contrib/host/perl/aether_host_perl.c b/contrib/host/perl/aether_host_perl.c index c92c360ef..cc23ab6f5 100644 --- a/contrib/host/perl/aether_host_perl.c +++ b/contrib/host/perl/aether_host_perl.c @@ -138,8 +138,6 @@ static int load_libperl(void) { static PerlInterpreter* my_perl = NULL; -static void* perl_perms_stack[64]; -static int perl_perms_depth = 0; extern int list_size(void*); extern void* list_get_raw(void*, int); @@ -151,28 +149,6 @@ extern const char* aether_string_data(const void*); // The shared grant matcher (runtime/aether_sandbox_match.h). #include "aether_sandbox_match.h" -static int perms_allow(void* perms, const char* category, const char* resource) { - if (!perms) return 1; - int n = list_size(perms); - if (n == 0) return 0; - for (int i = 0; i < n; i += 2) { - const char* cat = aether_string_data(list_get_raw(perms, i)); - const char* pat = aether_string_data(list_get_raw(perms, i + 1)); - if (!cat || !pat) continue; - if (cat[0] == '*' && pat[0] == '*') return 1; - if (strcmp(cat, category) == 0 && aether_grant_match(category, pat, resource)) return 1; - } - return 0; -} - -static int host_perl_checker(const char* category, const char* resource) { - if (perl_perms_depth <= 0) return 1; - for (int level = 0; level < perl_perms_depth; level++) { - if (!perms_allow(perl_perms_stack[level], category, resource)) return 0; - } - return 1; -} - int aether_perl_init(void) { if (my_perl) return 0; if (load_libperl() != 0) return -1; @@ -222,10 +198,8 @@ int aether_perl_run_sandboxed(void* perms, const char* code) { if (!code) return -1; if (aether_perl_init() != 0) return -1; - if (perl_perms_depth >= 64) return -1; - perl_perms_stack[perl_perms_depth++] = perms; - aether_sandbox_check_fn prev = _aether_sandbox_checker; - _aether_sandbox_checker = host_perl_checker; + if (aether_sandbox_depth() >= AETHER_SANDBOX_MAX_LEVELS) return -1; + aether_sandbox_push(perms); // Scrub Perl's cached %ENV — delete vars the sandbox doesn't grant. // Perl populates %ENV at startup before the sandbox is active. @@ -254,8 +228,7 @@ int aether_perl_run_sandboxed(void* perms, const char* code) { int result = run_perl_code(code); - _aether_sandbox_checker = prev; - perl_perms_depth--; + aether_sandbox_pop(); return result; } @@ -289,15 +262,12 @@ int aether_perl_run_sandboxed_with_map(void* perms, const char* code, uint64_t m run_perl_code(inject); } - if (perl_perms_depth >= 64) return -1; - perl_perms_stack[perl_perms_depth++] = perms; - aether_sandbox_check_fn prev = _aether_sandbox_checker; - _aether_sandbox_checker = host_perl_checker; + if (aether_sandbox_depth() >= AETHER_SANDBOX_MAX_LEVELS) return -1; + aether_sandbox_push(perms); int result = run_perl_code(code); - _aether_sandbox_checker = prev; - perl_perms_depth--; + aether_sandbox_pop(); // Note: outputs written via aether_map_put stay in Perl's // %_aether_output hash. Surfacing them back to the C-side shared diff --git a/contrib/host/python/aether_host_python.c b/contrib/host/python/aether_host_python.c index 9ff038845..ba3a66598 100644 --- a/contrib/host/python/aether_host_python.c +++ b/contrib/host/python/aether_host_python.c @@ -1,7 +1,7 @@ // aether_host_python.c — Embedded Python Language Host Module // // Embeds CPython in the Aether process. When run_sandboxed is called, -// installs the Aether sandbox checker so Python's libc calls are +// pushes its grants as a level of the calling thread's (#2955), so Python's libc calls are // intercepted and checked against the grant list. // // LOADING MODEL: dlopen, not -lpython. Resolves libpython at the @@ -153,8 +153,6 @@ static int load_libpython(void) { static int python_initialized = 0; -static void* python_perms_stack[64]; -static int python_perms_depth = 0; extern int list_size(void*); extern void* list_get_raw(void*, int); @@ -166,28 +164,6 @@ extern const char* aether_string_data(const void*); // The shared grant matcher (runtime/aether_sandbox_match.h). #include "aether_sandbox_match.h" -static int perms_allow(void* ctx, const char* category, const char* resource) { - if (!ctx) return 1; - int n = list_size(ctx); - if (n == 0) return 0; - for (int i = 0; i < n; i += 2) { - const char* cat = aether_string_data(list_get_raw(ctx, i)); - const char* pat = aether_string_data(list_get_raw(ctx, i + 1)); - if (!cat || !pat) continue; - if (cat[0] == '*' && pat[0] == '*') return 1; - if (strcmp(cat, category) == 0 && aether_grant_match(category, pat, resource)) return 1; - } - return 0; -} - -static int host_python_checker(const char* category, const char* resource) { - if (python_perms_depth <= 0) return 1; - for (int level = 0; level < python_perms_depth; level++) { - if (!perms_allow(python_perms_stack[level], category, resource)) return 0; - } - return 1; -} - int python_init(void) { if (python_initialized) return 0; if (load_libpython() != 0) return -1; @@ -212,18 +188,15 @@ int python_run(const char* code) { int python_run_sandboxed(void* perms, const char* code) { if (!code) return -1; if (python_init() != 0) return -1; - if (python_perms_depth >= 64) return -1; + if (aether_sandbox_depth() >= AETHER_SANDBOX_MAX_LEVELS) return -1; - // Push perms onto our stack and install our checker - python_perms_stack[python_perms_depth++] = perms; - aether_sandbox_check_fn prev = _aether_sandbox_checker; - _aether_sandbox_checker = host_python_checker; + // The grants as a level of this thread's, in the runtime (#2955) + aether_sandbox_push(perms); int result = g_py.PyRun_SimpleStringFlags(code, NULL); // Restore - _aether_sandbox_checker = prev; - python_perms_depth--; + aether_sandbox_pop(); return result; } @@ -300,20 +273,17 @@ int python_run_sandboxed_with_map(void* perms, const char* code, uint64_t map_to const char* preamble = "from _aether_map import aether_map_get, aether_map_put\n"; - if (python_perms_depth >= 64) return -1; + if (aether_sandbox_depth() >= AETHER_SANDBOX_MAX_LEVELS) return -1; - // Push perms and install checker - python_perms_stack[python_perms_depth++] = perms; - aether_sandbox_check_fn prev = _aether_sandbox_checker; - _aether_sandbox_checker = host_python_checker; + // The grants as a level of this thread's, in the runtime (#2955) + aether_sandbox_push(perms); // Run preamble + user code g_py.PyRun_SimpleStringFlags(preamble, NULL); int result = g_py.PyRun_SimpleStringFlags(code, NULL); // Restore - _aether_sandbox_checker = prev; - python_perms_depth--; + aether_sandbox_pop(); py_current_map_token = 0; return result; diff --git a/contrib/host/ruby/aether_host_ruby.c b/contrib/host/ruby/aether_host_ruby.c index 734961321..9539590b0 100644 --- a/contrib/host/ruby/aether_host_ruby.c +++ b/contrib/host/ruby/aether_host_ruby.c @@ -160,8 +160,6 @@ static int load_libruby(void) { static int ruby_initialized = 0; -static void* ruby_perms_stack[64]; -static int ruby_perms_depth = 0; extern int list_size(void*); extern void* list_get_raw(void*, int); @@ -173,28 +171,6 @@ extern const char* aether_string_data(const void*); // The shared grant matcher (runtime/aether_sandbox_match.h). #include "aether_sandbox_match.h" -static int perms_allow(void* perms, const char* category, const char* resource) { - if (!perms) return 1; - int n = list_size(perms); - if (n == 0) return 0; - for (int i = 0; i < n; i += 2) { - const char* cat = aether_string_data(list_get_raw(perms, i)); - const char* pat = aether_string_data(list_get_raw(perms, i + 1)); - if (!cat || !pat) continue; - if (cat[0] == '*' && pat[0] == '*') return 1; - if (strcmp(cat, category) == 0 && aether_grant_match(category, pat, resource)) return 1; - } - return 0; -} - -static int host_ruby_checker(const char* category, const char* resource) { - if (ruby_perms_depth <= 0) return 1; - for (int level = 0; level < ruby_perms_depth; level++) { - if (!perms_allow(ruby_perms_stack[level], category, resource)) return 0; - } - return 1; -} - int ruby_init_host(void) { if (ruby_initialized) return 0; if (load_libruby() != 0) return -1; @@ -270,10 +246,8 @@ int ruby_run_sandboxed(void* perms, const char* code) { if (!code) return -1; if (ruby_init_host() != 0) return -1; - if (ruby_perms_depth >= 64) return -1; - ruby_perms_stack[ruby_perms_depth++] = perms; - aether_sandbox_check_fn prev = _aether_sandbox_checker; - _aether_sandbox_checker = host_ruby_checker; + if (aether_sandbox_depth() >= AETHER_SANDBOX_MAX_LEVELS) return -1; + aether_sandbox_push(perms); // Scrub Ruby's ENV hash — delete vars the sandbox doesn't grant. // Ruby caches ENV at startup like Perl. @@ -302,8 +276,7 @@ int ruby_run_sandboxed(void* perms, const char* code) { int result = eval_ruby(code); - _aether_sandbox_checker = prev; - ruby_perms_depth--; + aether_sandbox_pop(); return result; } @@ -360,15 +333,12 @@ int ruby_run_sandboxed_with_map(void* perms, const char* code, uint64_t map_toke eval_ruby(scrub); } - if (ruby_perms_depth >= 64) return -1; - ruby_perms_stack[ruby_perms_depth++] = perms; - aether_sandbox_check_fn prev = _aether_sandbox_checker; - _aether_sandbox_checker = host_ruby_checker; + if (aether_sandbox_depth() >= AETHER_SANDBOX_MAX_LEVELS) return -1; + aether_sandbox_push(perms); int result = eval_ruby(code); - _aether_sandbox_checker = prev; - ruby_perms_depth--; + aether_sandbox_pop(); return result; } diff --git a/contrib/host/tcl/aether_host_tcl.c b/contrib/host/tcl/aether_host_tcl.c index 20c181504..d515065c6 100644 --- a/contrib/host/tcl/aether_host_tcl.c +++ b/contrib/host/tcl/aether_host_tcl.c @@ -1,7 +1,7 @@ // aether_host_tcl.c — Embedded Tcl Language Host Module // // Embeds libtcl in the Aether process. When run_sandboxed is called, -// installs the Aether sandbox checker so Tcl's libc calls (open, +// pushes its grants as a level of the calling thread's (#2955), so Tcl's libc calls (open, // getenv, socket, exec) are intercepted and checked against the // grant list. // @@ -184,8 +184,6 @@ static int load_libtcl(void) { static Tcl_Interp* T = NULL; // Bridge-owned permission stack. -static void* tcl_perms_stack[64]; -static int tcl_perms_depth = 0; extern int list_size(void*); extern void* list_get_raw(void*, int); @@ -197,28 +195,6 @@ extern const char* aether_string_data(const void*); // The shared grant matcher (runtime/aether_sandbox_match.h). #include "aether_sandbox_match.h" -static int perms_allow(void* ctx, const char* category, const char* resource) { - if (!ctx) return 1; - int n = list_size(ctx); - if (n == 0) return 0; - for (int i = 0; i < n; i += 2) { - const char* cat = aether_string_data(list_get_raw(ctx, i)); - const char* pat = aether_string_data(list_get_raw(ctx, i + 1)); - if (!cat || !pat) continue; - if (cat[0] == '*' && pat[0] == '*') return 1; - if (strcmp(cat, category) == 0 && aether_grant_match(category, pat, resource)) return 1; - } - return 0; -} - -static int host_tcl_checker(const char* category, const char* resource) { - if (tcl_perms_depth <= 0) return 1; - for (int level = 0; level < tcl_perms_depth; level++) { - if (!perms_allow(tcl_perms_stack[level], category, resource)) return 0; - } - return 1; -} - int tcl_init(void) { if (T) return 0; if (load_libtcl() != 0) return -1; @@ -255,11 +231,9 @@ int tcl_run(const char* code) { int tcl_run_sandboxed(void* perms, const char* code) { if (!code) return -1; if (tcl_init() != 0) return -1; - if (tcl_perms_depth >= 64) return -1; + if (aether_sandbox_depth() >= AETHER_SANDBOX_MAX_LEVELS) return -1; - tcl_perms_stack[tcl_perms_depth++] = perms; - aether_sandbox_check_fn prev = _aether_sandbox_checker; - _aether_sandbox_checker = host_tcl_checker; + aether_sandbox_push(perms); int result = 0; if (tcl_eval(T, code) != TCL_OK) { @@ -267,8 +241,7 @@ int tcl_run_sandboxed(void* perms, const char* code) { result = -1; } - _aether_sandbox_checker = prev; - tcl_perms_depth--; + aether_sandbox_pop(); return result; } @@ -317,7 +290,7 @@ static void register_map_bindings(Tcl_Interp* interp) { int tcl_run_sandboxed_with_map(void* perms, const char* code, uint64_t map_token) { if (!code) return -1; if (tcl_init() != 0) return -1; - if (tcl_perms_depth >= 64) return -1; + if (aether_sandbox_depth() >= AETHER_SANDBOX_MAX_LEVELS) return -1; register_map_bindings(T); extern void aether_shared_map_freeze_inputs_by_token(uint64_t); @@ -325,9 +298,7 @@ int tcl_run_sandboxed_with_map(void* perms, const char* code, uint64_t map_token current_map_token = map_token; - tcl_perms_stack[tcl_perms_depth++] = perms; - aether_sandbox_check_fn prev = _aether_sandbox_checker; - _aether_sandbox_checker = host_tcl_checker; + aether_sandbox_push(perms); int result = 0; if (tcl_eval(T, code) != TCL_OK) { @@ -335,8 +306,7 @@ int tcl_run_sandboxed_with_map(void* perms, const char* code, uint64_t map_token result = -1; } - _aether_sandbox_checker = prev; - tcl_perms_depth--; + aether_sandbox_pop(); current_map_token = 0; return result; diff --git a/docs/abi-policy.md b/docs/abi-policy.md new file mode 100644 index 000000000..014d0bf0b --- /dev/null +++ b/docs/abi-policy.md @@ -0,0 +1,150 @@ +# ABI compatibility: what a shipped library promises + +A library built by one Aether is used by code built by another: a C host +compiled against an older `libaether.h`, a program whose `ae` is newer, a +plugin host that loads libraries of several releases into one process. +This page says which of those pairs are supported, what each side may rely +on, and where an unsupported pair is refused before anything runs (#2957). + +## The boundaries + +| Boundary | What crosses it | Stability | +|---|---|---| +| The host ABI | `include/libaether.h`; a library's `aether_` exports, `aether__string_release`, `aether__outcome` and `aether__check` | Versioned by `AETHER_HOST_ABI` (1). Within a level it only grows | +| The catalog | `aether_lib_meta()`, the record an importer and a plugin host read | Schema `"1.minor"`: fields are only appended within 1 | +| The runtime ABI | generated C and the runtime it runs on: actor, message and frame layouts, the entry points generated code calls | Not stable across versions: a rebuild boundary | +| Shared facilities | what runtime copies in one process share through a rendezvous: sandbox levels, execution accounts | Versioned per facility, with the sizes of what the copies pass | +| Values | strings and closures | A string crosses a static boundary only as a copy; a closure, or a struct holding a string, never crosses one | + +**The host ABI.** Within a level, new functions, constants and outcomes +may be added; a removal, a layout change of a struct in `libaether.h` +(`AetherExecToken`, `AetherExecLimits`) or a change of meaning bumps the +level. A host treats an outcome it does not know as a failure, as it does +`AETHER_PANICKED`. A library records the level it was built at in its +catalog (`host_abi`, schema 1.11). A library with no `host_abi` (Aether +0.856 and before) is level 0, whose surface is `aether_set_memory_cap`, +`aether_set_call_deadline` and `aether_deadline_tripped`; level 1 adds the +outcomes, cancellation, execution accounts and the preflight. A host uses a +function of a level only when the header it was compiled against has it and +the library records that level or more. + +**The catalog.** An importer reads a catalog of any minor of schema 1, an +older one by its minor as before, and refuses another major. What a newer +catalog asks of its importer is listed in `requires`, its duties: + +| Duty | What the importer must do | +|---|---| +| `outcome_check` | read each call's outcome and raise a failure in the program (schema 1.10) | +| `string_release` | give a returned string back through the library's release (1.9) | +| `shared_runtime` | link the program against the shared runtime the library runs on (1.5) | +| `runtime_abi` | check that runtime is the one the library was made for (1.11) | +| `actors` | run the library's actors on the program's scheduler (1.7) | + +An importer that finds a duty it does not know refuses the library, naming +the duty, both Aether versions and the fix (upgrade `ae`, or rebuild the +library). A newer minor whose duties it knows is read. + +**The runtime ABI.** `AETHER_RUNTIME_ABI_ID` (`runtime/aether_abi.h`) is +the Aether version, `/abi` and `AETHER_RUNTIME_ABI_REV`, and the flags +that change layouts (`+debug-mailbox`). A self-contained library carries +its own runtime, so this ABI never crosses its edge. A `--shared-runtime` +library, and C emitted with `--emit=csrc` or `--emit=obj`, must run on the +runtime it was made for. The shared runtime reports its ID +(`aether_runtime_abi()`), and the catalog of a shared-runtime library +records the one it was made for (`runtime_abi`). + +**Shared facilities.** A library without `--shared-runtime` brings a copy +of the runtime, and copies in one process share the sandbox levels and the +execution accounts through the first copy loaded that has them, by its +record in its image (a PE section, an ELF note, a Mach-O section, #3445). +The record's layout never changes, so a copy of any release reads any +other's. Each +rendezvous starts with its version and its size, and the accounts' also +carry the sizes of what the copies pass each other. A copy that finds +another layout refuses that one facility, says so once on stderr naming +`--shared-runtime`, and reads nothing of the other's but the version and +sizes. + +## Where an unsupported pair is refused + +Every check runs before any code of the library does. + +| The pair | Refused by | How | +|---|---|---| +| A catalog of another major | the importer (`ae build`) | "reads schema 1 only" | +| A catalog that requires a duty the importer does not know | the importer | names the duty, both versions and the fix | +| A `--shared-runtime` library made for another runtime ABI, or recording none (level 0) | the importer | names both runtimes; "rebuild the library with this Aether" | +| Generated C compiled against the headers of another Aether | the C compiler | `#error` naming the aetherc that generated it | +| A library linked or loaded against another runtime than its own (a shared runtime replaced in place, or `--emit=csrc` code) | its first call | every export returns its zero value with `AETHER_INCOMPATIBLE` (6), none of its code runs, the reason is printed once and in `aether_last_outcome_text()`; `aether__check(&why)` asks first, 0 or -1 | +| A shared-runtime library on an older runtime that lacks the entry points it calls | the loader | `dlopen` or `LoadLibrary` fails | +| A rendezvous of another layout | the copy that finds it | that facility only, once on stderr | + +`tests/integration/abi_policy` exercises each of these, with hand-made +catalogs and libraries compiled for a runtime of another ABI. + +## Supported combinations + +| Combination | Supported | +|---|---| +| A C host and a self-contained library of any version | Yes, using the functions of the library's level (read `host_abi`); a host built with older headers uses its own level's | +| An Aether program importing a self-contained library | Yes, when the importer knows every duty the catalog requires; a library of an older version is read by its minor | +| An importer older than schema 1.11 and a newer library | Calls that succeed work; it cannot read `requires`, so it does not check outcomes, and a failed call reads as its zero value, as with 0.856's own libraries | +| Anything on `--shared-runtime` | Only on the runtime it was made for (one runtime ABI ID) | +| Two self-contained libraries of different versions in one process | Yes; each runs on its own runtime, and the shared facilities are refused across layouts | +| `--emit=csrc` and `--emit=obj` | Only with the runtime of the Aether that generated them | + +Verified by the cross-version matrix below on Windows x86_64 against +0.856.0 (by hand), and on Linux x86_64 and macOS arm64 in CI against the +latest published release, on every pull request and before every release. + +## Deprecated names + +A removed feature's names stay, accepted and ignored, until the next host +ABI level removes them. Each is marked where it is declared. + +| Name | Since | Now | Removed at | +|---|---|---|---| +| `AETHER_FLAG_LOCKFREE_POOLS` (`runtime/aether_runtime.h`) | #3374 | accepted, ignored; `aether_runtime_has_feature()` reports it off | host ABI level 2 | +| `aether_message_pool_stats` (`runtime/actors/aether_send_message.h`) | #3374 | reports zeros | host ABI level 2 | + +## The cross-version matrix + +`tests/integration/abi_matrix` builds `mlib.ae` with this Aether and with a +release before it (`AETHER_ABI_OLD_HOME`, an extracted release archive) and +runs every supported pair, and the refusals of the unsupported ones: + +- C hosts that read everything by name: this header with this library and + with the old one, and the old header with this library: typed calls, a + string given back with no byte kept, errors and deadlines at the levels + the pair has; +- the old and the new library in one process, each with its own runtime: + calls interleaved, an account of the new one suspended while the old one + runs and resumed, both unloaded; +- Aether consumers both ways: this importer with the old library, the old + importer with this one; +- the shared runtime: each library on its own, the new library on the old + runtime (refused at load or by its preflight), and this importer with the + old shared-runtime library (refused). + +What each case prints depends on the levels alone, so the matrix holds +against whichever release came before. To run it: + +```sh +AETHER_ABI_OLD_HOME=/path/to/aether-0.856.0 sh tests/integration/abi_matrix/test_abi_matrix.sh +``` + +Without `AETHER_ABI_OLD_HOME` it skips. + +## Changing the ABI + +`tests/runtime/test_abi_layout.c` records the layouts of host ABI level 1, +catalog schema 1.11 and the rendezvous versions, and fails first when one +changes: + +- a struct or constant of `libaether.h`: bump `AETHER_HOST_ABI`, and + record the new level's layouts beside the old; +- the catalog: append the field at the end of `AetherLibMeta`, bump the + minor, and add a duty to `requires` when an importer must act on it; +- a runtime layout or entry point between releases: bump + `AETHER_RUNTIME_ABI_REV`; +- a rendezvous: bump its version. diff --git a/docs/actor-concurrency.md b/docs/actor-concurrency.md index 98ebbbec2..a3c8e434e 100644 --- a/docs/actor-concurrency.md +++ b/docs/actor-concurrency.md @@ -37,11 +37,6 @@ a scheduler thread runs the handler, from the first actor on (#3112). - Batch dequeue drains multiple messages in a single atomic operation - Configurable threshold (SCHEDULER_DRAIN_BATCH = 512) -**Thread-Local Message Pools:** -- Per-thread pool of 256 pre-allocated buffers (up to 256 bytes each) -- Eliminates malloc/free on the message hot path -- Falls back to malloc for oversized messages or pool exhaustion - **Direct Send:** - Bypasses incoming queue for same-core actors - Writes directly to the actor's mailbox @@ -52,7 +47,7 @@ a scheduler thread runs the handler, from the first actor on (#3112). - Increases under sustained load, decreases when idle **Inline Single-Int Messages:** -- Messages with one integer field bypass pool allocation entirely +- Messages with one integer field need no payload allocation at all - Value encoded directly in `Message.payload_int` ## Actor Model @@ -65,6 +60,7 @@ Aether implements a lightweight actor model where actors are state machines that 2. **Send** - Send messages with the `!` operator or generated send functions 3. **Step** - Scheduler calls the actor's step function to process mailbox messages 4. **Deactivate** - Actor is marked inactive when its mailbox is empty +5. **End** - A panic in its handler, `actors.kill(ref)`, `actors.stop()` from its own handler, or a C host's release ends it, once ([The end of an actor](#the-end-of-an-actor)) ### Actor Structure @@ -80,7 +76,7 @@ typedef struct { } CounterActor; ``` -The fields, in order: `active`, `id`, `mailbox`, `step`, `thread`, `auto_process`, `assigned_core`, `migrate_to`, `spsc_queue`, `reply_slot`, `step_lock`, `timeout_ns`, `last_activity_ns`, `step_payload`, `step_type`, `dead`, `alloc_size`, `destroy_state`, `release_message`, `scheduler_owned`, `inbox`, `thread_parked`, `inflight`. The header comments each one. +The fields, in order: `active`, `id`, `mailbox`, `step`, `thread`, `auto_process`, `assigned_core`, `migrate_to`, `spsc_queue`, `reply_slot`, `step_lock`, `exit_flags`, `mailbox_bound`, `timeout_ns`, `last_activity_ns`, `step_payload`, `step_type`, `dead`, `alloc_size`, `destroy_state`, `release_message`, `scheduler_owned`, `incarnation`, `inbox`, `thread_parked`, `inflight`, `death_watch`, `mailbox_pending`, `full_drops`. The header comments each one. An actor the compiler generates holds the base as its first member instead, `ActorBase _aether_base;`, followed by its states. So a state can be named like any of these fields (`state id`, `state active`); only a state named `_aether_base` is refused (#3094). @@ -104,6 +100,15 @@ typedef struct { The mailbox is a single-producer single-consumer (SPSC) queue. The owning scheduler thread delivers messages and calls the actor's step function. `count` is `_Atomic` to handle the rare case where work-stealing transfers an actor between cores mid-flight. Cross-core messages arrive via the scheduler's per-sender lock-free incoming queues and are delivered to the mailbox by the owning core. +### A bounded mailbox + +The 32 slots are not a limit: what a full mailbox cannot take waits in the core queues, the senders' deferred sends and the actor's overflow, without bound. An actor declared `@mailbox(bound: N)` (#2974) takes at most N messages that its step has not taken yet, wherever they wait. The generated spawn sets `ActorBase.mailbox_bound`; `mailbox_pending` counts the messages accepted for the actor and not yet taken or dropped: + +- A send takes its place before it routes (`aether_mailbox_admit`, a compare-and-swap loop, exact: refused only while N are pending), in `scheduler_send_local` and `scheduler_send_remote`; `scheduler_send_many` sends a bounded actor's messages through them one at a time, and the main thread's batch takes each one's place as it joins the batch. The bound sits in the padding after `step_lock`, on the line `dead` is on, so an unbounded actor's send pays one load and a branch, and no field moved for it. +- Refused, it is never blocked on (a blocked sender can be a core's thread that the actor's own core waits on): a plain `!` is released and counted (`ActorBase.full_drops`, `scheduler_full_drops()`, `actors.full_drops(ref)`), reported once on stderr; an ask fails at once with `AETHER_ASK_FULL`, "the mailbox is full"; `scheduler_try_send` (`try_send(ref, Msg { ... })`) returns the reason, "the mailbox is full" or "the actor is dead", to its caller, and counts nothing. +- A place comes back when the generated step takes the message (`aether_mailbox_took`, emitted only for a bounded actor) or when a drop releases it before that (`release_undelivered` and the cooperative scheduler's drops: a death's drain, a message on its way to a dead actor, a teardown). +- System messages (`Down`, `IoReady`) are neither counted nor refused. + ### Message Structure ```c @@ -134,7 +139,7 @@ if (current_core_id >= 0 && current_core_id == atomic_load_explicit(&actor->assi } ``` -For single-int messages, the code generator emits an inline path that stores the value directly in `msg.payload_int`, bypassing pool allocation. +For single-int messages, the code generator emits an inline path that stores the value directly in `msg.payload_int`, with no payload allocation. A `ptr` field (a `std.bytes` handle, a `*T`) crosses as the pointer, with no copy. To hand the buffer over rather than share it, send it with `move`: `r ! Chunk { data: move buf }`. The sender's `buf` is then moved-from, and any later use of it is a compile error naming the line of the move. See [`move`](isolated.md#move-handing-a-buffer-over-2975). @@ -180,7 +185,7 @@ A handled message's fields are released by the step after its handler: `_aether_ - the messages queued to an actor that panicked, or that was released, and the ones sent to it after; - a message of a type its receiver has no handler for (its payload was not even freed before); -- the message a handler panicked on: the step records it in `step_payload` while the handler runs, and the step's panic barrier drops it, since the release after the handler never ran; +- the message a handler panicked on: the step records it in `step_payload` while the handler runs, and the step's panic barrier drops it, since the release after the handler never ran. Its drop functions run in a frame of their own, as the drops at an actor's end do: one that panics is reported through the death hook and ends there (#3403). They run in the step's context, as on the actor's own thread, and the barrier gives the thread's context back after them (#3416); - a message refused because the scheduler is not running, or that could not be queued for lack of memory. The receiver's own definitions interpret the payload, as its dispatch does, so a message is released as the program that defined it lays it out. A `ptr` field owns nothing (R4), so a buffer sent with `move` in one and dropped undelivered is not freed; a field of an owned type (`type Buf = owned ptr drop bytes.free`, #3142) is, by this same release, and a handler that binds such a field takes it out of the message, so the release after the handler finds nothing left of it. A message sent through a C `AetherActorRef` whose actor was released has only its payload freed, since the block may hold another actor by then, whose definitions need not be the sender's. @@ -192,9 +197,12 @@ An ask (`actor ? Msg {}`) sends its message with a reply slot (`Message._reply_s - an ask to an actor that is already dead (panicked or released) is not sent; - an ask whose message is dropped undelivered (its target died or was released on the way, or the mailbox it waited in was drained at the death) fails as dead; - an ask whose handler panics with it in hand fails from the step's panic barrier as "died before it replied"; -- an ask whose handler returns without replying fails when its step ends, as "returned without replying". +- an ask whose handler returns without replying fails when its step ends, as "returned without replying"; +- an ask with no memory to make it (its reply slot, or the copy of its message) fails at once and sends nothing (#3387): no state of the thread or of its core is left set, and inside a library call the call ends with `AETHER_OUT_OF_MEMORY`. + +The slot rides in the ask's own message, which the ask builds itself (#3395). It is never handed to the send through the thread: the send can step its target inline, and a message that handler sends carries no slot. -The ask a handler holds is part of its thread's step context, with the actor stepping. A step can run inside another (a send steps an idle actor of the same core inline, and a cooperative ask polls until its reply), so each step starts with no ask of its own and gives the enclosing step its context back when it ends: a handler that sends, or asks, before it replies still answers its own asker. +The ask a handler holds is part of its thread's step context, with the actor stepping. A step can run inside another (a send steps an idle actor of the same core inline, and a cooperative ask polls until its reply), so each step starts with no ask of its own and gives the enclosing step its context back when it ends: a handler that sends, or asks, before it replies still answers its own asker. The ask is one part of a step's context ("The delivery contract", item 5, has the rest). `v, err = actor ? Msg {}` reads the reason (the language reference's Ask Pattern lists them); `v = actor ? Msg {}` gives the reply type's zero value for any of them, as it does at a timeout. @@ -219,6 +227,70 @@ Like the mailbox, the overflow is touched only by the thread that runs the actor An ask that was answered or failed returns only once its reply slot is freed. The replier lets go of the slot a few instructions after it wakes the asker, so the asker waits for that and frees the slot itself (#3245). What an ask allocated is gone when it returns, which a program that counts its heap around asks relies on. A timed-out ask does not wait; its replier frees the slot when it comes. +## The delivery contract + +What a send promises, on both schedulers (#2974): + +1. **Per sender, in order.** Two messages one sender sends one receiver are handled in the order they were sent, on any core and across the receiver's moves between cores (#3090, #3093, #3240, #3241). Nothing is promised between two senders. +2. **At most once.** A message is handled once, or dropped once. A message to an actor that has ended (panicked, killed, stopped), or that its death or a restart in place drops, is released through `aether_message_drop` (what its fields own included) and counted by `scheduler_dead_sends()` (`actors.dead_sends()`); one to a released actor by `scheduler_released_sends()`, one sent while the scheduler is stopped by `scheduler_stopped_sends()`. An ask that no reply can come for fails with the reason as soon as that is known (the actor is dead, dies with it, or its mailbox is full), not at its timeout (#3151). +3. **Unbounded by default, as a decision.** A send never blocks its sender. The sender is often a core's own thread, and one blocked on a full mailbox whose actor that same core, or a core in a cycle with it, must run would deadlock the scheduler. So what a mailbox cannot take waits in the core queues, the senders' deferred sends and the actor's overflow, and memory is the limit. +4. **A bound when the actor asks for one.** `@mailbox(bound: N)` (above, "A bounded mailbox") caps what waits for an actor at N. A send past it is refused at once rather than blocked: counted for a plain `!`, reported to the caller of `try_send` and of an ask. +5. **A handler runs in its own context, whichever thread steps it.** A step can run inside another: a send steps an idle actor of the same core inline, inside the sender's handler, and a cooperative ask polls until its reply. So every step, at entry, puts aside the per-thread context of the code it runs inside and starts with none of it; at exit, normal or by panic, it gives that context back as it was; and its panic, or its end, undoes only what the step itself made (#3389; the rule is written once, in `runtime/actors/aether_step_barrier.h`). What follows the handler is the step's too, and runs in its context as on the actor's own thread: the drop of the message a panicking handler ran on, the finalize of an actor its step ended (the drops of the messages waiting for it are the actor's code) and the death hook, each in a containment frame of its own, so that a panic there ends nothing but itself; the context is given back after them (#3403, #3416). No step runs an actor whose step is already on the stack, so none finalizes an actor whose handler is still running (#3391): both schedulers step an actor only under its step lock, and an ask's poll leaves the asker, and any actor stepping below it, alone. The context, part by part: + - the ask in hand and the actor stepping: a handler that sends, or asks, before it replies still answers its own asker; + - the in-step flags the signal handler reads: the outer handler keeps its signal recovery after an inner step; + - a supervisor's restart claim, the reborn block and the spawn brackets: a nested step's spawn takes no claimed block and is no factory's child, and its panic undoes no enclosing claim; + - the sandbox levels in force on the thread, in every runtime copy that shares the count: a receiver stepped inside an enforce block is checked as on a thread of its own, against the levels it pushes itself, its drops after its handler included (#3416: they ran against the block's), and the block's levels are in force again when the send returns (#3393); + - the library calls running on the thread, with what they carry: an interruption on its way, the call's deadline and cancel, the stop a failed allocation notes, and the caller info the host set for the call. An actor stepped inside a call (a send's inline step on a core's thread, or a cooperative ask's poll in an export) is not the call: its loops are not cut by the call's deadline, a `try` in it catches its own panics, and it reads no caller info, as on a core's thread, in its handler and in its drops after it; the call is cut at its own next safe point once the step returns (#3392, #3396, #3416); + - the execution account in use on the thread (`aether_exec_use`, #2954): an actor stepped inside code that has one in use is charged nothing to it, its drops included, as on a thread of its own; the account stays the thread's meanwhile, and one the step left in use is let go. + + A plain step pays for none of the parts: one word (`aether_caps_slow`'s step bits) says whether some thread holds any of them, read once at each end of the step with one predicted branch; only when it is set are the parts looked at, out of line, each put aside only when this thread holds it. The executions are read from the thread's frames block, which the step's try frame is pushed on anyway. Measured against the barrier before the step-context rule (76d090fd), ping_pong and thread_ring are within 1%, inside the runs' spread; what remains is two thread-local reads at entry, the outer step's in-step flag and actor id, which a nested step gives back. + +## The end of an actor + +An actor ends once (#2974), by the first of these: a panic in its handler (the step's barrier catches it), `actors.kill(ref)` from anywhere (or the death of an actor it is linked to), `actors.stop()` from its own handler, or a C host's `scheduler_release_actor`. Each sets a bit in `ActorBase.dead` (`PANICKED`, `KILLED`, `STOPPED`, `RELEASED`), and the first keeps its reason with its watch list (`aether_actor_death_reason`): the panic's reason, `"killed"` or `"normal"`. From then on a send to it is dropped. + +Its death is then finalized, once, on the thread that owns its mailbox and while the actor steps nothing: what waits for it (its mailbox, the overflow behind it, an actor thread's inbox) is dropped through `aether_message_drop`, an ask among them fails as dead, and every message is credited as handled and counted by `scheduler_dead_sends()` (`actors.dead_sends()`), with those sent to it later and those still on their way when it died. A dropped message's release is the program's code for a field of an owned type (its drop function); each one runs in a frame of its own (`aether_message_drop_at_end`), so a panic in it is reported through the death hook with the actor's id and the panic's reason, as a panic in the release after a handler is by the step barrier, and the drain goes on (#3401: it left the finalize, so the rest of the drain and the notice never ran). The hook runs in a frame of its own too: one that panics is reported on stderr, and the drain goes on (#3417: its panic left the finalize halfway, the messages it had drained never counted, so `scheduler_shutdown` and `wait_for_idle()` waited for them forever, and the watchers never told). Nothing a finalize runs leaves it. Its watchers are then told. Where it happens: + +- a panic: in the step barrier, as the step unwinds; +- a stop, and a kill that lands while its handler runs: when the handler returns (the barrier checks the bits after every step). In the barrier, on either path, the finalize runs in the step's context and in a containment frame of its own, before the step gives the thread's context back: its drops are the actor's code, so an actor stepped inline runs them as on its own thread (#3416); +- a kill of an actor that is not stepping: its core finalizes it at the top of its loop, from a request the kill posts (`Scheduler.control`), or from its relief while it waits in an ask. `scheduler_wait` counts the requests, so `wait_for_idle()` returns only once the Downs are sent. Where no core thread runs (a C host between `scheduler_init` and `scheduler_start`, or one that never starts the cores, and a scheduler that has stopped), the kill finalizes it at once, on the killing thread (#3399: before the start it was posted to cores that did not run yet, and never finalized if they never did); +- an actor with a thread of its own: on that thread, at the end of its loop (#3298: its inbox is dropped and credited too, and a sender that pushes after that drain drops what it pushed). + +So a `Down` never comes while the actor's handler still runs, and it comes after the last messages the actor sent its watcher, on the same core's path. A killed actor whose handler is waiting in an ask has that ask end at once, with `"the asking actor was killed"`; the handler then runs to its end, and the actor ends after it (a handler is never cut short). + +### Death notices + +`actors.monitor(ref)`, from a handler, asks for one `Down { ref: actor_ref, reason: string }` when `ref` ends, and returns the monitor's id; if `ref` has ended already, the `Down` is sent at once with the reason it died of. `actors.demonitor(id)` drops the monitor: no `Down` of it is handled after the call, one already on its way included, since the monitor's id rides in the message (`Message.payload_int`) and the generated step checks it against the watcher's own list before the arm runs (`aether_down_accept`). An actor handles it in a `Down(ref, reason) -> { ... }` arm. + +`Down` is the runtime's message: the compiler declares it for every program that names it, with the reserved id 254 (`AETHER_MSG_DOWN`), and checks its struct against the runtime's `AetherDownMsg`, since the runtime writes the payload; a program's own `message Down` is refused. Its `reason` is a counted string the message owns. An actor with no `Down` arm drops a `Down` as any message it has no arm for. + +Each actor's watchers live in its `death_watch` list (`runtime/actors/aether_supervision.c`), made on first use: who monitors it, whom it monitors, and whom it is linked to. A death marks the list finished under its lock and takes its entries; a monitor that comes after finds it finished and is answered at once. No two lists are locked together. + +### Links + +`actors.link(ref)`, from a handler, ties the two actors both ways (a `LINK` entry in each list). When either ends, the other takes the death as its watchers are told: an actor that traps exits (`actors.trap_exits(true)`, the `AETHER_EXIT_TRAP` bit) gets a `Down` with the reason (monitor id 0, which `demonitor` never drops), and one that does not ends too, by `KILLED`, with the same reason, unless the reason is `"normal"`, which ends nothing. Its own watchers and links then take its death in turn, so a failure climbs a tree of links until an actor that traps exits. Where a death is finalized as soon as it is seen (the cooperative scheduler, and the threaded one once it is stopped), an end a link passes on while the thread is still making another waits in the thread's queue and is made after it, so a chain of linked actors ends in the same stack depth however long it is (#3394). No panic leaves such an end now (a dropped message's release, an owned field's drop function, and the death hook it reports a panic to each run in a frame of their own, #3401, #3417); the queue is still journaled, so a panic that got out would give up the ends still queued, as an unwinding recursion did, and leave the thread's later links working (#3400). `actors.unlink(ref)` removes the entries both ways; a death already taken stays taken. Linking to an actor that has ended already takes its death at once. + +Both schedulers step inside one panic barrier (`runtime/actors/aether_step_barrier.h`), so on the cooperative one too (Emscripten, esp32) a panic is a death with a `Down`. On wasi a panic ends the program, since the target has no setjmp to unwind with, so a death a monitor would watch cannot be caught there: `actors.monitor`, `actors.link` and `actors.trap_exits` (and `std.supervise`, built on them) are refused at compile time on that target, through an `unavailable` attribute on the runtime's declaration. + +### Restart in place + +A supervisor restarts a child under the ref its siblings and clients already hold (`std.supervise`, built on `actors.rebirth_begin`, `actors.rebirth_end` and `actors.incarnation`). It reruns the child's factory, so the factory's configuration (state writes, first messages) reaches the new incarnation too, and the factory's spawn of the child's actor type takes the child's own block instead of a new one: + +1. `actors.rebirth_begin(child)` claims the block when no message for it can still arrive: the child has ended, its death is finalized (what was queued is dropped and counted), its step lock is free (taken now, so nothing steps it), and no message is counted in flight for it. Otherwise it says "not yet" (0), and the supervisor tries again from a message to itself, so it never waits on its own core; or "never" (-1), for a live, released or C-registered actor. +2. The spawn (`scheduler_spawn_actor`, which finds the claim in a runtime thread-local) resets the block: the old state destroyed (`destroy_state`) and zeroed, the mailbox, timeouts, watch lists and reason cleared (the list emptied in place, under its lock: a monitor or a link of the dead child, from another handler, may hold it to read the death it told), `incarnation` one more; `id`, `assigned_core`, the overflow's allocation and the mailbox bound and its pending count kept (every message counted against the bound is taken or dropped, so the count stays exact). The generated spawn then runs the state initializers as on a new block. +3. Its last line, `scheduler_spawn_ready`, clears the death bits with a release store and lets go of the step lock: the child is alive. A fresh spawn is alive already: `aether_rebirth_take` and `scheduler_spawn_ready` read their thread-locals only while a claim or a spawn capture is live on some thread (a process-wide count), so a spawn pays one load for them, not a thread-local read (a call, with emutls). +4. `actors.rebirth_end(child)` returns 1, or undoes a rebirth the factory did not finish (it spawned no actor of that type, or panicked: the child stays ended, "the restart failed"). A panic of the supervisor's own handler undoes it as well. + +No message sent before the restart reaches the new incarnation. A counted send reads its target's death after counting itself, so a message counted before the claim held it up (in flight), and one counted after it reads the death and drops itself (counted in `dead_sends`), until the release store, after which it reaches the new incarnation. The main thread's batch flush reads the death only while a claim is live: the claim counts itself in `aether_caps_slow` (from bit 8, the word a step reads anyway) before it reads the child's inflight count, and the flush counts its messages before it reads that count, both seq_cst, so one of the two sees the other. A local send reads the death on the child's core, where the death was finalized. While the child is dead its core touches it only through counted messages (none) and steps it only under the step lock (the rebirth's), so the reset and the initializers run without a race against its core or its senders. A ref need not tell incarnations apart: the handle is the point. `actors.incarnation(ref)` says how many times it was restarted, and a monitor is per incarnation (a client re-monitors after a restart). A C `AetherActorRef` keeps its `id`, so it reaches the new incarnation too. + +`tests/runtime/test_actor_rebirth.c` restarts an actor that panics every 150 messages while three sender actors and the main thread send to it: each message is handled at most once and in its sender's order, handled plus dropped is what was sent, nothing reaches a half-made incarnation, and nothing a sender had finished sending when the child was claimed reaches the new one. + +### Supervisors + +`std.supervise` is an ordinary actor on these pieces (`std/supervise/module.ae`). `supervise.start` runs each child's factory in order before it returns, inside a spawn capture (`aether_actor_capture_begin` and `aether_actor_capture_end`: the first actor that comes alive between the two, through `scheduler_spawn_ready`, is the child; captures nest), so every child's ref is known at once. The supervisor then traps exits and links each child, so a child's death reaches it as a `Down`, and its own death, by a panic or a kill, takes the children with it. + +At a child's `Down` it checks the child's restart kind (`"permanent"`, `"transient"`, `"temporary"`) and its budget (the restarts made within the window, kept in a ring of times), then takes the strategy's set: the child alone (`one_for_one`), every child (`one_for_all`) or the child and those started after it (`rest_for_one`). The others in the set are killed, newest first, and each child of the set is marked to restart; one an earlier death marked and that is not restarted yet is not marked again, so a death while a restart still waits joins it, and each child restarts once (#3390). The marked children are restarted from a message to itself, one at a time, the first in start order next: a child it killed waits for its own `Down` (so its death is finalized before the claim), and a claim that says "not yet" is tried again from a later message, so the supervisor never blocks its core. Each restart runs the factory between `rebirth_begin` and `rebirth_end`, in the same capture, and links the child again. Past its budget, or at a restart that fails, it kills its children, newest first, and panics with the reason, so its own watchers get a `Down` saying why, and a supervisor it is linked to restarts it in turn. + ## Actor Timeouts The `after` clause on a `receive` block fires a handler if no message arrives within a given number of milliseconds: @@ -235,7 +307,7 @@ actor Monitor { The countdown starts when the actor waits on an empty mailbox: at spawn, and again each time it has handled a message. A message that arrives within the interval stops it, and the next wait starts it from the beginning, as Erlang's `receive ... after` does: the Monitor warns every time 5 seconds pass without a heartbeat. The timeout fires once per countdown. When its handler has run, only a message starts the next one, so a handler that sends the actor a message (`self ! Tick {}` below) keeps a cycle going, and one that sends nothing leaves the actor idle (#3162). -Internally, `timeout_ns` in the actor's base holds the interval, set at spawn and kept, and `last_activity_ns` when the countdown was armed (0 while it is not). The generated `spawn_` arms it. The generated step function arms it again at the end of each message's handling, disarms it when it takes a message, and before each `mailbox_receive()` fires the `after` arm (disarming it first) when it is armed, the interval has passed by `_aether_clock_ns()`, and no message is waiting. With no message to step it for, the actor's core (or its own thread) steps it once the deadline passes, so the timeout fires within a park's timed wait of it (32 ms at most). `wait_for_idle()` waits for every armed countdown of a live actor to end (#3102), so a poller that never stops keeps it waiting. The end of `main` waits for the messages sent to be handled, but not for countdowns (#3219): an actor with `after 60000` that handled a message moments before `main` returned does not hold the exit, and its timeout does not run, on either scheduler. `scheduler_shutdown()` calls the wait with countdowns left out, where `wait_for_idle()` keeps them. A program that wants a countdown to fire before it ends calls `wait_for_idle()` first. +Internally, `timeout_ns` in the actor's base holds the interval, set at spawn and kept, and `last_activity_ns` when the countdown was armed (0 while it is not). The generated `spawn_` arms it. The generated step function arms it again at the end of each message's handling, disarms it when it takes a message, and before each `mailbox_receive()` fires the `after` arm (disarming it first) when it is armed, the interval has passed by `_aether_clock_ns()`, and no message is waiting. With no message to step it for, the actor's core (or its own thread) steps it once the deadline passes, so the timeout fires within a park's timed wait of it (32 ms at most). `wait_for_idle()` waits for every armed countdown of a live actor to end (#3102), and for each arm that fired to return (#3443: the step disarms the countdown before the arm runs, and the arm may still send), so a poller that never stops keeps it waiting. The end of `main` waits for the messages sent to be handled, but not for countdowns (#3219): an actor with `after 60000` that handled a message moments before `main` returned does not hold the exit, and its timeout does not run, on either scheduler. `scheduler_shutdown()` calls the wait with countdowns left out, where `wait_for_idle()` keeps them. A program that wants a countdown to fire before it ends calls `wait_for_idle()` first. The timeout expression can reference actor state fields, not just integer literals, useful when the interval is configurable per-actor or computed: @@ -321,6 +393,14 @@ Both levels are independent and can be used alone or together. In single-core mode, actors run cooperatively. The scheduler processes actors in a loop, calling each actor's step function when its mailbox contains messages. +Each step runs inside the same panic barrier the multi-core scheduler uses (`runtime/actors/aether_step_barrier.h`, #2974), so a panic in a handler ends that actor, not the program: its death is finalized on the spot (or when its handler returns, for a kill or a stop that came during it) and its watchers get a `Down`, as above. Where the target has no setjmp (wasi), a panic still ends the program, and watching a death is refused at compile time. + +An ask polls the actors until its reply comes, on the one thread. Its timeout holds as with threads (#3330): the polls stop at the deadline, and a handler slower than the timeout, which the poll cannot leave while it runs, has its reply discarded as late, so the ask ends with `"ask timed out after N ms"`. The deadline is the only bound: an ask behind a long queue gets its reply once the queue ahead of it is handled, as with threads. An ask whose message is still queued at its deadline leaves it there as a plain send: it is handled later and its reply goes nowhere. An ask nothing can answer (no actor has a message left and no receive timeout is armed) ends at once with the same reason. + +The polls never step the asking actor, nor any actor whose handler is on the stack below it: its next message waits for the handler to return, as with threads (#3391). It used to be handled inside the ask, by a second step of the same actor, its state changing under the first handler, and a `stop()` or a panic there finalized the actor while the first still ran. So an ask only the asker could answer (it asked itself, or an actor that asks it back) is one nothing can answer, and ends at once. + +An export of a library on this runtime that asks (or waits) steps the actors it polls on the call's thread, inside the call, and they run outside it, as every nested step does ("The delivery contract", item 5): the call's deadline does not cut their loops and they read none of the call's caller info. A call can only wait for an actor it steps to return; its deadline then cuts it at its own next safe point (#3392). + ## Multi-Core Runtime The multi-core scheduler uses core partitioning with locality-aware placement: @@ -333,7 +413,7 @@ The multi-core scheduler uses core partitioning with locality-aware placement: ### Synchronization -The `wait_for_idle()` function blocks until all actors have finished processing their messages (quiescence). It is **non-destructive**: it does not stop or join scheduler threads, so it can be called multiple times in a program. This is the recommended way to synchronize the main thread with actor completion: +The `wait_for_idle()` function blocks until all actors have finished processing their messages (quiescence). It is **non-destructive**: it does not stop or join scheduler threads, so it can be called multiple times in a program. It is for code outside the actors (main, or a host's thread): it waits for every actor, so inside one (a handler, a receive timeout's arm) it would wait for its own step, and on the threaded scheduler a drop or a death hook a core runs would wait for that core; it panics there instead, on either scheduler for a handler or an arm (the cooperative one used to run the other actors nested and return), ending that actor with the reason; a drop run outside any frame ends the process with it, and on the cooperative scheduler a drop or a death hook after a step still waits nested and returns (#3463). This is the recommended way to synchronize the main thread with actor completion: ```aether,fragment main() { @@ -395,13 +475,13 @@ Measured on Windows (i7-13700K, 1,000,000 messages, 10 runs, M msg/s, medians) a Actor memory is allocated using NUMA-aware allocation (`aether_numa_alloc_aligned`, on a 64-byte boundary) at spawn time. The `scheduler_spawn_actor` function accepts the full derived-struct size to ensure correct allocation for user-defined actor types. -Message payloads are managed by thread-local pools. Payloads are returned to the pool on free via `aether_free_message`, which checks whether the pointer falls within the pool range before falling back to `free`. +A typed message's payload is a heap copy, freed with `free()` by `aether_free_message` once the message is handled or dropped. There is no payload pool: an actor can move to another core after a message is sent, so a pooled payload would be released by a thread that does not own the pool (#3374). ## Memory Model The actor runtime uses several allocation strategies for automatic cleanup: - **Actor memory**: one NUMA-aware, 64-byte-aligned allocation per actor at spawn time (`aether_numa_alloc_aligned`), or the kept block of a released actor of the same size. `scheduler_release_actor` takes the actor out of its core's table and retires it; it is reclaimed once every core's scheduler thread and every per-actor thread has passed the top of its loop since, and no other thread is walking the tables. The scheduler threads, per-actor threads and `wait_for_idle`'s scan for armed timeouts read the tables without a lock, so the reclaim has to wait for them; a core blocked in a long step delays it. A reclaimed block is not returned to the allocator while the scheduler runs: it stays marked released, so a send to it is dropped rather than reading freed memory, and the next spawn of that size takes it. The blocks go back with the scheduler's tables. The mailbox is an inline struct member, not a separate allocation. -- **Message payloads**: thread-local pools with automatic return +- **Message payloads**: a heap copy per typed message, freed when it is handled or dropped - **Arenas**: a general-purpose arena allocator provides bulk deallocation without per-object tracking for opt-in uses (`std.arena`, JSON parsing). The actor path does not use arenas. See [Memory Management](memory-management.md) for details on arenas, pools, and allocation strategies. diff --git a/docs/aether-embedded-in-host-applications.md b/docs/aether-embedded-in-host-applications.md index 0bc946b56..8ecd0c460 100644 --- a/docs/aether-embedded-in-host-applications.md +++ b/docs/aether-embedded-in-host-applications.md @@ -275,7 +275,8 @@ For a Java app to use Aether-defined handlers, you'd need one of: table, scope policies, factory names) as data, but handlers are written in Java. The config is the blueprint; Java is the runtime. This is the Terraform model, HCL defines *what*, the provider plugin (Go code) - defines *how*. + defines *how*. ([aether-vs-hcl.md](aether-vs-hcl.md) compares the two side + by side.) #### Summary: path of least resistance, current state diff --git a/docs/aether-vs-hcl.md b/docs/aether-vs-hcl.md new file mode 100644 index 000000000..d404bfb99 --- /dev/null +++ b/docs/aether-vs-hcl.md @@ -0,0 +1,230 @@ +# Aether and HCL + +HCL (HashiCorp Configuration Language, the language of Terraform, Nomad and +Packer) is the most capable static configuration language in common use. This +page compares it with an Aether configuration: a `.ae` file whose trailing +blocks read like configuration and run as code. It is the HCL-specific entry +point. The general argument is in [config-is-code.md](config-is-code.md), and +the survey of other formats (YAML, TOML, HOCON, CUE, Pkl, Starlark) is in +[aether-embedded-in-host-applications.md](aether-embedded-in-host-applications.md). + +## What HCL gets right + +HCL is not "just config", and a comparison that pretends it is would be +wrong. + +- **Repetition is built in.** `count` and `for_each` repeat a resource over a + number, a set or a map; `dynamic` blocks repeat a nested block; `for` + expressions (`[for s in var.services : s.name]`) build lists and maps. +- **Values compute.** Conditional expressions (`cond ? a : b`), `locals`, and + a library of built-in functions (string, collection, encoding, network, + date, and file functions such as `templatefile`). Since Terraform 1.8 a + provider can add functions of its own. +- **Inputs are typed and validated.** A `variable` has a `type` constraint + (`object({...})`, `list(...)`, `map(...)`), a `default`, and `validation` + blocks whose conditions run before anything is planned. +- **Changes are reviewed before they happen.** `terraform plan` shows what + an apply would create, change and destroy, against the recorded state of + the real infrastructure. +- **The ecosystem is enormous.** Thousands of providers, and a format every + infrastructure team already reads. + +## Where it stops + +What HCL leaves out is deliberate, but it is what a growing configuration +eventually needs: + +- **No functions of your own.** You compose the built-in functions (and + provider-defined ones); you cannot name a computation and reuse it. Modules + share structure, not logic. +- **Expressions, not statements.** A conditional is a ternary. "Create this + resource only in production" is `count = var.env == "prod" ? 1 : 0`, and a + conditional nested block is a `dynamic` block over a one- or zero-element + list. +- **A second language inside the first.** `templatefile` renders its own + template syntax (`%{ for }`, `%{ if }`, `${}`), with its own rules, in a + separate file. +- **The "how" lives elsewhere.** HCL describes *what*; a provider written in + Go implements *how*. Two languages, two repositories, two release cycles, + and a schema between them that both must agree on + ([aether-embedded-in-host-applications.md](aether-embedded-in-host-applications.md), + "Accept the boundary"). +- **Several namespaces for one idea.** `variable`, `locals`, `output`, `data` + and module inputs and outputs each carry values, with their own rules for + where they can be read. + +## The same configuration, side by side + +A service gateway with a list of backends. Each backend gets a health check; +production adds TLS and a second replica; the backend's config file is +rendered from a template. + +### HCL + +```hcl +variable "env" { + type = string + validation { + condition = contains(["dev", "prod"], var.env) + error_message = "env must be dev or prod." + } +} + +variable "backends" { + type = map(object({ + port = number + path = string + })) +} + +locals { + replicas = var.env == "prod" ? 2 : 1 +} + +resource "gateway_route" "backend" { + for_each = var.backends + + name = each.key + port = each.value.port + replicas = local.replicas + config = templatefile("${path.module}/backend.tpl", { + name = each.key + path = each.value.path + }) + + health_check { + path = "/healthz" + interval = 10 + } + + dynamic "tls" { + for_each = var.env == "prod" ? [1] : [] + content { + cert = "/etc/tls/${each.key}.pem" + } + } +} +``` + +plus `backend.tpl` in template syntax, plus the provider (Go) that implements +`gateway_route`. + +### Aether + +```aether,fragment +import gateway (routes, route, port, replicas, health_check, tls, config) +import std.os + +extern exit(code: int) + +backend_config(name: string, path: string) -> string { + return "name = ${name}\nroot = ${path}\n" +} + +// One backend's route, a function of its own: HCL has no way to name one. +backend(env: string, name: string, p: int, path: string) { + route(name) { + port(p) + replicas(if env == "prod" { 2 } else { 1 }) + config(backend_config(name, path)) + health_check("/healthz", 10) + if env == "prod" { + tls("/etc/tls/${name}.pem") + } + } +} + +main() { + env = os.getenv("ENV") + if env != "dev" && env != "prod" { + println("ENV must be dev or prod") + exit(1) + } + routes { + backend(env, "api", 8080, "/srv/api") + backend(env, "web", 8081, "/srv/web") + } +} +``` + +One file, one language. A backend is a function, the condition an `if`, the +template a function, and `gateway` (the library that implements `route`) is +written in Aether too, or is a C library the `.ae` binds to. The trailing +blocks are the [builder DSL](closures-and-builder-dsl.md): `route(name) { ... }` +reads like a block of settings and is a call with a closure. The backends +could as well come from a file read at startup, looped over with +`for i in 0..n`. + +## Typed, validated inputs + +HCL's `variable` blocks with `type` and `validation` have a counterpart in +`std.schema`: a declared record whose parse is the validation, and whose +failure lists every problem with its field and a code. + +```aether,fragment +import std.schema + +backend = schema.record() { + schema.field("port", schema.INT) { + schema.min(1) + schema.max(65535) + } + schema.field("path", schema.STR) { + schema.present() + schema.pattern("prefix:/") + } + schema.field("env", schema.STR) { + schema.one_of("dev,prod") + } +} +values, errors = schema.parse(backend, input) +``` + +The same declaration projects to JSON Schema (`schema.to_json_schema`), so an +editor or another service can check the input before it reaches the program +([schema-projection-roadmap.md](schema-projection-roadmap.md)). + +## Reviewing a configuration before it runs + +`terraform plan` is HCL's strongest operational feature, and Aether has no +equivalent of a plan against recorded infrastructure state. What it has for +review: + +- `ae check config.ae` type-checks without building or running. +- `ae inspect config.ae` prints what a script declares without running it: + its imports, the capabilities they need (`fs`, `net`, `os`), its exports. +- An operator's configuration can be built as a library with only the + capabilities it is granted (`--emit=lib --with=fs`), so a configuration + that tries to open a socket does not compile + ([containment-sandbox.md](containment-sandbox.md), + [hide-and-seal.md](hide-and-seal.md)). + +## HOCON, briefly + +HOCON reached the same place as HCL from the other side: it added includes, +substitutions and merge rules to JSON, and still has no conditionals or +loops. Growing a static format does not reach a programming language; it +reaches a large specification. The table in +[aether-embedded-in-host-applications.md](aether-embedded-in-host-applications.md) +puts it with YAML and CUE under "monstrous spec". + +## When to pick which + +Pick HCL when: + +- you need Terraform's providers: the ecosystem is the deciding factor, and + no `.ae` file replaces a mature cloud provider; +- you want plan-and-apply against recorded state; +- the people editing configuration should need nothing but a text editor. + Aether needs its toolchain installed to check or run a configuration, and + that is a real cost. + +Pick Aether when: + +- your configuration keeps growing features and is turning into a weak + programming language: functions, loops over computed inputs, branches on + the environment; +- your program's own settings are what is being configured, so the "how" + and the "what" can live in one language and one release; +- operators' configurations must run sandboxed, with only the capabilities + you grant. diff --git a/docs/api/actors.html b/docs/api/actors.html index 6e002cfdc..d4dcc4aa1 100644 --- a/docs/api/actors.html +++ b/docs/api/actors.html @@ -21,6 +21,18 @@

Exports

  • is_registered
  • registry_size
  • registry_clear
  • +
  • monitor
  • +
  • demonitor
  • +
  • kill
  • +
  • stop
  • +
  • dead_sends
  • +
  • full_drops
  • +
  • link
  • +
  • unlink
  • +
  • trap_exits
  • +
  • rebirth_begin
  • +
  • rebirth_end
  • +
  • incarnation

  • Modules

    @@ -94,7 +106,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • @@ -156,6 +170,76 @@

    registry_clear fn

    registry_clear()

    Wipe all bindings. Tests use this for isolation.

    +
    +

    monitor fn

    +
    monitor(ref) -> int
    +

    ref: actor_ref

    +

    From a handler: one Down to this actor when `ref` ends, at once if it has ended already. Returns the monitor's id, for demonitor.

    +
    +
    +

    demonitor fn

    +
    demonitor(id)
    +

    id: int

    +

    From a handler: no Down of monitor `id` is handled after this, one already on its way included.

    +
    +
    +

    kill fn

    +
    kill(ref)
    +

    ref: actor_ref

    +

    Ends `ref` with "killed" once its current handler, if one is running, returns. Its queued messages are dropped and counted (dead_sends), an ask it is waiting in ends at once, and its watchers get Down. From anywhere; a dead actor is not killed twice.

    +
    +
    +

    stop fn

    +
    stop()
    +

    From a handler: this actor ends with "normal" when the handler returns.

    +
    +
    +

    dead_sends fn

    +
    dead_sends() -> long
    +

    Messages a panicked, killed or stopped actor did not handle: queued at its death or sent to it after, each released and counted once.

    +
    +
    +

    full_drops fn

    +
    full_drops(ref) -> long
    +

    ref: actor_ref

    +

    Plain sends (`!`) to `ref` dropped because its mailbox was full: an actor declared `@mailbox(bound: N)` takes at most N messages it has not handled yet. try_send(ref, Msg {...}) says "the mailbox is full" instead, and an ask fails with it; neither is counted here.

    +
    + + +
    +

    trap_exits fn

    +
    trap_exits(on)
    +

    on: bool

    +

    From a handler: a linked actor's death reaches this actor as a Down (its id 0, never dropped by demonitor) instead of ending it, while `on`.

    +
    +
    +

    rebirth_begin fn

    +
    rebirth_begin(ref) -> int
    +

    ref: actor_ref

    +

    1 when `ref` is claimed for a restart on this thread: it has ended, its death is finalized and no message of it is in flight. 0 when not yet (try again soon), -1 when it cannot be (it is alive, released, or one a C host registered).

    +
    +
    +

    rebirth_end fn

    +
    rebirth_end(ref) -> int
    +

    ref: actor_ref

    +

    1 when `ref` is alive again. 0 when the factory spawned no actor of its type, or panicked first: the restart is undone and `ref` stays ended, "the restart failed".

    +
    +
    +

    incarnation fn

    +
    incarnation(ref) -> int
    +

    ref: actor_ref

    +

    How many times `ref` was restarted in place: 0 for the actor spawned.

    +
    diff --git a/docs/api/alloc.html b/docs/api/alloc.html index 776b7ead1..bb6e173dd 100644 --- a/docs/api/alloc.html +++ b/docs/api/alloc.html @@ -94,7 +94,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/arena.html b/docs/api/arena.html index c9a092dc4..95a2ca746 100644 --- a/docs/api/arena.html +++ b/docs/api/arena.html @@ -95,7 +95,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/audio.html b/docs/api/audio.html index 2dcb91a47..c8cb2adb4 100644 --- a/docs/api/audio.html +++ b/docs/api/audio.html @@ -134,7 +134,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/audit.html b/docs/api/audit.html index fbca8597f..b0d57f605 100644 --- a/docs/api/audit.html +++ b/docs/api/audit.html @@ -92,7 +92,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/bignum.html b/docs/api/bignum.html index 28aa9331c..828be81ff 100644 --- a/docs/api/bignum.html +++ b/docs/api/bignum.html @@ -117,7 +117,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/bits.html b/docs/api/bits.html index c99e277e6..7a5be8e2e 100644 --- a/docs/api/bits.html +++ b/docs/api/bits.html @@ -105,7 +105,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/brotli.html b/docs/api/brotli.html index 570134da1..7f63f1d57 100644 --- a/docs/api/brotli.html +++ b/docs/api/brotli.html @@ -122,7 +122,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/bytes.html b/docs/api/bytes.html index 57c2d3b96..5b2327ff9 100644 --- a/docs/api/bytes.html +++ b/docs/api/bytes.html @@ -37,6 +37,8 @@

    Exports

  • finish
  • to_string
  • free
  • +
  • wipe
  • +
  • wipe_free
  • data
  • capacity
  • set_length
  • @@ -120,7 +122,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • @@ -279,6 +283,18 @@

    free fn

    b: ptr

    Discard the buffer without converting to a string. Idempotent on null. Use this for error paths that mid-way decide the buffer isn't needed.

    +
    +

    wipe fn

    +
    wipe(b)
    +

    b: ptr

    +

    Zero every byte the buffer holds, its whole capacity, so that the writes happen even right before a free: a loop of `set` calls is a dead store the C compiler may drop once it inlines them (link-time optimization does) (#3376). The buffer stays valid and keeps its length. Use it on secrets: key material, nonces, the text of a private key. Nothing on null, or on a string, which is immutable and may be shared. A buffer that grew moved its contents, and the memory it moved out of was freed as it was, so give a secret a buffer of its final size.

    +
    +
    +

    wipe_free fn

    +
    wipe_free(b)
    +

    b: ptr

    +

    wipe, then free. Idempotent on null.

    +

    data fn

    data(b) -> ptr
    diff --git a/docs/api/capsicum.html b/docs/api/capsicum.html index 4ccef2fc3..8e7800fe3 100644 --- a/docs/api/capsicum.html +++ b/docs/api/capsicum.html @@ -116,7 +116,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/cas.html b/docs/api/cas.html index f226511f5..b27982159 100644 --- a/docs/api/cas.html +++ b/docs/api/cas.html @@ -95,7 +95,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/casper.html b/docs/api/casper.html index 92d03b177..43323cd8d 100644 --- a/docs/api/casper.html +++ b/docs/api/casper.html @@ -96,7 +96,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/cbor.html b/docs/api/cbor.html index d60044190..35e6f39b2 100644 --- a/docs/api/cbor.html +++ b/docs/api/cbor.html @@ -134,7 +134,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/clapae.html b/docs/api/clapae.html index 50fe5333a..7fac6bcb1 100644 --- a/docs/api/clapae.html +++ b/docs/api/clapae.html @@ -120,7 +120,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/collections.html b/docs/api/collections.html index 99b35064e..271be726a 100644 --- a/docs/api/collections.html +++ b/docs/api/collections.html @@ -132,7 +132,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/config.html b/docs/api/config.html index c22117c72..6d1ba9a5a 100644 --- a/docs/api/config.html +++ b/docs/api/config.html @@ -94,7 +94,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/cryptography.html b/docs/api/cryptography.html index 4dd65e4fa..244285ead 100644 --- a/docs/api/cryptography.html +++ b/docs/api/cryptography.html @@ -42,6 +42,7 @@

    Exports

  • sha256_bytes
  • hash_bytes
  • random_bytes
  • +
  • random_buffer
  • random_hex
  • random_base64
  • digest_new
  • @@ -122,7 +123,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • @@ -291,6 +294,12 @@

    random_bytes fn

    n: int

    Draw `n` cryptographically-secure random bytes from the OS CSPRNG. Returns (bytes, length, "") on success — `bytes` is an owned AetherString preserving every byte including embedded NULs. Returns ("", 0, error) on failure (n < 0, allocation failure, syscall failure). The OS sources are: - Linux: getrandom(2) → /dev/urandom fallback - macOS/BSD: arc4random_buf(3) - Windows: BCryptGenRandom (CNG) std.math.random_int / random_float are SEEDABLE PRNGs (fine for sampling, NOT for secrets). When the value is unpredictability — bearer tokens, session keys, opaque storage filenames — this is the right primitive. n == 0 succeeds with a length-0 result.

    +
    +

    random_buffer fn

    +
    random_buffer(n) -> (ptr, string)
    +

    n: int

    +

    Draw `n` cryptographically-secure random bytes into a fresh std.bytes buffer of length n (caller frees). Returns (null, error) on failure, never a buffer of zeros. Same OS sources as random_bytes. This is the shape for key material: the bytes go from the OS straight into the buffer. Copying random_bytes' string into a buffer with bytes.copy_from_string makes its C side ask whether the source is a string by reading the source's first bytes, the key's own: a branch on the secret, and a misread when they look like a string header.

    +

    random_hex fn

    random_hex(n) -> (string, _1)
    diff --git a/docs/api/decimal.html b/docs/api/decimal.html index adb3a8531..acb971194 100644 --- a/docs/api/decimal.html +++ b/docs/api/decimal.html @@ -131,7 +131,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/deque.html b/docs/api/deque.html index 11fb75eec..383648201 100644 --- a/docs/api/deque.html +++ b/docs/api/deque.html @@ -104,7 +104,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/dir.html b/docs/api/dir.html index 05b154667..11fb68aa2 100644 --- a/docs/api/dir.html +++ b/docs/api/dir.html @@ -99,7 +99,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/dl.html b/docs/api/dl.html index 19790bd9f..222e2c49b 100644 --- a/docs/api/dl.html +++ b/docs/api/dl.html @@ -96,7 +96,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/encoding.html b/docs/api/encoding.html index 213e2f004..0abb24138 100644 --- a/docs/api/encoding.html +++ b/docs/api/encoding.html @@ -99,7 +99,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/file.html b/docs/api/file.html index 10b03c1f5..463da528f 100644 --- a/docs/api/file.html +++ b/docs/api/file.html @@ -104,7 +104,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/floatarr.html b/docs/api/floatarr.html index e8e5b8e9b..b5507981f 100644 --- a/docs/api/floatarr.html +++ b/docs/api/floatarr.html @@ -103,7 +103,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/fs.html b/docs/api/fs.html index 433861040..ed6fcb3de 100644 --- a/docs/api/fs.html +++ b/docs/api/fs.html @@ -238,7 +238,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/hash.html b/docs/api/hash.html index 4f730db7a..d028dcf15 100644 --- a/docs/api/hash.html +++ b/docs/api/hash.html @@ -94,7 +94,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/host.html b/docs/api/host.html index 483cbd45a..cb22f8378 100644 --- a/docs/api/host.html +++ b/docs/api/host.html @@ -29,6 +29,25 @@

    Exports

  • caller_identity
  • caller_attribute
  • caller_deadline_ms
  • +
  • last_outcome
  • +
  • last_outcome_text
  • +
  • account_new
  • +
  • account_free
  • +
  • account_use
  • +
  • account_in_use
  • +
  • account_used
  • +
  • account_tripped
  • +
  • DEADLINE_ELAPSED
  • +
  • DEADLINE_ACTIVE
  • +
  • TRIPPED_DEADLINE
  • +
  • TRIPPED_MEMORY
  • +
  • OK
  • +
  • PANICKED
  • +
  • DEADLINE
  • +
  • CANCELLED
  • +
  • MEMORY_CAP
  • +
  • OUT_OF_MEMORY
  • +
  • INCOMPATIBLE

  • Modules

    @@ -102,7 +121,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • @@ -202,6 +223,95 @@

    caller_attribute fn

    caller_deadline_ms fn

    caller_deadline_ms() -> long
    +
    +

    last_outcome fn

    +
    last_outcome() -> int
    +

    host.OK, or what ended the last call: host.PANICKED, host.DEADLINE, ...

    +
    +
    +

    last_outcome_text fn

    +
    last_outcome_text() -> string
    +

    "" for host.OK, else the reason (a panic's message, cut at 1023 bytes).

    +
    +
    +

    account_new fn

    +
    account_new(memory_bytes, deadline_ms, counts, parent) -> ptr
    +

    memory_bytes: long, deadline_ms: long, counts: int, parent: ptr

    +

    A new account: at most `memory_bytes` live (0: no limit of its own), a deadline of `deadline_ms` (0: none) counted as host.DEADLINE_ELAPSED or host.DEADLINE_ACTIVE, and `parent` (null, or an account it narrows: what it holds counts against the parent too). null when out of memory, or when `parent` is no live account (freed, or another runtime copy's).

    +
    +
    +

    account_free fn

    +
    account_free(account)
    +

    account: ptr

    +

    Frees an account. The blocks charged to it are detached (its parent chain credited for them), and its children go under its own parent. Freed while in use, it is in use no more: account_in_use() is null after, here and, from its next allocation or call, on any other thread.

    +
    +
    +

    account_use fn

    +
    account_use(account) -> int
    +

    account: ptr

    +

    Puts `account` (null: none) in use on this thread: 0, or -1 when it is in use on another thread.

    +
    +
    +

    account_in_use fn

    +
    account_in_use() -> ptr
    +

    The account in use on this thread, or null.

    +
    +
    +

    account_used fn

    +
    account_used(account) -> long
    +

    account: ptr

    +

    The live bytes charged to `account` (and its children).

    +
    +
    +

    account_tripped fn

    +
    account_tripped(account) -> int
    +

    account: ptr

    +

    host.TRIPPED_DEADLINE and host.TRIPPED_MEMORY bits: what the account ran out of. 0 when neither.

    +
    +
    +

    DEADLINE_ELAPSED const

    +
    DEADLINE_ELAPSED = 0
    +
    +
    +

    DEADLINE_ACTIVE const

    +
    DEADLINE_ACTIVE = 1
    +
    +
    +

    TRIPPED_DEADLINE const

    +
    TRIPPED_DEADLINE = 1
    +
    +
    +

    TRIPPED_MEMORY const

    +
    TRIPPED_MEMORY = 2
    +
    +
    +

    OK const

    +
    OK = 0
    +
    +
    +

    PANICKED const

    +
    PANICKED = 1
    +
    +
    +

    DEADLINE const

    +
    DEADLINE = 2
    +
    +
    +

    CANCELLED const

    +
    CANCELLED = 3
    +
    +
    +

    MEMORY_CAP const

    +
    MEMORY_CAP = 4
    +
    +
    +

    OUT_OF_MEMORY const

    +
    OUT_OF_MEMORY = 5
    +
    +
    +

    INCOMPATIBLE const

    +
    INCOMPATIBLE = 6
    +
    diff --git a/docs/api/http.html b/docs/api/http.html index 165e2fd6c..319a56e4e 100644 --- a/docs/api/http.html +++ b/docs/api/http.html @@ -56,6 +56,10 @@

    Exports

  • server_free
  • server_set_tls_raw
  • server_set_tls
  • +
  • server_set_tls_pem_raw
  • +
  • server_set_tls_pem
  • +
  • server_set_tls_backend_raw
  • +
  • server_set_tls_backend
  • server_set_keepalive_raw
  • server_set_keepalive
  • server_set_h2_raw
  • @@ -232,7 +236,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • @@ -478,6 +484,28 @@

    server_set_tls fn

    server: ptr, cert_path: string, key_path: string

    Enable TLS termination on an HTTP server. Both paths must point at PEM-encoded files; the cert and key are validated to match before the server is marked TLS-enabled. Returns "" on success, an error string on failure (file unreadable, parse error, mismatched key, build without OpenSSL). Idempotent — calling twice with the same files is a no-op success; calling with different files re-loads. Example: server = http.server_create(443) err = http.server_set_tls(server, "/etc/ssl/cert.pem", "/etc/ssl/key.pem") if err != "" { println("TLS setup failed: ${err}"); exit(1) } http.server_start(server)

    +
    +

    server_set_tls_pem_raw extern

    +
    server_set_tls_pem_raw(server, cert_pem, key_pem) -> string
    +

    server: ptr, cert_pem: string, key_pem: string

    +
    +
    +

    server_set_tls_pem fn

    +
    server_set_tls_pem(server, cert_pem, key_pem) -> string
    +

    server: ptr, cert_pem: string, key_pem: string

    +

    server_set_tls with the certificate chain and private key as PEM text, for a program that holds them in memory and should not write the key to disk (#3362). The first certificate is the leaf, the rest its chain; the same checks as server_set_tls, the same results, on both backends. The server keeps its own copy of the text, wiped when it is replaced or the server is freed. Returns "" or why not. Example: err = http.server_set_tls_pem(server, cert_pem, key_pem) if err != "" { println("TLS setup failed: ${err}"); exit(1) }

    +
    +
    +

    server_set_tls_backend_raw extern

    +
    server_set_tls_backend_raw(server, name) -> string
    +

    server: ptr, name: string

    +

    Choose the server's TLS backend (#1298): "openssl", or "aether", the pure-Aether TLS 1.3 server (std.cryptography.tls13_server); "" returns to the default, which is OpenSSL wherever it is built in and the pure server where it is not or when AETHER_PURE_TLS=1. A choice made here wins over both. Call it before server_start, before or after server_set_tls (it re-applies the certificate and key). The pure server must be linked: `import std.cryptography.tls13_server` in the program. Returns "" or why the backend cannot be used. err = http.server_set_tls_backend(server, "aether") if err != "" { println("TLS backend: ${err}"); exit(1) }

    +
    +
    +

    server_set_tls_backend fn

    +
    server_set_tls_backend(server, backend) -> string
    +

    server: ptr, backend: string

    +

    server_set_keepalive_raw extern

    server_set_keepalive_raw(server, enabled, max_requests, idle_ns) -> string
    diff --git a/docs/api/http1.html b/docs/api/http1.html index 10c407931..f06f1e9cb 100644 --- a/docs/api/http1.html +++ b/docs/api/http1.html @@ -102,7 +102,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/index.html b/docs/api/index.html index 719efb5f6..464189b98 100644 --- a/docs/api/index.html +++ b/docs/api/index.html @@ -84,7 +84,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • @@ -114,7 +116,7 @@

    Standard Library

    actors

    std.actors — process-global name → actor_ref registry.

    - 6 exports + 18 exports

    alloc

    @@ -154,7 +156,7 @@

    brotli

    bytes

    std.bytes — mutable byte buffer with random-access write and overlap-safe forward copy_within.

    - 32 exports + 34 exports

    capsicum

    @@ -194,7 +196,7 @@

    config

    cryptography

    std.cryptography - Cryptographic hash primitives + Base64 codec Import with: import std.cryptography Pure functions: bytes in, hex digest or Base64 string out.

    - 34 exports + 35 exports

    decimal

    @@ -244,12 +246,12 @@

    hash

    host

    std.host — primitives for Aether scripts embedded in a host application.

    - 14 exports + 33 exports

    http

    std.http - HTTP Client & Server (alias for std.net) NB the pure-Aether TLS backends are deliberately NOT imported here.

    - 144 exports + 148 exports

    http1

    @@ -334,7 +336,7 @@

    math

    mem

    std.mem — byte-level access to caller-allocated raw pointers.

    - 98 exports + 99 exports

    message

    @@ -456,11 +458,21 @@

    strbuilder

    std.strbuilder — amortised-O(1) string append.

    18 exports
    + +

    stream

    +

    std.stream: credit-windowed streams between actors (#426).

    + 11 exports +

    string

    std.string - String Module Import with: import std.string Building strings incrementally? Do NOT loop `d = "${d}${piece}"` / `string.concat(d, piece)` — each iteration re-copies the whole prefix, so the loop is O(n²) in total bytes (issue #1346).

    100 exports
    + +

    supervise

    +

    std.supervise: supervisors that restart actors (#2974).

    + 9 exports +

    sync

    std.sync — a single atomic 64-bit integer cell.

    @@ -474,7 +486,7 @@

    tar

    tcp

    std.tcp - TCP Socket Operations (alias for std.net) Raw externs end in `_raw` and return NULL/int in C-style.

    - 38 exports + 43 exports

    time

    @@ -561,6 +573,18 @@

    Search Results

  • is_registeredactors
  • registry_sizeactors
  • registry_clearactors
  • +
  • monitoractors
  • +
  • demonitoractors
  • +
  • killactors
  • +
  • stopactors
  • +
  • dead_sendsactors
  • +
  • full_dropsactors
  • +
  • linkactors
  • +
  • unlinkactors
  • +
  • trap_exitsactors
  • +
  • rebirth_beginactors
  • +
  • rebirth_endactors
  • +
  • incarnationactors
  • systemalloc
  • of_arenaalloc
  • arena_freealloc
  • @@ -726,6 +750,8 @@

    Search Results

  • finishbytes
  • to_stringbytes
  • freebytes
  • +
  • wipebytes
  • +
  • wipe_freebytes
  • databytes
  • capacitybytes
  • set_lengthbytes
  • @@ -934,6 +960,7 @@

    Search Results

  • sha256_bytescryptography
  • hash_bytescryptography
  • random_bytescryptography
  • +
  • random_buffercryptography
  • random_hexcryptography
  • random_base64cryptography
  • digest_newcryptography
  • @@ -1231,6 +1258,25 @@

    Search Results

  • caller_identityhost
  • caller_attributehost
  • caller_deadline_mshost
  • +
  • last_outcomehost
  • +
  • last_outcome_texthost
  • +
  • account_newhost
  • +
  • account_freehost
  • +
  • account_usehost
  • +
  • account_in_usehost
  • +
  • account_usedhost
  • +
  • account_trippedhost
  • +
  • DEADLINE_ELAPSEDhost
  • +
  • DEADLINE_ACTIVEhost
  • +
  • TRIPPED_DEADLINEhost
  • +
  • TRIPPED_MEMORYhost
  • +
  • OKhost
  • +
  • PANICKEDhost
  • +
  • DEADLINEhost
  • +
  • CANCELLEDhost
  • +
  • MEMORY_CAPhost
  • +
  • OUT_OF_MEMORYhost
  • +
  • INCOMPATIBLEhost
  • get_rawhttp
  • get_with_timeout_rawhttp
  • get_with_timeout_ns_rawhttp
  • @@ -1272,6 +1318,10 @@

    Search Results

  • server_freehttp
  • server_set_tls_rawhttp
  • server_set_tlshttp
  • +
  • server_set_tls_pem_rawhttp
  • +
  • server_set_tls_pemhttp
  • +
  • server_set_tls_backend_rawhttp
  • +
  • server_set_tls_backendhttp
  • server_set_keepalive_rawhttp
  • server_set_keepalivehttp
  • server_set_h2_rawhttp
  • @@ -1846,6 +1896,7 @@

    Search Results

  • movemem
  • comparemem
  • setmem
  • +
  • wipemem
  • copy_atmem
  • move_atmem
  • fill_atmem
  • @@ -2472,6 +2523,17 @@

    Search Results

  • finishstrbuilder
  • finish_with_lengthstrbuilder
  • freestrbuilder
  • +
  • StreamChunkstream
  • +
  • StreamCreditstream
  • +
  • connectstream
  • +
  • offerstream
  • +
  • writestream
  • +
  • finishstream
  • +
  • creditstream
  • +
  • consumedstream
  • +
  • availablestream
  • +
  • in_flightstream
  • +
  • peakstream
  • newstring
  • from_cstrstring
  • from_literalstring
  • @@ -2572,6 +2634,15 @@

    Search Results

  • keepstring
  • release_keptstring
  • from_ptrstring
  • +
  • startsupervise
  • +
  • childsupervise
  • +
  • child_restartsupervise
  • +
  • whichsupervise
  • +
  • restartssupervise
  • +
  • stopsupervise
  • +
  • SuperviseChildsupervise
  • +
  • SuperviseSpecsupervise
  • +
  • MAX_CHILDRENsupervise
  • atomic_newsync
  • atomic_loadsync
  • atomic_storesync
  • @@ -2604,6 +2675,7 @@

    Search Results

  • writer_finishtar
  • writer_aborttar
  • connect_rawtcp
  • +
  • connect_buffered_rawtcp
  • send_rawtcp
  • receive_rawtcp
  • receive_n_rawtcp
  • @@ -2616,6 +2688,7 @@

    Search Results

  • server_poll_rawtcp
  • set_nodelay_rawtcp
  • set_read_buffer_rawtcp
  • +
  • available_rawtcp
  • fd_rawtcp
  • server_fd_rawtcp
  • poll_rawtcp
  • @@ -2623,6 +2696,7 @@

    Search Results

  • send_bytes_rawtcp
  • receive_into_rawtcp
  • connecttcp
  • +
  • connect_bufferedtcp
  • writetcp
  • write_ntcp
  • write_slicetcp
  • @@ -2639,8 +2713,10 @@

    Search Results

  • server_polltcp
  • set_nodelaytcp
  • set_read_buffertcp
  • +
  • availabletcp
  • polltcp
  • poll2tcp
  • +
  • close_drainedtcp
  • DateTimetime
  • nowtime
  • now_mstime
  • diff --git a/docs/api/intarr.html b/docs/api/intarr.html index b7d55ee90..4f57d16e4 100644 --- a/docs/api/intarr.html +++ b/docs/api/intarr.html @@ -103,7 +103,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/intmap.html b/docs/api/intmap.html index 2fe54cb5c..04c988045 100644 --- a/docs/api/intmap.html +++ b/docs/api/intmap.html @@ -100,7 +100,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/io.html b/docs/api/io.html index 8307d7651..2b661fa1e 100644 --- a/docs/api/io.html +++ b/docs/api/io.html @@ -134,7 +134,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/ipc.html b/docs/api/ipc.html index bbc308b3f..9432992c5 100644 --- a/docs/api/ipc.html +++ b/docs/api/ipc.html @@ -92,7 +92,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/json.html b/docs/api/json.html index 3385265f3..2ba149ea9 100644 --- a/docs/api/json.html +++ b/docs/api/json.html @@ -139,7 +139,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/jsonpath.html b/docs/api/jsonpath.html index a67602150..fd7b0d106 100644 --- a/docs/api/jsonpath.html +++ b/docs/api/jsonpath.html @@ -98,7 +98,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/ksuid.html b/docs/api/ksuid.html index 436dc4a13..344865f1f 100644 --- a/docs/api/ksuid.html +++ b/docs/api/ksuid.html @@ -89,7 +89,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/lanes.html b/docs/api/lanes.html index 5fcb19039..6f9725185 100644 --- a/docs/api/lanes.html +++ b/docs/api/lanes.html @@ -215,7 +215,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/language.html b/docs/api/language.html index 7fcc3dda2..7249fb622 100644 --- a/docs/api/language.html +++ b/docs/api/language.html @@ -99,7 +99,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/list.html b/docs/api/list.html index 216503926..240ce5847 100644 --- a/docs/api/list.html +++ b/docs/api/list.html @@ -102,7 +102,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/log.html b/docs/api/log.html index 4c1a29522..fc88e33b3 100644 --- a/docs/api/log.html +++ b/docs/api/log.html @@ -102,7 +102,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/longarr.html b/docs/api/longarr.html index f7848b438..63e281dc0 100644 --- a/docs/api/longarr.html +++ b/docs/api/longarr.html @@ -103,7 +103,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/lzf.html b/docs/api/lzf.html index d8edfc75a..8e30622f1 100644 --- a/docs/api/lzf.html +++ b/docs/api/lzf.html @@ -97,7 +97,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/map.html b/docs/api/map.html index ca32a4095..1ae346e9e 100644 --- a/docs/api/map.html +++ b/docs/api/map.html @@ -108,7 +108,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/math.html b/docs/api/math.html index f39009654..add096e1c 100644 --- a/docs/api/math.html +++ b/docs/api/math.html @@ -133,7 +133,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/mem.html b/docs/api/mem.html index f333cfb0c..9c5c43744 100644 --- a/docs/api/mem.html +++ b/docs/api/mem.html @@ -78,6 +78,7 @@

    Exports

  • move
  • compare
  • set
  • +
  • wipe
  • copy_at
  • move_at
  • fill_at
  • @@ -186,7 +187,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • @@ -531,6 +534,12 @@

    set fn

    set(dst, value, n) -> ptr

    dst: ptr, value: int, n: long

    +
    +

    wipe fn

    +
    wipe(dst, n)
    +

    dst: ptr, n: long

    +

    Zero `n` bytes at `dst` so the writes happen even right before a free, where a plain set is a dead store the C compiler may drop. The raw-memory counterpart of bytes.wipe, for a struct a module frees itself, such as a hash context holding secret state (#3402).

    +

    copy_at fn

    copy_at(dst, dst_off, src, src_off, n) -> ptr
    diff --git a/docs/api/message.html b/docs/api/message.html index 78c26539a..87d8c0b5e 100644 --- a/docs/api/message.html +++ b/docs/api/message.html @@ -96,7 +96,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/msgpack.html b/docs/api/msgpack.html index 99e06f7bb..c8b7310a3 100644 --- a/docs/api/msgpack.html +++ b/docs/api/msgpack.html @@ -127,7 +127,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/mutation.html b/docs/api/mutation.html index 380b2b26d..770b71b1c 100644 --- a/docs/api/mutation.html +++ b/docs/api/mutation.html @@ -89,7 +89,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/nanoid.html b/docs/api/nanoid.html index 50bff3183..af8bf9c40 100644 --- a/docs/api/nanoid.html +++ b/docs/api/nanoid.html @@ -90,7 +90,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/net.html b/docs/api/net.html index 1de64a8a3..bc841e0c7 100644 --- a/docs/api/net.html +++ b/docs/api/net.html @@ -152,7 +152,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/number.html b/docs/api/number.html index 6c34ea006..ffa9060a0 100644 --- a/docs/api/number.html +++ b/docs/api/number.html @@ -103,7 +103,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/observe.html b/docs/api/observe.html index 90479acbc..62a53b7c2 100644 --- a/docs/api/observe.html +++ b/docs/api/observe.html @@ -97,7 +97,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/os.html b/docs/api/os.html index ce9a7149a..84d04d1f2 100644 --- a/docs/api/os.html +++ b/docs/api/os.html @@ -168,7 +168,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/path.html b/docs/api/path.html index 19ee48043..9a519b6d6 100644 --- a/docs/api/path.html +++ b/docs/api/path.html @@ -98,7 +98,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/plural.html b/docs/api/plural.html index a3ad28624..b4b0ac4a4 100644 --- a/docs/api/plural.html +++ b/docs/api/plural.html @@ -90,7 +90,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/pqueue.html b/docs/api/pqueue.html index 730e92472..c8bff6ecf 100644 --- a/docs/api/pqueue.html +++ b/docs/api/pqueue.html @@ -97,7 +97,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/quote.html b/docs/api/quote.html index 360bf7aca..ae65f26b3 100644 --- a/docs/api/quote.html +++ b/docs/api/quote.html @@ -154,7 +154,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/reflect.html b/docs/api/reflect.html index 86f5d9215..526460a67 100644 --- a/docs/api/reflect.html +++ b/docs/api/reflect.html @@ -134,7 +134,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/regex.html b/docs/api/regex.html index 3623b6d5e..16d4604c0 100644 --- a/docs/api/regex.html +++ b/docs/api/regex.html @@ -115,7 +115,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/resp.html b/docs/api/resp.html index d075fa6bd..21260a863 100644 --- a/docs/api/resp.html +++ b/docs/api/resp.html @@ -130,7 +130,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/sandbox.html b/docs/api/sandbox.html index 4379f377e..5554a66f5 100644 --- a/docs/api/sandbox.html +++ b/docs/api/sandbox.html @@ -103,7 +103,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • @@ -145,7 +147,7 @@

    new fn

    enforce fn

    enforce(perms, code)

    perms: Grants, code: fn

    -

    Run `code` with `perms` in force: every sandbox-checked operation inside it, and in anything it calls, must match one of the grants. The grants stop applying when `code` returns. Trusted names: `sandbox.enforce(perms, audit_log, db) callback { ... }`. A call written in the block to a named function (`audit_log(...)`), or into a named module (`db.query(...)`), runs with the authority of the code that wrote the enforce instead of the block's grants. The compiler handles the extra names (compiler/analysis/sandbox_trust.c): it checks them, routes those calls through wrappers, and leaves this two-argument function to do the rest. Trust is lexical and narrow on purpose: - a trusted call's arguments are still evaluated inside the sandbox; - a function called from somewhere else, even one that calls a trusted name, stays sandboxed; - the names cannot be used as values in the block; - only Aether functions can be trusted, not C externs; - an enforce written by contained code records the sandboxed authority it runs under, so trusting a name there gains nothing. The grants are frozen when the block starts: enforce checks against its own copy, which nothing else can reach, so changing `perms` afterwards (or a grant_* call made with it from inside the block, by code it was passed to) does not widen what the running block may do. For the same reason the block is not given the list: its parameter is null.

    +

    Run `code` with `perms` in force: every sandbox-checked operation inside it, and in anything it calls, must match one of the grants. The grants stop applying when `code` returns. Trusted names: `sandbox.enforce(perms, audit_log, db) callback { ... }`. A call written in the block to a named function (`audit_log(...)`), or into a named module (`db.query(...)`), runs with the authority of the code that wrote the enforce instead of the block's grants. The compiler handles the extra names (compiler/analysis/sandbox_trust.c): it checks them, routes those calls through wrappers, and leaves this two-argument function to do the rest. Trust is lexical and narrow on purpose: - a trusted call's arguments are still evaluated inside the sandbox; - a function called from somewhere else, even one that calls a trusted name, stays sandboxed; - the names cannot be used as values in the block; - only Aether functions can be trusted, not C externs; - an enforce written by contained code records the sandboxed authority it runs under, so trusting a name there gains nothing. The grants are frozen when the block starts: enforce checks against its own copy, which nothing else can reach, so changing `perms` afterwards (or a grant_* call made with it from inside the block, by code it was passed to) does not widen what the running block may do. For the same reason the block is not given the list: its parameter is null. The grants apply to the thread that runs the block, and to every program and library on the same runtime the block calls into on that thread: an export of another library called from inside the block (through a host service, say) is held to these grants too, and an enforce of its own can only narrow them. A message the block sends is handled with no grants, as an actor's thread handles it: also when the send steps an idle receiver of the same core inline, on this thread, since a step puts the thread's levels aside until it ends (#3393). A library built without --shared-runtime on macOS or Windows has a runtime of its own and cannot read these grants: called from inside the block, its checked operations are refused, with a message naming --shared-runtime (docs/containment-sandbox.md).

    free fn

    diff --git a/docs/api/schema.html b/docs/api/schema.html index daa27743f..55ddb86ec 100644 --- a/docs/api/schema.html +++ b/docs/api/schema.html @@ -123,7 +123,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/set.html b/docs/api/set.html index 14b1dc845..58ab7bbf1 100644 --- a/docs/api/set.html +++ b/docs/api/set.html @@ -102,7 +102,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/signal.html b/docs/api/signal.html index 7d0a5bfed..26e320b45 100644 --- a/docs/api/signal.html +++ b/docs/api/signal.html @@ -99,7 +99,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/snapshot.html b/docs/api/snapshot.html index 8d432eaa8..53b4f8127 100644 --- a/docs/api/snapshot.html +++ b/docs/api/snapshot.html @@ -93,7 +93,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/sort.html b/docs/api/sort.html index 99ef3876b..f6a6a401b 100644 --- a/docs/api/sort.html +++ b/docs/api/sort.html @@ -100,7 +100,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/spec.html b/docs/api/spec.html index bb0bc04f6..71e314673 100644 --- a/docs/api/spec.html +++ b/docs/api/spec.html @@ -134,7 +134,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/strarr.html b/docs/api/strarr.html index 8e018693e..b0b18ce90 100644 --- a/docs/api/strarr.html +++ b/docs/api/strarr.html @@ -103,7 +103,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/strbuilder.html b/docs/api/strbuilder.html index f21b59dd6..29886dc9b 100644 --- a/docs/api/strbuilder.html +++ b/docs/api/strbuilder.html @@ -106,7 +106,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/stream.html b/docs/api/stream.html new file mode 100644 index 000000000..96542744e --- /dev/null +++ b/docs/api/stream.html @@ -0,0 +1,204 @@ + + + + + + stream - Aether + + + + +
    +
    +

    stream

    +

    std.stream: credit-windowed streams between actors (#426).

    +

    import std.stream

    +
    +
    +
    +

    StreamChunk message

    +
    message StreamChunk {
    +    s: ptr, data: string, last: int
    +}
    +

    One chunk of stream `s`; `last` 1 (and no data) ends it.

    +
    +
    +

    StreamCredit message

    +
    message StreamCredit {
    +    s: ptr, n: int
    +}
    +

    `n` more chunks of stream `s` may be sent; `n` 0 is the consumer's end of it, after its last credit.

    +
    +
    +

    connect fn

    +
    connect(producer, consumer, window) -> ptr
    +

    producer: actor_ref, consumer: actor_ref, window: int

    +

    A stream from `producer` to `consumer` with at most `window` chunks in flight, or null when `window` is less than 1. Give it to both actors; the consumer's end frees it (see stream.credit).

    +
    +
    +

    offer fn

    +
    offer(s, data) -> int
    +

    s: ptr, data: string

    +

    The producer's: sends `data` as the next chunk. 1 when it was sent; 0 when the window is full (send it again after the next StreamCredit); -1 after finish.

    +
    +
    +

    write fn

    +
    write(s, data, at, chunk) -> int
    +

    s: ptr, data: string, at: int, chunk: int

    +

    The producer's: sends `data` from byte `at` on, in chunks of at most `chunk` bytes, while the window allows. Returns the byte it got to: the length of `data` when all of it went, less when the window filled (call again from there after the next StreamCredit); -1 after finish, or when `chunk` is less than 1.

    +
    +
    +

    finish fn

    +
    finish(s)
    +

    s: ptr

    +

    The producer's: the stream is done. The consumer gets the last chunk after every chunk sent before it. It costs no credit; the producer still takes StreamCredits until stream.credit says the stream has ended.

    +
    +
    +

    credit fn

    +
    credit(s, n) -> int
    +

    s: ptr, n: int

    +

    The producer's, for each StreamCredit it receives: 1 when `n` chunks more may be sent; 0 when this is the consumer's end of the stream, which is freed now (the producer drops it).

    +
    +
    +

    consumed fn

    +
    consumed(s, last)
    +

    s: ptr, last: int

    +

    The consumer's, in its StreamChunk arm once it has handled the chunk: returns its credit to the producer or, for the last chunk (`last` 1, the chunk's own field), ends the stream for the consumer, which drops it.

    +
    +
    +

    available fn

    +
    available(s) -> int
    +

    s: ptr

    +

    The producer's: chunks it may send now.

    +
    +
    +

    in_flight fn

    +
    in_flight(s) -> int
    +

    s: ptr

    +

    The producer's: chunks sent whose credit has not come back yet.

    +
    +
    +

    peak fn

    +
    peak(s) -> int
    +

    s: ptr

    +

    The producer's: the most chunks that were in flight at once (never more than the window).

    +
    +
    +
    + + + diff --git a/docs/api/string.html b/docs/api/string.html index acb52e210..0726e835c 100644 --- a/docs/api/string.html +++ b/docs/api/string.html @@ -188,7 +188,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/supervise.html b/docs/api/supervise.html new file mode 100644 index 000000000..bc3e68d37 --- /dev/null +++ b/docs/api/supervise.html @@ -0,0 +1,180 @@ + + + + + + supervise - Aether + + + + +
    +
    +

    supervise

    +

    std.supervise: supervisors that restart actors (#2974).

    +

    import std.supervise

    +
    +
    +
    +

    start fn

    +
    start()
    +
    +
    +

    child fn

    +
    child(_ctx, name, factory)
    +

    _ctx: ptr, name: string, factory: fn

    +

    In a supervise.start block: a child named `name`, started by `factory`, restarted after any end ("permanent").

    +
    +
    +

    child_restart fn

    +
    child_restart(_ctx, name, factory, restart)
    +

    _ctx: ptr, name: string, factory: fn, restart: string

    +

    As child, with its restart kind: "permanent" (restarted after any end), "transient" (unless it ended "normal") or "temporary" (never).

    +
    +
    +

    which fn

    +
    which(sup, name) -> actor_ref
    +

    sup: actor_ref[Supervisor], name: string

    +

    The ref of the child named `name` (the same across its restarts), or null.

    +
    +
    +

    restarts fn

    +
    restarts(sup) -> int
    +

    sup: actor_ref[Supervisor]

    +

    How many restarts the supervisor has made.

    +
    +
    +

    stop fn

    +
    stop(sup)
    +

    sup: actor_ref

    +

    Kills the children, newest first, and ends the supervisor normally.

    +
    +
    +

    SuperviseChild struct

    +
    struct SuperviseChild
    +
    +
    +

    SuperviseSpec struct

    +
    struct SuperviseSpec
    +
    +
    +

    MAX_CHILDREN const

    +
    MAX_CHILDREN = 31
    +
    +
    +
    + + + diff --git a/docs/api/sync.html b/docs/api/sync.html index a36432a6f..904b4a394 100644 --- a/docs/api/sync.html +++ b/docs/api/sync.html @@ -95,7 +95,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/tar.html b/docs/api/tar.html index d6e62c427..9324d5865 100644 --- a/docs/api/tar.html +++ b/docs/api/tar.html @@ -112,7 +112,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/tcp.html b/docs/api/tcp.html index 80654e549..8a295092f 100644 --- a/docs/api/tcp.html +++ b/docs/api/tcp.html @@ -16,6 +16,7 @@

    Aether

    Exports


    Modules

    @@ -126,7 +131,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • @@ -160,6 +167,11 @@

    connect_raw extern

    host: string, port: int

    Raw externs - escape hatch

    +
    +

    connect_buffered_raw extern

    +
    connect_buffered_raw(host, port, read_buffer) -> ptr
    +

    host: string, port: int, read_buffer: int

    +

    send_raw extern

    send_raw(sock, data) -> int
    @@ -221,6 +233,11 @@

    set_read_buffer_raw externset_read_buffer_raw(sock, bytes) -> int

    sock: ptr, bytes: int

    +
    +

    available_raw extern

    +
    available_raw(sock) -> int
    +

    sock: ptr

    +

    fd_raw extern

    fd_raw(sock) -> int
    @@ -257,6 +274,12 @@

    connect fn

    host: string, port: int

    Connect to host:port. Returns (socket, "") on success, (null, error) on failure. Caller must tcp.close the returned socket.

    +
    +

    connect_buffered fn

    +
    connect_buffered(host, port, read_buffer) -> (ptr, string)
    +

    host: string, port: int, read_buffer: int

    +

    connect, with the receive buffer (SO_RCVBUF) set to `read_buffer` bytes before the connection is made, so the window the connection opens with is that size. A client meant to take little while it does not read (a test of a server's send timeout) connects this way: set_read_buffer on a connected socket shrinks the buffer, but the kernel still takes the window it advertised when connecting (#3338). The size is a request: a kernel may round it up (macOS holds about 300 KB for a 16 KB request on loopback). Returns (socket, "") or (null, error); `read_buffer` must be positive.

    +

    write fn

    write(sock, data) -> (int, string)
    @@ -351,7 +374,13 @@

    set_nodelay fn

    set_read_buffer fn

    set_read_buffer(sock, bytes) -> string

    sock: ptr, bytes: int

    -

    SO_RCVBUF: how many bytes the kernel holds for this socket before the reader takes them. Setting it also stops the kernel growing the buffer by itself while data arrives, which Linux, macOS and Windows do whether the program reads or not. A client that must stop taking bytes when it stops reading (a test of a server's send timeout, say) sets it small. Returns "" on success or an error.

    +

    SO_RCVBUF: how many bytes the kernel holds for this socket before the reader takes them. Setting it asks the kernel not to grow the buffer by itself while data arrives, which Linux, macOS and Windows do whether the program reads or not. On a connected socket the window advertised at connect still stands: a client that must take little while it does not read connects with connect_buffered instead. Returns "" on success or an error.

    +
    +
    +

    available fn

    +
    available(sock) -> int
    +

    sock: ptr

    +

    How many received bytes are waiting on `sock`, readable without blocking, or -1 on error (FIONREAD).

    poll fn

    @@ -365,6 +394,12 @@

    poll2 fn

    a: ptr, b: ptr, timeout_ms: int

    Wait on two sockets at once — the full-duplex relay primitive. Returns (a_ready, b_ready, "") where each flag is true when that side is readable (both may be true); (false, false, "") on timeout; (false, false, error) if both handles are closed or poll failed. Service whichever side is ready with read_n, then re-poll.

    +
    +

    close_drained fn

    +
    close_drained(sock)
    +

    sock: ptr

    +

    Close `sock` once what the peer has already sent is read and dropped. A socket closed with bytes unread ends with a reset, and a peer that gets one may discard what it had not read yet: an alert just sent, which the peer then sees as a bare close. Bounded: at most 256 KiB, and 5 ms for each more to arrive. For the close after an error the peer must still read (the pure TLS stack's, #3368); a plain close is tcp.close.

    +
    diff --git a/docs/api/time.html b/docs/api/time.html index 5ae497e86..9e39fbef9 100644 --- a/docs/api/time.html +++ b/docs/api/time.html @@ -110,7 +110,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/tracking.html b/docs/api/tracking.html index 8de42b66d..142c0925d 100644 --- a/docs/api/tracking.html +++ b/docs/api/tracking.html @@ -93,7 +93,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/truetype.html b/docs/api/truetype.html index a74d14533..161c23feb 100644 --- a/docs/api/truetype.html +++ b/docs/api/truetype.html @@ -112,7 +112,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/tsid.html b/docs/api/tsid.html index a1bf58498..76fd56b57 100644 --- a/docs/api/tsid.html +++ b/docs/api/tsid.html @@ -89,7 +89,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/udp.html b/docs/api/udp.html index 55558bc72..dc9a9590a 100644 --- a/docs/api/udp.html +++ b/docs/api/udp.html @@ -128,7 +128,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/ulid.html b/docs/api/ulid.html index 88342e36e..0ba45bb31 100644 --- a/docs/api/ulid.html +++ b/docs/api/ulid.html @@ -89,7 +89,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/unicode.html b/docs/api/unicode.html index a43a46c07..01099c50a 100644 --- a/docs/api/unicode.html +++ b/docs/api/unicode.html @@ -96,7 +96,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/url.html b/docs/api/url.html index 67fc634ad..beeb05e82 100644 --- a/docs/api/url.html +++ b/docs/api/url.html @@ -96,7 +96,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/uuid.html b/docs/api/uuid.html index 30d89686b..fafd1cbd0 100644 --- a/docs/api/uuid.html +++ b/docs/api/uuid.html @@ -90,7 +90,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/worker.html b/docs/api/worker.html index 1e0d666de..a3b4a1962 100644 --- a/docs/api/worker.html +++ b/docs/api/worker.html @@ -98,7 +98,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/xml.html b/docs/api/xml.html index 2f1698530..f0c53db8a 100644 --- a/docs/api/xml.html +++ b/docs/api/xml.html @@ -113,7 +113,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/yaml.html b/docs/api/yaml.html index 6ff156f2d..dd6a56cde 100644 --- a/docs/api/yaml.html +++ b/docs/api/yaml.html @@ -104,7 +104,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/zip.html b/docs/api/zip.html index 6a5b232dd..c2bcfcb5d 100644 --- a/docs/api/zip.html +++ b/docs/api/zip.html @@ -113,7 +113,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/zlib.html b/docs/api/zlib.html index 6bab0d67b..c5b979419 100644 --- a/docs/api/zlib.html +++ b/docs/api/zlib.html @@ -116,7 +116,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/api/zstd.html b/docs/api/zstd.html index 5f321656f..e80830cf0 100644 --- a/docs/api/zstd.html +++ b/docs/api/zstd.html @@ -107,7 +107,9 @@

    Modules

  • spec
  • strarr
  • strbuilder
  • +
  • stream
  • string
  • +
  • supervise
  • sync
  • tar
  • tcp
  • diff --git a/docs/architecture.md b/docs/architecture.md index 2675f2726..dc17826b9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -162,7 +162,7 @@ IDENTIFIER("x") EQUALS NUMBER(42) PLUS IDENTIFIER("y") **Key Features:** - Computed goto dispatch for message handlers -- Inline single-int message encoding (bypasses pool allocation) +- Inline single-int message encoding (no payload allocation) - `scheduler_send_local` / `scheduler_send_remote` routing based on `current_core_id` - Locality-aware actor placement via `scheduler_spawn_actor` (caller's core, or core 0 for main thread) - NUMA-aware actor allocation with derived-struct size @@ -219,7 +219,7 @@ rejects timestamp macros outright. int count; // the actor's own state follows } CounterActor; ``` - The fields, in order: `active`, `id`, `mailbox`, `step`, `thread`, `auto_process`, `assigned_core`, `migrate_to`, `spsc_queue`, `reply_slot`, `step_lock`, `timeout_ns`, `last_activity_ns`, `step_payload`, `step_type`, `dead`, `alloc_size`, `destroy_state`, `release_message`, `scheduler_owned`, `inbox`, `thread_parked`, `inflight`. The header comments each one. + The fields, in order: `active`, `id`, `mailbox`, `step`, `thread`, `auto_process`, `assigned_core`, `migrate_to`, `spsc_queue`, `reply_slot`, `step_lock`, `exit_flags`, `mailbox_bound`, `timeout_ns`, `last_activity_ns`, `step_payload`, `step_type`, `dead`, `alloc_size`, `destroy_state`, `release_message`, `scheduler_owned`, `incarnation`, `inbox`, `thread_parked`, `inflight`, `death_watch`, `mailbox_pending`, `full_drops`. The header comments each one. 2. **Message Structure** (`runtime/actors/actor_state_machine.h`) ```c @@ -358,10 +358,9 @@ On Windows `aether_thread.h` implements pthreads with Win32 primitives and inclu ### Memory Management **Message Payloads:** -- Thread-local pool of 256 pre-allocated buffers (256 bytes each) -- Pool acquisition and release use plain loads/stores (no atomics for TLS) -- Falls back to malloc for oversized messages or pool exhaustion -- `aether_free_message` returns buffers to the pool or calls `free` +- A typed message's payload is a heap copy made by the send +- `aether_free_message` frees it (`free()`) once the message is handled or dropped +- No payload pool: an actor can move to another core after a message is sent, so a pooled payload would be released by a thread that does not own the pool (#3374) **Actor Allocation:** - `scheduler_spawn_actor` allocates via `aether_numa_alloc_aligned` with the full derived-struct size, on a 64-byte boundary (generated actor structs are declared `aligned(64)`) @@ -381,16 +380,15 @@ design and its record in [design/ownership-model.md](design/ownership-model.md)) The runtime implements these optimization strategies in the active code paths: -1. **Thread-Local Message Pools** - Eliminate malloc/free overhead -2. **Batch Dequeue** - Reduce atomic operations from N to 1 per batch -3. **Adaptive Batching** - Scale batch size with load (64 to 1024) -4. **Direct Mailbox Delivery** - Same-core messages bypass the queue -5. **Computed Goto Dispatch** - Direct label jumps for message handlers -6. **Inline Single-Int Messages** - Bypass pool allocation for common messages -8. **Message-Driven Migration** - Co-locate communicating actors -9. **Optimized Spinlock** - PAUSE/YIELD hints during spin-wait -10. **Cache Line Alignment** - Prevent false sharing on shared structures -11. **Relaxed Atomic Ordering** - Avoid barriers on non-critical counters +1. **Batch Dequeue** - Reduce atomic operations from N to 1 per batch +2. **Adaptive Batching** - Scale batch size with load (64 to 1024) +3. **Direct Mailbox Delivery** - Same-core messages bypass the queue +4. **Computed Goto Dispatch** - Direct label jumps for message handlers +5. **Inline Single-Int Messages** - No payload allocation for common messages +6. **Message-Driven Migration** - Co-locate communicating actors +7. **Optimized Spinlock** - PAUSE/YIELD hints during spin-wait +8. **Cache Line Alignment** - Prevent false sharing on shared structures +9. **Relaxed Atomic Ordering** - Avoid barriers on non-critical counters See [runtime-optimizations.md](runtime-optimizations.md) for implementation details. diff --git a/docs/build-system.md b/docs/build-system.md index a77830249..950b3e12c 100644 --- a/docs/build-system.md +++ b/docs/build-system.md @@ -966,7 +966,11 @@ targeting WASI should treat `panic` as fatal and use `(value, err)` returns for anything it expects to recover from. `--target=wasm` remains the route to a runnable **browser** bundle with JS -glue; `wasm32-wasi` produces a self-contained module for a WASI runtime. +glue; `wasm32-wasi` produces a self-contained module for a WASI runtime. The +runtime `--target=wasm` compiles beside the program is the cooperative one +`runtime/coop_runtime_sources.txt` lists (installed as +`share/aether/runtime/coop_runtime_sources.txt`), the list ci-wasm and the +ESP32 build read too (#3383). `wasm32-freestanding` is deliberately **not** offered: it ships no libc, so the generated C's `#include ` cannot resolve and `--emit=obj` fails diff --git a/docs/c-embedding.md b/docs/c-embedding.md index 21333c5b6..294d2cbb9 100644 --- a/docs/c-embedding.md +++ b/docs/c-embedding.md @@ -261,7 +261,7 @@ void scheduler_send_remote(ActorBase* actor, Message msg, int from_core); ```c #define AETHER_FLAG_AUTO_DETECT (1 << 0) // Detect CPU features #define AETHER_FLAG_LOCKFREE_MAILBOX (1 << 1) // Lock-free mailboxes -#define AETHER_FLAG_LOCKFREE_POOLS (1 << 2) // Lock-free pools +#define AETHER_FLAG_LOCKFREE_POOLS (1 << 2) // Deprecated: accepted and ignored (#3374) #define AETHER_FLAG_ENABLE_SIMD (1 << 3) // SIMD optimizations #define AETHER_FLAG_ENABLE_MWAIT (1 << 4) // MWAIT for idle #define AETHER_FLAG_VERBOSE (1 << 5) // Print config on init diff --git a/docs/closures-and-builder-dsl.md b/docs/closures-and-builder-dsl.md index afc126678..563be2042 100644 --- a/docs/closures-and-builder-dsl.md +++ b/docs/closures-and-builder-dsl.md @@ -463,7 +463,12 @@ main() { The `_ctx: ptr` convention is the key to making builder DSLs feel declarative. When a function's first parameter is named `_ctx` with type `ptr`: -1. **The parameter is hidden from callers**, it doesn't count toward arity +1. **The parameter is hidden from callers**, it doesn't count toward arity: + in a trailing block the call gives the parameters after `_ctx`, and any + defaults among them fill what it leaves out (`item("a")` for + `item(_ctx, name, kind = "plain")`). A call that passes the context + itself first (`_ctx` or `builder_context()`), or gives every parameter, + passes it explicitly 2. **Inside trailing blocks**, the compiler auto-injects `builder_context()` as the first argument 3. **Outside trailing blocks**, the function can still be called explicitly with a diff --git a/docs/config-is-code.md b/docs/config-is-code.md index a8787bbef..455e758ee 100644 --- a/docs/config-is-code.md +++ b/docs/config-is-code.md @@ -71,6 +71,9 @@ computed cache key, a quick `if hostname matches X` you reach for shell or templating around the DSL, and you're back at Level 1's problems. +For HCL in particular (what it does well, where it stops, and the same +configuration written both ways) see [aether-vs-hcl.md](aether-vs-hcl.md). + ### Level 3, Config IS code ```python diff --git a/docs/containment-sandbox.md b/docs/containment-sandbox.md index dafbe38c0..c8c1077b2 100644 --- a/docs/containment-sandbox.md +++ b/docs/containment-sandbox.md @@ -195,8 +195,8 @@ visibility into the permissions it grants. ### Contained cannot reach container The block handed to `sandbox.enforce(worker) { … }` runs with the -worker's grants installed: `sandbox.enforce` pushes the grant list, -installs the checker, runs the block, then pops and uninstalls. Every +worker's grants in force: `sandbox.enforce` pushes the grant list as a +level of the calling thread's, runs the block, then pops it. Every capability the block exercises through std (`fs.read`, `os.getenv`, `os.exec`, `tcp.connect`, …) is checked, and so is everything those functions call, however deep. Names are a separate matter: the block can @@ -227,11 +227,31 @@ sandbox.enforce(outer) callback { } ``` -The checker consults every grant list on the stack and allows an +The check consults every level in force on the thread and allows an operation only when each of them does, so the inner block gets the intersection of the two. An inner grant list cannot widen what an outer one refused. +### Threads, libraries and panics + +The levels belong to the thread that entered them, and are kept by the +runtime (#2955): + +| Case | What holds | +|---|---| +| two threads, each in its own block | each is checked against its own levels only; one leaving its block changes nothing for the other | +| a message sent from a block | handled with no grants: an actor is not the block. That holds when the send steps an idle receiver of the same core inline, on the block's own thread: the step puts the block's levels aside and the block has them again when the send returns (#3393) | +| a library's export called from inside a block on the same thread (through a host service, say), one runtime | held to the block's grants; its own `enforce` only narrows them, and when it ends the block is checked as before | +| the same, with libraries built without `--shared-runtime` on macOS or Windows | each library has a copy of the runtime of its own, which cannot read another copy's grants: the inner library's checked operations are refused, and the first refusal prints a message naming `--shared-runtime`. Every copy finds the others by the record it carries in its image (a PE section, an ELF note, a Mach-O section), in load order, the program first: libraries a host loads, `RTLD_LOCAL` ones included, and a program with the libraries it loads, whenever it loads them and though its executable exports nothing (Windows, or Linux without `-rdynamic`, #3445: its copy went unfound, and the library's code inside its deny-all block was allowed everything) | +| a contrib host bridge (`lua.run_sandboxed(perms, code)` and the others) | pushes `perms` as a level of the thread, so inside an `enforce` the guest gets the intersection | +| a panic out of a block, caught anywhere (a `try` in any code, an actor's barrier) | the levels pushed inside the catching frame end with it, so the catch is held only to the blocks still around it | +| more than 64 levels on one thread (blocks, trusted calls, bridge calls) | the 65th is refused with a panic before its block runs | + +`sandbox_install()` and `sandbox_uninstall()`, which a hand-written +`sandbox_push` / `sandbox_pop` pair used to need, do nothing now: the push +is all it takes. A C host pushes and pops with `aether_sandbox_push` and +`aether_sandbox_pop` (runtime/aether_sandbox.h). + A level cannot be widened from inside, either. `enforce` freezes the grant list when the block starts and checks against its own copy, which nothing else can reach, and the block's parameter is `null` rather than the list. @@ -255,7 +275,7 @@ resource (`grant_tcp("*", 0)` is any host on any port), `"/etc/*"` a prefix, and | `classPathElement("x.jar")` | Not applicable (Aether is single-binary) | | `grant(new SocketPermission(...))` | `grant_tcp("host", port)` | | `component("Bear")` | `sandbox.enforce(perms) { bear_code() }` | -| SecurityManager check | the checker `sandbox.enforce` installs, consulted by std | +| SecurityManager check | the check of the thread's levels, consulted by std | | ClassLoader isolation | `seal except` on the enforced block | ## Mapping to Docker/VM containment @@ -1597,7 +1617,8 @@ same permissions registry and LD_PRELOAD checker either way. `notify(event, id)`, host calls back through the typed downcall API for detail. See [`embedded-namespaces-and-host-bindings.md`](embedded-namespaces-and-host-bindings.md) (typed-SDK story) and [`aether-embedded-in-host-applications.md`](aether-embedded-in-host-applications.md) - (rationale + YAML/HCL/Pkl/Jsonnet/Starlark comparison). + (rationale + YAML/HCL/Pkl/Jsonnet/Starlark comparison), and + [`aether-vs-hcl.md`](aether-vs-hcl.md) for HCL side by side. - **Aether as host**, an Aether `main()` executable embeds `contrib.host..run_sandboxed(perms, code)` for Lua, Python (CPython), Perl, Ruby, Tcl, JavaScript in-process. Java, Go, and diff --git a/docs/design/ownership-model.md b/docs/design/ownership-model.md index 1ed118696..bdc3b3011 100644 --- a/docs/design/ownership-model.md +++ b/docs/design/ownership-model.md @@ -227,9 +227,12 @@ shared; its retain and release are atomic from then on, every other count is plain. `aether_string_publish` sets it and leaves pinned strings alone. Published where it is stored: a module variable, a closure's captured string or shared cell, a container element (list, map, string sequence, strarr: -`string_retain_shared`), and a struct field reached through a pointer. A -message does not share: a send deep-copies its strings (#466), so it needs no -publish. A value read from a published place carries the bit with it. +`string_retain_shared`), and any part of a value reached through a pointer, a +slice or an actor's reference, by one rule for every store (#3398: only +the string fields were, and a struct, an element, a whole array or a nested +optional took its strings plain). A message does not share: a send +deep-copies its strings (#466), so it needs no publish. A value read from a +published place carries the bit with it. **Within the budgets (#2925).** Measured on an Apple M1 Pro, each `benchmarks/ownership/` program built by #2916's merge (c5f20025) and by its @@ -615,6 +618,29 @@ a drain releases what the place holds then and empties it. Stores, moves and releases never touch the journal; a scope exit forgets the place. The drain takes its entries off the journal before it runs any release. +**Every owned place a panic can skip is journaled (#3406).** R2 says a place +holds one reference and its exit gives it back; a panic skips the exit, so the +journal must stand for it at every place that owns. Owned pointers (#3142, +`type T = owned ptr drop f`) were the exception: an owned local and an arm's +binding of an owned message field (the arm takes the field out of the message, +so the barrier's drop of the message no longer holds it) were released at +their exits only, and a panic leaked them, their drop never run. One codegen +rule now covers every such place (`push_owned_place_drop`): its exit release, +and a journal entry written where it begins (`aether_unwind_track_owned_place`). +The entry holds a thunk of the drop, one per drop function +(`_ae_owned_drop_`, written at the end of the program where every drop is +declared), which calls it as the exit release does: the checker takes any +one-parameter `@frees ptr` function as a drop, whatever it returns and however +its C prototype types the pointer (`int fclose(FILE*)`), and passing the drop +itself as a `void (*)(void*)` did not compile for those (#3413). The entry is written +only while a frame that can unwind is live, and its token tells the exit +whether there is one to forget; outside every frame the place costs one call +that returns at the depth check. Measured on a function whose only work is one +owned buffer (5,000,000 calls, medians of 11 interleaved runs): 289 against +295 ms outside any frame, 288 against 302 ms inside a `try`: an owned local or +binding costs one journal entry while a frame is live (a `try` or an actor +step), about 3 ns at most, and one depth check outside one. + **Temporaries live to the end of their statement when a view may point into them.** `string.bytes(s)` returns a view of `s`; given a temporary (`string.bytes(make())`), freeing the temporary as soon as the call returned diff --git a/docs/emit-lib.md b/docs/emit-lib.md index e9f12594e..b9ea3b2d7 100644 --- a/docs/emit-lib.md +++ b/docs/emit-lib.md @@ -140,6 +140,9 @@ content-addressable. Fields: | `closures[]` | Closure surface reachable from exports: `name`, `role` (`builder` / `param` / `literal`), `enclosing_export`, `signature`, `captures[]` (`name` + `type`), `source_file`, `source_line` | | `constants[]` | Exported scalar/string consts: `name`, `type`, `value` | | `structs[]` | Schema 1.3: the structs the exports use, `name`, `kind` (`""`, `"extern"`, `"extern_packed"`), `fields[]` (`name`, `type` as Aether source, `flags`), `source_file`, `source_line` | +| `host_abi` | Schema 1.11: the host ABI level of `libaether.h` the library was built at ([abi-policy.md](abi-policy.md)) | +| `runtime_abi` | Schema 1.11: the runtime ABI it was compiled for (`AETHER_RUNTIME_ABI_ID`, such as `"0.856.0/abi1"`) | +| `requires[]` | Schema 1.11: what its importer must do (`outcome_check`, `string_release`, `shared_runtime`, `runtime_abi`, `actors`); an importer that does not know one refuses the library | ```json { @@ -602,10 +605,10 @@ aether_set_call_deadline(50); // 50 ms wall-clock call_into_aether(); // returns inside both bounds -if (aether_deadline_tripped()) { - /* The deadline fired mid-call. The Aether call returned - * early via the codegen tripwire. Any partial state is - * the host's to interpret. */ +if (aether_last_outcome() == AETHER_DEADLINE) { + /* The deadline fired mid-call: the call ended at a safe point + * and returned its zero value. Any partial state is the + * host's to interpret. */ } ``` @@ -619,24 +622,328 @@ error-string convention; stdlib functions surface "out of memory: cap exceeded" up the call chain rather than aborting. Pass `0` to disable. +**Allocation failure ends the call (#2956).** Inside an export, an +allocation that fails, past the cap or with the allocator out of memory, +still fails cleanly where it happens (the std operation returns its +error, its own memory freed), and the call ends at its next safe point, +or when it returns, with `AETHER_MEMORY_CAP` or `AETHER_OUT_OF_MEMORY` +and the zero value: its defers run, and a `try` in the script does not +catch it. The call does not unwind from inside the allocator, which would +skip the C code between it and the safe point. An allocation the code +cannot go on without ends the call at once with `AETHER_OUT_OF_MEMORY`: a +message's payload copy, `make([]T, n)`, and every allocation the generated +code makes for itself (a captured variable's cell, `ref`, `lazy`, a +closure's environment, a closure boxed into a `ptr`, `heap.new`, #3377). +Outside an export a send stops the process as before, and the others are +a panic naming what ran out (they used to dereference NULL). A `malloc` +the program declares and calls itself still returns NULL, as C's does, +and ends the call when it next reaches a safe point or returns. +Neither the outcome nor the unwind allocates: the reasons are static +strings and the journal is drained in place. One failure still stops the +process, inside an export or not: a message the scheduler has no memory to +queue behind an actor's earlier messages, since it has no place that keeps +their order. + **Wall-clock deadline.** Per-thread monotonic (`CLOCK_MONOTONIC`). -The codegen emits `if (aether_caps_armed && aether_caps_deadline_tripped()) -{ __aether_abort_call(); break; }` at every `for` / `while` loop head +The codegen emits a safe point, `if (aether_caps_armed && aether_exec_poll()) +{ __aether_abort_call(); break; }`, at every `for` / `while` loop head under `--emit=lib` not under `--emit=exe` (zero overhead on -non-sandboxed builds). `aether_caps_armed` is a plain global that goes -nonzero once any thread arms a deadline (or trips one) and stays so; until -then a loop head costs one load, with no call and no thread-local read. -(The call reads two thread-locals, and on macOS each of those is itself a -call: in a ported interpreter that was a fifth of the run time.) On trip the sticky flag flips; subsequent -loop heads in the same call exit too, so any depth of nested loops -unwinds in O(N) tripwire-bounded breaks. Pass `0` to disable; pass -a fresh ms value to clear the sticky flag and re-arm. - -The trip is observable via `aether_deadline_tripped()` the host -checks after the call to know whether the result was complete or -truncated. Retrying a tripped deadline is the host's policy: -re-arm + call again with a more generous budget, or surface -`Deadline` to the user. +non-sandboxed builds). `aether_caps_armed` is a global that goes +nonzero once any thread arms a deadline, trips one or cancels a call, and +stays so; until then a loop head costs one load, with no call and no +thread-local read. It is an atomic read with a relaxed load (a plain load +on x86 and ARM), so a loop that started before another thread's cancel +sees it. (The call reads thread-locals, and on macOS each of +those is itself a call: in a ported interpreter that was a fifth of the +run time.) Pass `0` to disable; pass a fresh ms value to clear the sticky +flag and re-arm. + +**Interruptions (#2956).** Inside an export, the first safe point past the +deadline ends the call: the interruption unwinds like a panic, so every +frame on the way runs its defers and releases its owned locals, and the +export returns its zero value with the outcome `AETHER_DEADLINE`. A `try` +in the script runs its own cleanup and passes the interruption on instead +of running its handler: a script cannot swallow its deadline. Another +thread can end a call the same way: `aether_exec_token()` on the calling +thread names the running call (or, outside one, its next), and +`aether_exec_cancel(token)` from any thread ends it at its next safe point +with `AETHER_CANCELLED`. A call nested inside the cancelled one ends first; +the cancelled one at its own next safe point, once the host code between +them has returned. A cancel of a call that already ended does nothing. +Outside any export (a library's actor, say) a loop head breaks at a passed +deadline, as it did before. An actor stepped on the call's thread inside +it (a send's inline step, a cooperative ask's poll) is outside it too: the +call's deadline and cancel do not reach its loops, a `try` in it catches +its own panics, and the call is cut at its own next safe point once the +step returns (#3392). The drops its step runs after its handler (of the +message a panicking handler ran on, and of the messages waiting for an +actor the step ended) are outside the call as well (#3416). Code the runtime runs inside the call in a frame of +its own (the drop of an ending actor's messages, when the call ends an +actor there and then) is cut by the interruption, and the interruption +goes on to the call: it ends at the call's next safe point, or when it +returns, and a later call's `try` is not touched by it (#3404). Such a frame +tells an interruption from a panic by what its own code raised: one already +on its way when the code began (a `defer` the interruption's unwind runs +ends an actor, whose drops run there) goes on as it was, and an ordinary +panic in a drop is the drop's, reported as any is, so the call still ends +with the interruption's outcome and a `try` on the way passes it on (#3415). + +The safe points are loop heads, and `json.parse`, which checks every 4096 +values it reads and then fails with "stopped: the call's deadline passed or +it was cancelled" (the export then ends with the outcome). Nothing else +stops a call, so these are bounded only by their own length: + +- straight-line code, however long; +- recursion without a loop: a safe point at every function's entry would + cut it, but it made fib(30) through its stub 1.18 ms against 0.69 ms, so + there is none; +- C-backed std operations other than the JSON parse (string split, join and + replace, `json.stringify`, a regex match, compression), each bounded by + its input; +- `extern` C functions, and host services the script calls: they run to + their return, and the next safe point after it ends the call. + +The trip is also observable via `aether_deadline_tripped()`. Retrying a +tripped deadline is the host's policy: re-arm + call again with a more +generous budget, or surface `Deadline` to the user. + +### Execution accounts (#2954) + +The cap and the deadline above belong to the process and to one call. A +host that runs many scripts on one thread, each yielding to it and resumed +later (a game's scripts, an editor's plugins), needs a budget that belongs +to the script: an execution account. + +```c +AetherExecLimits lim = { 256 * 1024, 5000, AETHER_DEADLINE_ACTIVE, NULL }; +AetherExecAccount* script = aether_exec_account_new(&lim); + +AetherExecAccount* was; +aether_exec_use(script, &was); // this thread's calls charge `script` +int r = aether_on_frame(dt); +if (aether_last_outcome() == AETHER_MEMORY_CAP) { /* past its 256 KiB */ } +aether_exec_use(was, NULL); // back to the host's own + +if (aether_exec_account_tripped(script)) drop(script); +aether_exec_account_free(script); +``` + +- **Memory.** While an account is in use on a thread, every cap-aware + allocation made there (strings, collections, JSON, the std buffers) is + charged to it and to its parent chain. One that would take any of them + past its `memory_bytes` is refused as the process cap refuses one: the + call ends at its next safe point with `AETHER_MEMORY_CAP`, and the + account's `aether_exec_account_tripped` has + `AETHER_ACCOUNT_TRIPPED_MEMORY`. The process cap stays the outer ceiling. +- **A block stays the account's that allocated it** until it is freed, + wherever that happens: in a call under another account, or in the host + outside any call. So a string a script hands its host keeps counting + against the script until the host releases it, and two scripts on one + thread never charge each other. +- **Deadlines.** A call under an account has the earliest deadline on the + account's chain, or the call deadline (`aether_set_call_deadline`) if + that is earlier. `AETHER_DEADLINE_ELAPSED` (the default) counts wall time + from `aether_exec_account_new`, so the time a script spends suspended + counts; `AETHER_DEADLINE_ACTIVE` counts only the time spent in calls + under the account, once however calls nest: a call a host service makes + inside another, under the same account or a descendant of it, adds no + time of its own to it (#3409). Past it the call ends with + `AETHER_DEADLINE`, and the account keeps + `AETHER_ACCOUNT_TRIPPED_DEADLINE`. +- **A parent only narrows.** An account made with a `parent` charges what + it holds to the parent too, and its deadline is never later than the + parent's: a host gives each plugin an account and each of the plugin's + scripts a child of it. A `parent` that is no live account of these + accounts (one the host freed, or one another runtime copy made without + sharing them) makes `aether_exec_account_new` return NULL (#3412): the + child is refused, never made wider than the parent meant. +- **Nesting.** An execution puts back the account that was in use when it + began, whatever the code it ran set: a host service that runs another + script under its own account, inside a call, leaves the outer call's + account as it was. +- **Threads.** An account is in use on one thread at a time: + `aether_exec_use` returns -1 for one in use on another thread, and it may + move to another thread once let go there. Allocations on other threads (an + actor's handler) are not charged to it, nor those of an actor stepped on + this thread while it is in use (a send's inline step, a cooperative ask's + poll), the drops its step runs after its handler included (#3416): a step + runs in its own context (actor-concurrency.md, "The delivery contract"). + The refusal on another thread is a threaded build's: a cooperative build + (`AETHER_NO_THREADING`) runs one thread per program by contract, so there + is no other thread to refuse. +- **Ownership.** The account is the host's from `aether_exec_account_new` + to `aether_exec_account_free`; the runtime keeps nothing of `limits` past + the call, writes `previous` and keeps nothing of it. Freeing an account + detaches its blocks, which then count only against the process cap: its + ancestors are credited with what they were charged for them (#3408). Its + children go under its own parent, which their blocks were charged to all + along, and keep narrowing under it. Nothing in the runtime names a freed account (#3407): in use + on the calling thread, it is in use no more; in use on another thread, + nothing more is charged to it there, and that thread lets go of it at its + next allocation, call or `aether_exec_use`; an execution that began under + it puts no account back when it returns. A freed account is the host's + no more: passing it again is not supported. +- **An Aether host** has the same in std.host: `host.account_new(bytes, ms, + host.DEADLINE_ELAPSED, parent)`, `host.account_use(a)`, + `host.account_in_use()`, `host.account_used(a)`, + `host.account_tripped(a)` and `host.account_free(a)`. + +**Runtime copies.** A library built without `--shared-runtime` has a +runtime of its own, and the accounts are still one per process: every copy +works through the accounts of the first loaded image that has them, found +once, at its first account call or execution (the rule the sandbox's +levels follow). There is one lock, one table of blocks and one account in +use per thread, so an account is charged whichever copy allocates, and is +refused on another thread whichever copy is asked. + +- The copy that owns the accounts must stay loaded while another runs: + unloading it first is not supported. Any other copy may be unloaded: it + leaves the accounts as it goes (#3411), and is never written into after. +- Each copy marks its image with a record the loader maps with it (a PE + section, an ELF note, a Mach-O section), and the copies read the records + in load order, the program first. So a program's own copy is found though + its executable exports nothing (on Windows, and on Linux without + `-rdynamic`, as a C host linked with `ae cflags --libs` is), whenever the + libraries are loaded (#3445). Records are read on Windows, macOS, Linux + and FreeBSD, NetBSD and OpenBSD; elsewhere (Cygwin, illumos, DragonFly) + copies look each other up by name in the global scope, and where two do + not find each other, a copy refuses (-1) to put in use an account the + other made, rather than charge one it does not guard. Copies in a + namespace of their own (glibc's `dlmopen`) find none outside it. +- The accounts carry a version and the sizes of what the copies pass each + other. A copy that finds accounts of another layout (another Aether + version) does not share them: it says so once on stderr, naming + `--shared-runtime`; its `aether_exec_account_new` returns NULL and its + `aether_exec_use` -1 for an account; its allocations are charged to + none; and it reads nothing of the other's but the version and sizes. + +**What it costs.** While no account exists nothing is charged, and a +cap-aware allocation still tests one word before it calls `malloc` (the +word also holds the process cap and the test hook); an execution's entry +adds one load, and an actor step one at each end (the delivery contract's +item 5, in [actor-concurrency.md](actor-concurrency.md#the-delivery-contract)). + +While an account exists anywhere in the process (#3410): + +- A thread with no account in use takes no lock. Its cap-aware + allocation or free reads its account in use and one word of a filter + of the addresses the accounts recorded (32 KiB); only an address whose + bit is set (another account's block, or one sharing its bit) takes the + accounts' lock to look the table up. Actor cores and HTTP workers pay + nothing for a script's account. A clear bit is enough: a block is + recorded under the lock with its bit set first, before the allocator + returns it, and whatever thread frees it got the pointer after that, so + it sees the bit; a free that finds the bit clear is of a block no + account was charged for. The bits are cleared only when the table holds + no block. A false positive, a free whose bit another block set, costs a + table probe under the lock: with n blocks recorded about n / 262,144 of + such frees (8% with 20,000). +- A thread with an account in use takes the lock for each allocation and + free (a table probe and a walk of the account's chain), so threads that + each have one contend on it. A waiter pauses, then yields. +- Freeing an account is one pass over the table, under the lock. + +Measured on Windows x86_64 (MinGW, the msvcrt heap), a 64-byte +`aether_caps_malloc` and `aether_caps_free`, best of 5 passes of 2 million +on each thread: + +| Setting | 1 thread | 3 threads | +|---|---|---| +| no account exists | 27 ns | 81-145 ns | +| none exists, one more thread allocating (heap contention only) | 74-99 ns | 123-198 ns | +| an account in use on that other thread, holding 20,000 blocks | 61-82 ns (199-252 before #3410) | 102-156 ns (697-716 before) | +| each thread its own account | 46 ns (41 before) | 374-407 ns (532-570 before) | + +Freeing an account of 40,000 live blocks takes 0.5 ms (1.1 s before). + +## Failures and nested calls at the boundary + +Every `aether_` export (and every package builder export) runs inside +an execution frame (#2955, #2956). How each call ended is its outcome: + +```c +#include + +int r = aether_parse(text); +switch (aether_last_outcome()) { +case AETHER_OK: use(r); break; +case AETHER_PANICKED: log("script failed: %s", aether_last_outcome_text()); break; +case AETHER_DEADLINE: /* cut by its deadline: r is not a result */ break; +} +``` + +- **A panic ends the call, not the process.** A panic the export does not + catch used to print "panic outside any try/catch or actor" and abort the + host. It now ends the call with `AETHER_PANICKED`, the export returns + the zero value of its result (NULL for a string, which the release + accepts), and `aether_last_outcome_text()` gives the panic's message + (copied into a fixed buffer, cut at 1023 bytes: nothing is allocated to + report it). The library takes the next call at once. Nothing done before + the failure is rolled back: host effects, module state, messages sent. +- **A zero value with an outcome other than `AETHER_OK` is not a result.** + Release only what an `AETHER_OK` call returned. `AETHER_DEADLINE` and + `AETHER_CANCELLED` mean a safe point ended the call ("Per-call resource + caps" above): it returned its zero value, its defers run. A call that ran + to its return with a stop noted after its last safe point (an allocation + it survived, a JSON parse that stopped, the deadline's flag) ends with + that outcome and returns its zero value as well (#3388): the stub + releases what the function returned, a string, or the strings and + closures of a struct or closure by value. A `ptr` or `*Struct` result + owns nothing (R4), so what it points to is not released, as on a panic. + An Aether importer gives such a string back to the library before it + raises the failure, whichever version built the library. +- **Nested calls.** An export that calls a host service, which calls an + export again on the same thread, runs two executions. The inner one sees + what the service set for it (caller info, `aether_set_call_deadline`), + has its own outcome, and whatever it leaves behind is undone when it + returns, on every exit: the outer one's caller info, deadline (and trip + flag) and sandbox levels are as they were + ([lib-caller-info.md](lib-caller-info.md#nested-calls-2955), + [containment-sandbox.md](containment-sandbox.md#threads-libraries-and-panics)). + Executions nest up to 32 deep on one thread. +- **An Aether program importing the library in binary form** gets a failed + call raised again as a panic in its own runtime, with the same reason, so + a `try` around the call catches it; `host.last_outcome()` and + `host.last_outcome_text()` in std.host read the outcome without one. The + stub reads it from the library's own runtime through the accessor the + catalog names (`outcome`, schema 1.10), since a library with a runtime of + its own records it there. +- **What a call costs.** Catching a panic needs the stub to save its + context, where a panic can return to, on every call; nothing else is paid + unless something asks for it (a nested call, a deadline ever armed, a + sandbox level on some thread, caller info changed during the call). On + x86 and x86-64 the stub saves it with `__builtin_setjmp` (0.57 ns; the C + library's setjmp is 2.85 ns on Windows), elsewhere with setjmp. Measured + on Windows MinGW, a C host calling in a loop (medians of 9 interleaved + rounds against main): an export that calls a function costs 5.7 ns a call + against 0.4 ns, about 2 ns of it the registers Win64 makes the stub keep + (rbx, rbp, rsi, rdi, r12 to r15, xmm6 to xmm15) for a panic's return; + fib(30) through its stub is 0.699 ms against 0.701 ms, the difference + lost in the recursion. An export that can neither panic, allocate, loop + nor call anything (scalar parameters and locals, `+ - *`, comparisons, + if/else, return) gets no frame and records its OK outcome only: 0.57 ns + against 0.19 ns. +- **A library made for another runtime runs nothing** (#2957). A + `--shared-runtime` library on a runtime of another ABI than the one it + was compiled for (a shared runtime replaced in place), or `--emit=csrc` + code linked against another Aether's runtime, finds out at its first + call: every export returns its zero value with `AETHER_INCOMPATIBLE` + (6), none of its code runs, and the reason, naming both runtimes, is + printed once and in `aether_last_outcome_text()`. A host asks first with + `aether__check(const char** why)`: 0, or -1 with the reason. The + same C compiled against another Aether's headers does not compile. A + host treats an outcome it does not know as a failure. + [abi-policy.md](abi-policy.md) has what is supported across versions. +- **The host API is in every library.** A library built with its own + runtime carries `aether_set_call_deadline`, `aether_deadline_tripped`, + `aether_set_memory_cap`, `aether_set_caller`, `aether_clear_caller`, + `aether_last_outcome`, `aether_last_outcome_text`, `aether_exec_token`, + `aether_exec_cancel` and the execution accounts' `aether_exec_account_new`, + `aether_exec_account_free`, `aether_exec_use`, `aether_exec_account_used` + and `aether_exec_account_tripped` even when its code calls none of them, so a host + that loads it can call them in its runtime (a library DLL had no + `aether_set_call_deadline` before). On the shared runtime they are the + shared runtime's. ## What's out of scope for v1 @@ -777,7 +1084,16 @@ those fields were always present, just NULL before). `schema_version` is "1.0" for function-only artifacts, "1.1" once closure records are present, "1.2" once exported-constant records (`constant_count`/`constants`, of type `AetherLibConstant`) are present, -and "1.3" once struct records or source signatures are (below). A reader +and "1.3" once struct records or source signatures are (below), and so on +up to "1.9" (runtime/aether_lib_meta.h lists them). Since "1.10" every +catalog has `outcome`, the library's outcome accessor +([above](#failures-and-nested-calls-at-the-boundary)). Since "1.11" +(#2957) every catalog also has `host_abi`, the host ABI level it was built +at, `runtime_abi`, the runtime ABI it was compiled for, and `requires`, a +comma-separated list of what its importer must do; every library built now +is "1.11". An importer reads any minor of schema 1 and refuses another +major, and refuses a library that requires something it does not know, +naming it and the fix ([abi-policy.md](abi-policy.md)). A reader touches a field only when the artifact's minor version is at least the one that appended it: an older artifact's struct ends before the newer slots. The function table includes every @@ -975,6 +1291,12 @@ ae build app.ae -o app # imports both pointer) and a builder that uses its config have none. Where the copies stay apart (macOS, Windows), importing a static library that has such an export is an error naming each one, with the rebuild: `--shared-runtime`. +- A sandbox's grants are each thread's, in the runtime: on one runtime a + library's export called from inside another's `sandbox.enforce` block is + held to the block's grants. Where the copies stay apart, the inner copy + cannot read them and refuses what the block would check, with a message + naming `--shared-runtime` + ([containment-sandbox.md](containment-sandbox.md#threads-libraries-and-panics)). - Hooks a program supplies to the runtime by `@c_callback` (the pure-TLS client and server) reach a shared runtime by registration: every `@c_callback` registers itself by name when its image loads, and the diff --git a/docs/http-server.md b/docs/http-server.md index fce91bc43..57b6caae2 100644 --- a/docs/http-server.md +++ b/docs/http-server.md @@ -97,9 +97,50 @@ parse runs. Plaintext clients hitting the TLS port get rejected at handshake time. The cert and key must be PEM-encoded; the server validates they match. -When the build does not include OpenSSL (`AETHER_HAS_OPENSSL` -undefined), `server_set_tls` returns `"TLS unavailable: built without -OpenSSL"`. +### Choosing the TLS backend + +Two backends terminate TLS: OpenSSL, and a pure-Aether TLS 1.3 server +(`std.cryptography.tls13_server`, no OpenSSL at all). The choice is +explicit (#1298): + +- **By default** OpenSSL serves wherever it is compiled in, and the pure + server serves where it is not (every `ae build --target=` cross-build, + which links no OpenSSL). `AETHER_PURE_TLS=1` moves a process onto the + pure stack. +- **Per server**, `http.server_set_tls_backend(server, backend)` picks + `"aether"` or `"openssl"` (or `""` for the default), and wins over the + environment variable: + +```aether,fragment +import std.http +import std.cryptography.tls13_server // links the pure server + +err = http.server_set_tls(server, "cert.pem", "key.pem") +if err != "" { println("TLS setup: ${err}"); exit(1) } +err = http.server_set_tls_backend(server, "aether") +if err != "" { println("TLS backend: ${err}"); exit(1) } +``` + +It returns an error string when the named backend cannot serve: `"openssl"` +in a build without OpenSSL, `"aether"` in a program that does not import +`std.cryptography.tls13_server`, or an unknown name. Call it before +`server_start`; it may come before or after `server_set_tls` (it re-applies +the certificate and key). The pure server speaks TLS 1.3 only. It signs with +an ECDSA P-256, P-384 or P-521, Ed25519 or RSA (2048 to 16384 bits) key, the +key's own scheme when the client offers it, in constant time (see +[writing-security-sensitive-code.md](writing-security-sensitive-code.md)) +(#3326), and checks each signature against the certificate's key before +sending it, so a signature computed wrongly, which can disclose the key, is +never sent (#3376). `server_set_tls` loads the certificate and key on this backend as on +OpenSSL's, and returns why when it cannot serve them: an unreadable file, an +encrypted or too-short key, a key that does not match the certificate +(#3328). A chain or a response over one TLS record (2^14 bytes) goes out in +as many records as it takes, so a large chain serves as it does on OpenSSL +(#3380); the server reads a ClientHello split across records, up to 64 KiB +(#3321). + +The client has the same choice per request, `client.set_tls_backend(req, +backend)` (see the client section of `std/http/README.md`). --- @@ -411,6 +452,15 @@ the lot is full or unavailable the worker keeps its connection, which is what happened before parking existed. `aether_http_parked_connections` in the metrics output reports how many are held. +On a TLS connection, "the client speaks again" means application data. +A client may also send records that carry none, a KeyUpdate above all, +and a read would handle such a record and then wait for a request for +the whole idle timeout, holding its worker. So when the socket of a TLS +connection turns readable, the server asks the TLS library, without +waiting, whether a read would find data. If none, the connection goes, +or goes back, to the lot for what is left of its idle time. This holds +on both backends (#3405). + Server emits `Connection: keep-alive` and `Keep-Alive: timeout=N, max=M` headers per response. diff --git a/docs/install-layout.md b/docs/install-layout.md index 50c59ea52..835d897b2 100644 --- a/docs/install-layout.md +++ b/docs/install-layout.md @@ -158,11 +158,12 @@ disagree. The flag that matters: - **`-DAETHER_HAS_SANDBOX`**, gates `aether_sandbox_check()`. When defined, `runtime/aether_sandbox.h` provides a `static - inline aether_sandbox_check()` that consults the global - `_aether_sandbox_checker` (defined in `libaether.a`); when - undefined, the header replaces it with a no-op macro that - always returns 1. There's no linker error on skew, the - function is inline in both branches, so a build that compiles + inline aether_sandbox_check()` that reads the count of threads + with a grant level in force and, when there is one, checks the + calling thread's levels (`aether_sandbox_check_slow`, defined in + `libaether.a`); when undefined, the header replaces it with a + no-op macro that always returns 1. There's no linker error on + skew, the macro branch links nothing, so a build that compiles the header without the flag silently loses sandbox enforcement even when linked against a sandbox-enabled library. The shipped `libaether.a` is always built with this flag (see `CFLAGS` in diff --git a/docs/language-reference.md b/docs/language-reference.md index be400c67d..8cb8447ad 100644 --- a/docs/language-reference.md +++ b/docs/language-reference.md @@ -2687,9 +2687,12 @@ it with the type's drop function: `T` and `null`). - **The drop function** takes one `ptr` and frees it: C's `free`, or a function or extern whose parameter is `@frees` (`bytes.free`, a function of - the program's own). The type's base is a bare `ptr`. + the program's own, C's `fclose` for a `FILE*`). What it returns is ignored, + and a C prototype may type the pointer (`int fclose(FILE*)`, #3413). The + type's base is a bare `ptr`. -A handler that panics while it holds an owned binding does not release it; +A panic releases what a holder it leaves holds, as the holder's exit would: a +function's local, and a handler's binding when the handler panics (#3406); the message's other owned fields are released with the message. ## Optionals @@ -3619,7 +3622,16 @@ w ! Close {} // error: actor 'Writer' has no receive arm for 'Close' A function can take or return a typed reference, `close_it(w: actor_ref[Writer])` or `-> actor_ref[Writer]`, and a `spawn` result passes -where one is wanted. +where one is wanted. A typed reference also reaches the actor's state +(`w.lines`); a bare `actor_ref` names no actor, so a field access through +one is an error that says to type it (#3333). + +A send never blocks the sender. Two messages one sender sends one actor are +handled in the order sent; a message is handled at most once, and one the +actor never handles (it had ended, or its mailbox bound was reached) is +released and counted. See "The delivery contract" in +[actor-concurrency.md](actor-concurrency.md#the-delivery-contract), and +"A bounded mailbox" below. ### Ask Pattern (Request-Reply) @@ -3681,6 +3693,9 @@ destructured from there (`p, err = store ? GetRange {}`, then `lo, hi = p`). | `"the actor is dead"` | the actor had died (or was released) before the ask reached it | | `"the actor died before it replied"` | the handler panicked with the ask in hand | | `"the handler returned without replying"` | the handler that took the ask finished without a `reply` | +| `"the asking actor was killed"` | the actor whose handler asked was killed while it waited (`actors.kill`), and the ask ended at once | +| `"the mailbox is full"` | the actor has a mailbox bound (`@mailbox(bound: N)`) and N messages it has not handled yet; the ask was refused at once | +| `"out of memory"` | there was no memory to make the ask (its reply slot, or the copy of its message); nothing was sent, and inside a library call the call then ends with `AETHER_OUT_OF_MEMORY` (#3387) | An ask to a dead actor, in either form, ends at once rather than after the timeout, and so does one whose actor dies before replying or whose handler @@ -3809,6 +3824,102 @@ each of its messages (a `_` arm does not count). A protocol and its actors can come from an imported module, as messages do. `protocol` and `implements` are words only in these places: they stay usable as names. +### Ending an actor, and Death notices + +An actor ends once: when its handler panics, when `actors.kill(ref)` is +called on it (from anywhere), or when its own handler calls `actors.stop()` +(`import std.actors`). A kill or a stop takes effect when the handler that is +running returns; a handler is never cut short. What was queued for the actor +is then dropped, and so is every later send to it, each counted by +`actors.dead_sends()`. + +An actor that calls `actors.monitor(ref)` gets one `Down` message when `ref` +ends, with the reason: `"normal"` for a stop, `"killed"` for a kill, the +panic's message for a panic. `Down` is declared by the compiler, as +`message Down { ref: actor_ref, reason: string }`, so a program handles it in +an arm without declaring it (and may not declare its own): + +```aether,run +import std.actors + +message Watch { who: actor_ref } +message Work {} + +actor Worker { + receive { + Work() -> { panic("out of paper") } + } +} + +actor Foreman { + receive { + Watch(who) -> { _id = actors.monitor(who) } + Down(ref, reason) -> { println("worker ended: ${reason}") } + } +} + +main() { + w = spawn(Worker()) + f = spawn(Foreman()) + f ! Watch { who: w } + wait_for_idle() + w ! Work {} + wait_for_idle() +} +``` +```output +worker ended: out of paper +``` + +A `Down` comes after the last messages the ended actor sent its watcher, and +never while its handler still runs. A monitor of an actor that has ended +already is answered at once, with the reason it ended with. +`actors.demonitor(id)`, with the id `monitor` returned, drops a monitor: no +`Down` of it is handled after that, one already on its way included. A +handler that is waiting in an ask when its actor is killed gets +`"the asking actor was killed"` from it at once. + +`actors.link(ref)` ties two actors both ways: when either ends, the other +ends too, with the same reason, unless the reason is `"normal"`. An actor +that calls `actors.trap_exits(true)` gets a `Down` for a linked actor's death +instead, and lives on. `actors.unlink(ref)` takes the tie away. + +An ended actor can be restarted under the same ref, so the refs its siblings +and clients hold reach the new one: `std.supervise` does it, by strategy and +within a restart budget, from factories such as `|| -> spawn(Worker())` +(see its [guide](../std/supervise/README.md)). `actors.incarnation(ref)` says +how many times an actor was restarted. + +On wasi a panic ends the whole program (the target has no way to unwind), so +`actors.monitor`, `actors.link` and `actors.trap_exits` are refused when +compiling for it. + +### A bounded mailbox + +An actor's mailbox is unbounded unless the actor says otherwise: a send never +blocks its sender, and what the actor has not handled yet waits in memory. +`@mailbox(bound: N)` before an actor takes at most N messages it has not +handled yet; past that, a send is refused at once, never blocked on: + +```aether,fragment +@mailbox(bound: 1000) +actor Ingest { + receive { Line(text) -> { /* ... */ } } +} + +err = try_send(ingest, Line { text: t }) // "", or "the mailbox is full" +ingest ! Line { text: t } // dropped and counted when full +n, why = ingest ? Count {} // why: "the mailbox is full" +``` + +`try_send(ref, Msg { ... })` sends as `!` does and says what happened: `""` +when it sent, `"the mailbox is full"` or `"the actor is dead"` when it did +not (the message is released). A plain `!` past the bound is dropped, +counted by `actors.full_drops(ref)`, and reported once on stderr. An ask past +it fails at once with `"the mailbox is full"`. N counts every message +accepted for the actor and not yet taken by its handler, wherever it waits, +and a `Down` is never refused. + --- ## Operators @@ -5002,6 +5113,7 @@ Rules: | Function | Description | |----------|-------------| | `spawn(ActorName())` | Create actor instance | +| `try_send(ref, Msg { ... })` | Send as `ref ! Msg { ... }` does, and say whether it did: `""`, `"the mailbox is full"` or `"the actor is dead"` (see A bounded mailbox) | | `wait_for_idle()` | Wait for all actors to finish | ### Environment & Process diff --git a/docs/lib-caller-info.md b/docs/lib-caller-info.md index fa8e55b45..ed4dc3925 100644 --- a/docs/lib-caller-info.md +++ b/docs/lib-caller-info.md @@ -81,6 +81,30 @@ The runtime does **not** auto-clear on export return. Hosts that multiplex multiple guests on one thread should call `aether_clear_caller` between invocations themselves. +### Nested calls (#2955) + +Each call into an export (an execution, [emit-lib.md](emit-lib.md#failures-and-nested-calls-at-the-boundary)) +sees the caller info that was set when it was entered. A host service the +export calls may set another, for the exports it calls in turn: the +running export keeps its own, so `host.caller_identity()` after the +service returns is still the outer caller's. When the export that was +running returns, however it ends, the setting it was entered with is back, +so the host that called it finds its own again. `aether_set_call_deadline` +from a host service works the same way: the deadline is for the exports it +enters, and the running one keeps its own. + +Copying is done only then: a set or clear while an export runs keeps a +copy of what the running exports see. When there is no memory for that +copy, `aether_set_caller` returns `-1` and `aether_clear_caller` does +nothing, so a running export never loses its view. + +An actor stepped on the export's thread, inside the call (a send that +steps an idle actor of the same core inline, or a cooperative ask's poll), +is not the export: it reads no caller info, as on a core's thread of its +own, and a setting it makes is gone when its step ends, when the export's +is back (#3396). With no memory to keep the export's setting aside while it +runs, it still reads none and its settings are refused. + ## Storage limits Default caps: diff --git a/docs/memory-management.md b/docs/memory-management.md index a8f30a6bf..2560aa2cf 100644 --- a/docs/memory-management.md +++ b/docs/memory-management.md @@ -81,8 +81,10 @@ call's result, another local, a field read. `string.free(s)` or empty; on a parameter it is refused (E0901), since the function does not own it. Strings are immutable once made, so two places sharing one counted string is safe; a string stored where other threads can read it (a module -`var`, a closure environment, a value reached through a pointer) is -published, and its count becomes atomic from then on. +`var`, a closure environment, any part of a value reached through a +pointer, a slice or another actor's reference: a field, an element, a whole +array, an optional, at any depth) is published, and its count becomes +atomic from then on. ### Reading a string out of a container or a `ptr` @@ -147,7 +149,12 @@ are its variants: a sum owns when one of its variant structs does, and its retain and drop are that variant's, for the variant its tag names. A struct parameter is borrowed; one its body writes into is copied on entry. A struct boxed with `heap.new(T)` owns its fields as a local struct does, and -`heap.free(p)` releases them before it frees the box. A +`heap.free(p)` releases them before it frees the box. Out of memory, +`heap.new`, like every allocation the compiler makes for the program (a +captured variable's cell, `ref`, `lazy`, a closure's environment or box), +is a panic naming what ran out, and inside a library call it ends the call +with `AETHER_OUT_OF_MEMORY` (#3377); a `malloc` the program calls itself +returns NULL. A fixed-size array is held by value wherever it goes: a local, a field, a message field, an optional's payload, a function's result (returned in a struct of its own), a temporary passed as an argument. @@ -159,7 +166,11 @@ closure, released when the element leaves (`list.remove`, `list.clear`, a store over it, `map.remove`, `free`); a `list.set` that fails gives back what it was handed. A module `var` and an actor's state field take what is stored and release what they replace; the actor's state is released when the actor -is destroyed. A message owns its fields: the send takes them, a receive arm +is destroyed. So does a store into another actor's state through its +reference (`w.label = s`, `w.items[i] = v`, a part of one such as +`w.items[i].name = s`): the actor's state holds a reference of its own, and +since the actor reads it on its own thread, a string it takes is published, +as one stored through a pointer is. A message owns its fields: the send takes them, a receive arm reads them in place, and the message is released after the arm. What the arm keeps (in state, in a container) it takes. @@ -229,6 +240,10 @@ A panic caught by a `try` releases what the scopes it leaves hold, as their exits would have: every owning local, temporary and argument is recorded in an unwind journal while it is live, and every plain `defer` of those scopes runs, newest first. `defer try` and `defer catch` do not run on a panic. +That includes an owned pointer (`type T = owned ptr drop f`) in a local or +bound from a message field by a handler (#3406): while a frame that can +unwind is live (a `try`, an actor step) it costs one journal entry, about +3 ns at most, and outside one a single depth check. ### Cycles diff --git a/docs/profiling-guide.md b/docs/profiling-guide.md index 30e679c2d..9f6e0e1d7 100644 --- a/docs/profiling-guide.md +++ b/docs/profiling-guide.md @@ -178,8 +178,7 @@ Look for operations with the highest cycle counts and compare them across runs: Build your staging binary with profiling enabled to catch regressions before production. Profiling is a **compile-time** switch (`-DAETHER_PROFILE`), not a -runtime setting, the `AETHER_PROFILE` *environment variable* is unrelated (it -selects a runtime memory profile and does **not** enable profiling output). +runtime setting: no environment variable turns it on. ```bash # Staging binary, built with profiling instrumentation diff --git a/docs/runtime-config.md b/docs/runtime-config.md index 9d5798e39..563f22c7d 100644 --- a/docs/runtime-config.md +++ b/docs/runtime-config.md @@ -27,17 +27,12 @@ Multiple threads work simultaneously. ### What Was Implemented -1. **Lock-Free Message Pools** - - TLS pools have no mutex - - Zero contention on hot path - - Automatic per-thread allocation - -2. **Lock-Free Mailboxes** +1. **Lock-Free Mailboxes** - SPSC (Single Producer Single Consumer) atomic queue - 64-byte cache line padding - Runtime switchable -3. **Runtime Configuration API** +2. **Runtime Configuration API** - Control optimizations via flags - Auto-detect CPU features - Enabled by default without manual configuration @@ -62,7 +57,6 @@ int main() { **Example output on a modern CPU:** - Lock-free mailboxes: ENABLED -- Lock-free pools: ENABLED - MWAIT idle: ENABLED - AVX2 SIMD: ENABLED @@ -73,7 +67,6 @@ int main() { // Explicit flags - full control aether_runtime_init(8, AETHER_FLAG_LOCKFREE_MAILBOX | // Use lock-free mailboxes - AETHER_FLAG_LOCKFREE_POOLS | // Use lock-free TLS pools AETHER_FLAG_ENABLE_SIMD | // Use AVX2 vectorization AETHER_FLAG_ENABLE_MWAIT); // Use MWAIT for idle ``` @@ -102,7 +95,6 @@ aether_runtime_init(0, // CPU: // Active Optimizations: // Lock-free mailbox: ENABLED -// Lock-free pools: ENABLED // ... ``` @@ -114,15 +106,15 @@ aether_runtime_init(0, |------|-----|-------------| | `AETHER_FLAG_AUTO_DETECT` | 0x01 | Auto-detect CPU features (recommended) | | `AETHER_FLAG_LOCKFREE_MAILBOX` | 0x02 | Use lock-free SPSC mailboxes | -| `AETHER_FLAG_LOCKFREE_POOLS` | 0x04 | Use lock-free TLS message pools | +| `AETHER_FLAG_LOCKFREE_POOLS` | 0x04 | Deprecated: accepted and ignored. There is no message pool (#3374); a typed message's payload is a heap copy | | `AETHER_FLAG_ENABLE_SIMD` | 0x08 | Enable AVX2 vectorization | | `AETHER_FLAG_ENABLE_MWAIT` | 0x10 | Enable MWAIT idle strategy | | `AETHER_FLAG_VERBOSE` | 0x20 | Print configuration on init | **Combine flags with bitwise OR:** ```c -int flags = AETHER_FLAG_LOCKFREE_MAILBOX | - AETHER_FLAG_LOCKFREE_POOLS; +int flags = AETHER_FLAG_LOCKFREE_MAILBOX | + AETHER_FLAG_ENABLE_MWAIT; aether_runtime_init(8, flags); ``` @@ -242,18 +234,6 @@ same-core send goes straight into the mailbox unless a message of the actor is still in the queues or its overflow, which it then goes behind ([`runtime/scheduler/multicore_scheduler.c`](../runtime/scheduler/multicore_scheduler.c)). -### Message Pool Allocation -```c -// runtime/actors/aether_message_pool.h, per-thread pool, abridged -static inline Message* message_pool_alloc(void) { - if (!g_msg_pool) message_pool_init_thread(); - if (atomic_load_explicit(&g_msg_pool->count, memory_order_relaxed) == 0) { - return malloc(sizeof(Message)); // pool exhausted: fall back to heap - } - // pop an index off the thread's free list -} -``` - --- ## Environment Variables @@ -261,9 +241,7 @@ static inline Message* message_pool_alloc(void) { | Variable | Effect | |----------|--------| | `AETHER_SINGLE_CORE` | Pins all actors to core 0 (eliminates cross-core overhead) | -| `AETHER_PROFILE` | Memory profile: `micro`, `small`, `medium` (default), `large` | -| `AETHER_MSG_POOL_SIZE` | Override message pool size (default from profile) | -| `AETHER_VERBOSE` | Print optimization configuration at startup | +| `AETHER_VERBOSE` | Print the runtime configuration once, when the scheduler starts (a program compiled with `-DAETHER_VERBOSE` prints it too, once) | | `AETHER_PREEMPT` | Enable cooperative preemption (time-based drain loop break, default 1ms) | | `AETHER_PREEMPT_MS` | Override preemption threshold in milliseconds (default 1) | | `BENCHMARK_MESSAGES` | Sets message count for benchmark programs | @@ -317,7 +295,7 @@ All platform-dependent code is gated behind `AETHER_HAS_*` compile-time flags de make stdlib EXTRA_CFLAGS="-DAETHER_NO_FILESYSTEM -DAETHER_NO_NETWORKING" # Query capabilities at runtime -AETHER_VERBOSE=1 ./program +AETHER_VERBOSE=1 ./program # a program with actors: printed when its scheduler starts ``` **Apple Silicon Notes:** diff --git a/docs/runtime-optimizations.md b/docs/runtime-optimizations.md index 547a8060c..7e4e5d2dd 100644 --- a/docs/runtime-optimizations.md +++ b/docs/runtime-optimizations.md @@ -7,7 +7,7 @@ The Aether runtime implements several performance optimizations targeting messag The optimizations are tiered by how they activate: - **Tier 0, platform capabilities (compile-time):** `AETHER_HAS_THREADS`, `AETHER_HAS_ATOMICS`, `AETHER_HAS_FILESYSTEM`, `AETHER_HAS_NETWORKING`, `AETHER_HAS_SIMD`, `AETHER_HAS_NUMA`, `AETHER_HAS_AFFINITY`. Auto-detected from the target; any flag can be forced off with `-DAETHER_NO_` (e.g. `-DAETHER_NO_THREADING`). See [Platform Detection](architecture.md#platform-detection-tier-0). -- **Tier 1, always on:** the Active Optimizations below (direct send, adaptive batching, message coalescing, thread-local pools). +- **Tier 1, always on:** the Active Optimizations below (direct send, adaptive batching, message coalescing). - **Tier 2, auto-detected at startup:** SIMD batch processing (AVX2/NEON), MWAIT idle (x86 MONITOR/MWAIT), CPU core pinning (OS-dependent). - **Tier 3, opt-in:** lock-free mailboxes and message deduplication, see [Opt-In Features](#opt-in-features). @@ -27,31 +27,11 @@ depended on how many actors it had (#3112). The cooperative (no-threads) runtime queues a send the same way and runs it at the next `wait_for_idle`, `aether_scheduler_poll` or shutdown. -### Thread-Local Message Payload Pools +### Message Payloads **Implementation:** `runtime/actors/aether_send_message.c` -Per-thread pool of pre-allocated buffers eliminates dynamic allocation overhead in the message-passing hot path. Each pool contains 256 buffers of up to 256 bytes. Messages that fit within the pool buffer size are served from the pool; larger messages fall back to `malloc`. Global atomic counters track pool hit/miss rates across threads. - -```c -#define MSG_PAYLOAD_POOL_SIZE 256 -#define MSG_PAYLOAD_MAX_SIZE 256 - -typedef struct { - char buffer[MSG_PAYLOAD_MAX_SIZE]; - int in_use; // Thread-local: no atomics needed -} PooledPayload; - -typedef struct { - PooledPayload payloads[MSG_PAYLOAD_POOL_SIZE]; - int next_index; // Thread-local: plain increment - int initialized; -} PayloadPool; - -static __thread PayloadPool* g_payload_pool = NULL; -``` - -Because each pool is thread-local, acquisition and release use plain loads and stores rather than atomic operations. The round-robin index uses bitwise AND masking for constant-time slot lookup. +A typed message's payload is a heap copy, made by the send and freed with `free()` by whoever handles or drops the message (`aether_free_message`). There is no payload pool (#3374): a per-thread pool was tried, but an actor can move to another core after a message is sent, so a pooled payload would be released by a thread that does not own the pool. `AETHER_FLAG_LOCKFREE_POOLS` is accepted and ignored, and `aether_message_pool_stats` reports zeros; both are deprecated. ### Message Coalescing with Batch Dequeue @@ -147,7 +127,7 @@ Cache line padding on `head` and `tail` prevents false sharing between producer **Implementation:** `runtime/actors/aether_send_buffer.c`, `scheduler_send_many` in `runtime/scheduler/multicore_scheduler.c` -`send_buffered(actor, msg)` gathers a thread's messages to one actor in a thread-local buffer of `SEND_BUFFER_SIZE` (256), and a flush hands them to `scheduler_send_many`, which sends them as that many single sends would go: counted as sent, behind whatever is queued for the actor, and kept when a queue is full. To an actor of another core, from a core's thread, they are enqueued in one batch (one atomic update), what the queue has no room for waiting in the sender's deferred buffer in order. A flush always empties the buffer (#3292). Same-core messages used to go to a per-actor SPSC queue (`runtime/actors/aether_spsc_queue.h`, `ActorBase.spsc_queue`), outside every ordering rule; nothing fills it now. +`send_buffered(actor, msg)` gathers a thread's messages to one actor in a thread-local buffer of `SEND_BUFFER_SIZE` (256), and a flush hands them to `scheduler_send_many`, which sends them as that many single sends would go: counted as sent, behind whatever is queued for the actor, and kept when a queue is full. To an actor of another core, from a core's thread, they are enqueued in one batch (one atomic update), what the queue has no room for waiting in the sender's deferred buffer in order. A flush always empties the buffer (#3292). Same-core messages used to go to a per-actor SPSC queue (`runtime/actors/aether_spsc_queue.h`, `ActorBase.spsc_queue`), outside every ordering rule; nothing fills it now. The buffer is a `static` thread-local of `aether_send_buffer.c`; the inline functions of `aether_send_buffer.h` reach it through `send_buffer_here()`, as every runtime thread-local of more than one field is reached from outside its file (`runtime/actors/aether_frames.h` says why). ### Direct Mailbox Delivery @@ -242,27 +222,21 @@ All ring buffers use power-of-2 sizes with bitwise AND masking instead of modulo #define QUEUE_SIZE 1024 #define QUEUE_MASK (QUEUE_SIZE - 1) int index = (head + 1) & QUEUE_MASK; - -// Also used in thread-local pool (plain increment, no atomics) -int idx = pool->next_index++ & (MSG_PAYLOAD_POOL_SIZE - 1); ``` ### Relaxed Atomic Memory Ordering -**Implementation:** `runtime/actors/aether_send_message.c`, `runtime/scheduler/multicore_scheduler.c` +**Implementation:** `runtime/scheduler/multicore_scheduler.c` Non-critical atomic operations use `memory_order_relaxed` to avoid unnecessary memory barriers: ```c -// Statistics counters -atomic_fetch_add_explicit(&g_pool_hits, 1, memory_order_relaxed); - // Scheduler idle tracking atomic_fetch_add_explicit(&sched->idle_cycles, 1, memory_order_relaxed); atomic_store_explicit(&sched->idle_cycles, 0, memory_order_relaxed); ``` -Statistics counters and approximate work counts do not require sequential consistency. Relaxed ordering provides atomicity without the overhead of full memory barriers. +Approximate work counts do not require sequential consistency. Relaxed ordering provides atomicity without the overhead of full memory barriers. ### NUMA-Aware Allocation diff --git a/docs/scheduler-quick-reference.md b/docs/scheduler-quick-reference.md index c172bd4ef..8a810a0db 100644 --- a/docs/scheduler-quick-reference.md +++ b/docs/scheduler-quick-reference.md @@ -140,12 +140,6 @@ sender -> scheduler_send_local -> mailbox (direct) -> actor step #define MAILBOX_SIZE 32 // In actor_state_machine.h (must be power of 2) ``` -### Message Pool -```c -#define MSG_PAYLOAD_POOL_SIZE 256 // In aether_send_message.c -#define MSG_PAYLOAD_MAX_SIZE 256 // Max pooled message size -``` - ### Cross-Core Queue ```c #define QUEUE_SIZE 1024 // In lockfree_queue.h @@ -195,7 +189,6 @@ Both migration and work stealing use ascending core-id lock ordering to prevent ### Low Throughput - Verify compiler flags include `-O3 -march=native -flto` -- Check that message pool is active (pool stats via `aether_message_pool_stats`) - Ensure batch size is appropriate for workload - Verify core count matches available hardware @@ -205,7 +198,7 @@ Both migration and work stealing use ascending core-id lock ordering to prevent - Avoid blocking operations in actor step functions ### Crashes -- Verify every actor struct starts with `ActorBase`'s fields: expand `AETHER_ACTOR_BASE_FIELDS` first (or put an `ActorBase` member first) rather than copying the list, which drifts as fields are added. In order, they are `active`, `id`, `mailbox`, `step`, `thread`, `auto_process`, `assigned_core`, `migrate_to`, `spsc_queue`, `reply_slot`, `step_lock`, `timeout_ns`, `last_activity_ns`, `dead`, `alloc_size`, `destroy_state`, `scheduler_owned`, `inbox`, `thread_parked`, `inflight`. +- Verify every actor struct starts with `ActorBase`'s fields: expand `AETHER_ACTOR_BASE_FIELDS` first (or put an `ActorBase` member first) rather than copying the list, which drifts as fields are added. In order, they are `active`, `id`, `mailbox`, `step`, `thread`, `auto_process`, `assigned_core`, `migrate_to`, `spsc_queue`, `reply_slot`, `step_lock`, `exit_flags`, `mailbox_bound`, `timeout_ns`, `last_activity_ns`, `step_payload`, `step_type`, `dead`, `alloc_size`, `destroy_state`, `release_message`, `scheduler_owned`, `incarnation`, `inbox`, `thread_parked`, `inflight`, `death_watch`, `mailbox_pending`, `full_drops`. - Initialize `migrate_to = -1` after actor creation - Use `scheduler_send_remote` instead of direct mailbox writes for cross-core messages - Call `scheduler_shutdown()` before process exit (waits for quiescence, stops and joins threads) @@ -223,7 +216,7 @@ Both migration and work stealing use ascending core-id lock ordering to prevent - `runtime/scheduler/multicore_scheduler.c` - Multi-core scheduler: coalescing, migration, work stealing - `runtime/scheduler/aether_scheduler_coop.c` - Cooperative single-threaded scheduler (WASM, embedded) - `runtime/config/aether_optimization_config.h` - Platform detection (Tier 0 `AETHER_HAS_*` flags) -- `runtime/actors/aether_send_message.c` - Message sending, thread-local pools +- `runtime/actors/aether_send_message.c` - Message sending, payload copies - `runtime/actors/aether_actor_thread.c` - Actor-owned thread loop - `runtime/utils/aether_thread.h` - Portable thread primitives (POSIX/Win32/no-op stubs) - `runtime/aether_numa.c` - NUMA topology detection and allocation diff --git a/docs/stdlib-reference.md b/docs/stdlib-reference.md index 80cc333b1..65b0685a2 100644 --- a/docs/stdlib-reference.md +++ b/docs/stdlib-reference.md @@ -7,11 +7,11 @@ cannot leave the index behind. The sections after it cover the most-used modules in depth; for the others the index links to the module source, whose header comment is the authoritative description. -## Module index (88 modules) +## Module index (90 modules) | Module | Purpose | Exports | Detail | |---|---|---:|---| -| `std.actors` | Process-global registry mapping names to actor references. | 6 | [guide](../std/actors/README.md) · [source](../std/actors/module.ae) | +| `std.actors` | Name registry, death notices, links, and ending and restarting actors. | 18 | [guide](../std/actors/README.md) · [source](../std/actors/module.ae) | | `std.alloc` | Allocator handles the containers accept, so a structure can allocate from an arena instead of the system. | 6 | [guide](../std/alloc/README.md) · [source](../std/alloc/module.ae) | | `std.arena` | Bulk allocator: many allocations, released in one shot. | 7 | [guide](../std/arena/README.md) · [source](../std/arena/module.ae) | | `std.audio` | Audio playback: WAV and PCM loading, device control, volume; pan, pitch, looping, groups, a queue source and a headless engine for the game-engine mixer. | 46 | [guide](../std/audio/README.md) · [source](../std/audio/module.ae) | @@ -19,7 +19,7 @@ header comment is the authoritative description. | `std.bignum` | Arbitrary-precision integers. | 29 | [guide](../std/bignum/README.md) · [source](../std/bignum/module.ae) | | `std.bits` | Unsigned bit operations: rotates, shifts, popcount, leading zeros, unsigned divide. | 17 | [guide](../std/bits/README.md) · [source](../std/bits/module.ae) | | `std.brotli` | Brotli encoding and capped decoding, streaming and one-shot, for `Content-Encoding: br`. | 34 | [guide](../std/brotli/README.md) · [source](../std/brotli/module.ae) | -| `std.bytes` | Mutable byte buffer with random access and overlap-safe copies. | 32 | [guide](../std/bytes/README.md) · [source](../std/bytes/module.ae) | +| `std.bytes` | Mutable byte buffer with random access and overlap-safe copies. | 34 | [guide](../std/bytes/README.md) · [source](../std/bytes/module.ae) | | `std.capsicum` | FreeBSD Capsicum capability-mode bindings. | 28 | [guide](../std/capsicum/README.md) · [source](../std/capsicum/module.ae) | | `std.cas` | Content-addressed store keyed by the sha256 of file contents. | 7 | [guide](../std/cas/README.md) · [source](../std/cas/module.ae) | | `std.casper` | FreeBSD Casper service delegation. | 8 | [guide](../std/casper/README.md) · [source](../std/casper/module.ae) | @@ -27,7 +27,7 @@ header comment is the authoritative description. | `std.clapae` | Command-line argument parser, modelled on clap. | 32 | [guide](../std/clapae/README.md) · [source](../std/clapae/module.ae) | | `std.collections` | Dynamic list, hash map and packed int array, with the raw externs the alias modules re-export. | 44 | [guide](../std/collections/README.md) · [source](../std/collections/module.ae) | | `std.config` | Process-global immutable string to string store. | 6 | [guide](../std/config/README.md) · [source](../std/config/module.ae) | -| `std.cryptography` | Cryptographic hashes, HMAC, and the Base64 codec. | 34 | [full section](#cryptography-stdcryptography) | +| `std.cryptography` | Cryptographic hashes, HMAC, and the Base64 codec. | 35 | [full section](#cryptography-stdcryptography) | | `std.decimal` | Arbitrary-precision decimal fixed-point arithmetic with explicit rounding modes, for money math. | 43 | [guide](../std/decimal/README.md) · [source](../std/decimal/module.ae) | | `std.deque` | Fixed-capacity double-ended queue over `long` values. | 16 | [guide](../std/deque/README.md) · [source](../std/deque/module.ae) | | `std.dir` | Directory operations, re-exported from `std.fs`. | 11 | [guide](../std/dir/README.md) · [source](../std/dir/module.ae) | @@ -37,8 +37,8 @@ header comment is the authoritative description. | `std.floatarr` | Fixed-size packed-double buffer. | 15 | [guide](../std/floatarr/README.md) · [source](../std/floatarr/module.ae) | | `std.fs` | Files, directories, metadata, recursive walk, and change watching. | 150 | [guide](../std/fs/README.md) · [source](../std/fs/module.ae) | | `std.hash` | Fast non-cryptographic hashes and checksums: FNV, MurmurHash3, SipHash, CRC-32. | 6 | [guide](../std/hash/README.md) · [source](../std/hash/module.ae) | -| `std.host` | Primitives for Aether scripts embedded in a host application. | 14 | [guide](../std/host/README.md) · [source](../std/host/module.ae) | -| `std.http` | HTTP client and server: the `std.net` surface plus Go-style wrappers. | 144 | [guide](../std/http/README.md) · [source](../std/http/module.ae) | +| `std.host` | Primitives for Aether scripts embedded in a host application. | 33 | [guide](../std/host/README.md) · [source](../std/host/module.ae) | +| `std.http` | HTTP client and server: the `std.net` surface plus Go-style wrappers. | 148 | [guide](../std/http/README.md) · [source](../std/http/module.ae) | | `std.http1` | Pure-Aether HTTP/1.1 response reader (RFC 9112). | 14 | [guide](../std/http1/README.md) · [source](../std/http1/module.ae) | | `std.intarr` | Fixed-size packed-int buffer. | 15 | [guide](../std/intarr/README.md) · [source](../std/intarr/module.ae) | | `std.intmap` | Hash map from `long` keys to `long` values, for integer-keyed counting and lookup. | 12 | [guide](../std/intmap/README.md) · [source](../std/intmap/module.ae) | @@ -55,7 +55,7 @@ header comment is the authoritative description. | `std.lzf` | One-shot LZF compression and decompression. | 9 | [guide](../std/lzf/README.md) · [source](../std/lzf/module.ae) | | `std.map` | Hash map, re-exported from `std.collections`, with readable key snapshots. | 20 | [guide](../std/map/README.md) · [source](../std/map/module.ae) | | `std.math` | Arithmetic, trigonometry, rounding and floating-point helpers. | 45 | [full section](#math-stdmath) | -| `std.mem` | Byte-level reads and writes over caller-allocated raw pointers. | 98 | [guide](../std/mem/README.md) · [source](../std/mem/module.ae) | +| `std.mem` | Byte-level reads and writes over caller-allocated raw pointers. | 99 | [guide](../std/mem/README.md) · [source](../std/mem/module.ae) | | `std.message` | ICU MessageFormat formatting and message catalogues. | 8 | [guide](../std/message/README.md) · [source](../std/message/module.ae) | | `std.msgpack` | MessagePack serialisation and deserialisation. | 39 | [guide](../std/msgpack/README.md) · [source](../std/msgpack/module.ae) | | `std.mutation` | Text-based mutation-testing driver for `std.spec` suites. | 1 | [guide](../std/mutation/README.md) · [source](../std/mutation/module.ae) | @@ -80,10 +80,12 @@ header comment is the authoritative description. | `std.spec` | BDD test framework: describe and it, hooks, assertions, structured reports. | 46 | [guide](../std/spec/README.md) · [source](../std/spec/module.ae) | | `std.strarr` | Growable string array whose backing is a `string[]`, for sorting runtime-built lists. | 15 | [guide](../std/strarr/README.md) · [source](../std/strarr/module.ae) | | `std.strbuilder` | Amortised-O(1) string building. | 18 | [guide](../std/strbuilder/README.md) · [source](../std/strbuilder/module.ae) | +| `std.stream` | Credit-windowed streams between actors: memory stays bounded however fast the producer is. | 11 | [guide](../std/stream/README.md) · [source](../std/stream/module.ae) | | `std.string` | Managed strings: construction, search, slicing, case, split and join. | 100 | [full section](#strings-stdstring) | +| `std.supervise` | Supervisors that restart dead actors in place, by strategy and within a restart budget. | 9 | [guide](../std/supervise/README.md) · [source](../std/supervise/module.ae) | | `std.sync` | Atomic 64-bit integer cell (load, store, add, sub, compare-and-swap) for refcounts and lock-free reclamation. | 7 | [guide](../std/sync/README.md) · [source](../std/sync/module.ae) | | `std.tar` | Streaming tar archives: a ustar writer, a ustar, pax and GNU reader. | 24 | [full section](#posix-ustar-archives-stdtar) | -| `std.tcp` | TCP sockets, re-exported from `std.net`. | 38 | [guide](../std/tcp/README.md) · [source](../std/tcp/module.ae) | +| `std.tcp` | TCP sockets, re-exported from `std.net`. | 43 | [guide](../std/tcp/README.md) · [source](../std/tcp/module.ae) | | `std.udp` | Datagram sockets for game networking: non-blocking bind, send_to, recv_from, poll, and address values. | 40 | [guide](../std/udp/README.md) · [source](../std/udp/module.ae) | | `std.time` | Civil date and time over Unix epoch seconds (UTC). | 22 | [guide](../std/time/README.md) · [source](../std/time/module.ae) | | `std.tracking` | Leak-detecting allocator wrapper. | 5 | [guide](../std/tracking/README.md) · [source](../std/tracking/module.ae) | @@ -691,6 +693,8 @@ out = bytes.finish(b, 6) // "ABABAB" - `bytes.copy_within(b, dst, src, length)` → `int` - Self-copy, forward byte-by-byte (RLE-safe) - `bytes.finish(b, length)` → `string` - Hand off to refcounted AetherString; buffer is consumed - `bytes.free(b)` - Discard without finishing (idempotent on null) +- `bytes.wipe(b)` - Zero every byte of the buffer's capacity, so that the writes happen even right before a free (a loop of `set` calls is a dead store the C compiler may drop once it inlines them). The buffer stays valid and keeps its length. For secrets: key material, nonces, the text of a private key. Nothing on null or a string. A buffer that grew left its earlier contents in the memory it moved out of, so give a secret a buffer of its final size (#3376). +- `bytes.wipe_free(b)` - `wipe`, then `free` (idempotent on null). - `bytes.data(b)` → `ptr` - Borrowed pointer to the buffer's own memory, for handing the region to a foreign runtime (a WebAssembly `HEAPU8.set`, a C API) without copying it byte by byte. Valid until the next call that can grow the buffer, which may move the allocation and invalidate it. - `bytes.capacity(b)` → `int` - Bytes reserved, which is at least `length` and may be more. - `bytes.set_length(b, n)` → `int` - Publish how many bytes a direct write into `data()`'s region made live. Clamped to the capacity, and returns the length actually set. @@ -1941,6 +1945,7 @@ main() { **Cryptographically-secure random** (OS CSPRNG, `getrandom(2)` / `/dev/urandom` on Linux, `arc4random_buf(3)` on macOS/BSD): - `cryptography.random_bytes(n)` → `(string, int, string)` - `n` random bytes as `(bytes, length, error)`; `bytes` preserves embedded NULs. +- `cryptography.random_buffer(n)` → `(ptr, string)` - `n` random bytes in a fresh `std.bytes` buffer, or `(null, error)`. The shape for key material: the bytes go from the OS into the buffer without passing through a string. - `cryptography.random_hex(n)` → `(string, string)` - `n` random bytes as a `2*n`-char lowercase-hex string. For opaque bearer-token / API-key minting. - `cryptography.random_base64(n)` → `(string, string)` - `n` random bytes as RFC 4648 §4 unpadded Base64 (~33% denser than hex). @@ -1974,6 +1979,52 @@ Public-key crypto, symmetric ciphers, and key derivation live under `std.cryptog The post-quantum KEM is `std.cryptography.mlkem` (ML-KEM, FIPS 203), with `mlkem512_*`, `mlkem768_*` and `mlkem1024_*` sets: `keygen() -> (ek, dk, err)` and `encaps(ek) -> (ct, key, err)` draw their randomness from the OS CSPRNG, and `decaps(dk, ct) -> (key, err)` returns the 32-byte shared secret. Each call runs the FIPS 203 input checks (key and ciphertext lengths, the encapsulation-key modulus check, the decapsulation-key hash check) and returns an error with null outputs when one fails. `keygen_derand(d, z)` and `encaps_derand(ek, m)` take the randomness from the caller and are for known-answer tests only. `mlkem_ek_bytes(k)`, `mlkem_dk_bytes(k)` and `mlkem_ct_bytes(k)` give the sizes for k = 2, 3, 4 and 0 for any other k. +The Weierstrass curves are `std.cryptography.p256`, `p384`, `p521` and `secp256k1`, each with `scalar_mult_base(k) -> (x, y)`, `scalar_mult(k, x, y) -> (x, y)`, `ecdh(priv, x, y) -> shared_x`, `ecdsa_sign(priv, hash) -> (r, s)`, `ecdsa_sign_hedged(priv, hash, extra) -> (r, s)`, `ecdsa_verify(x, y, hash, r, s) -> int`, `public_key_valid(x, y) -> int` and `private_key_valid(priv) -> int`, on big-endian buffers of 32, 48, 66 and 32 bytes. They run on `std.cryptography.ecc`, a fixed-width Montgomery field engine with complete addition formulas and a fixed-window scalar multiplication: signing, ECDH and the scalar multiplications are constant time in the private key and the nonce, and a P-256 verify takes about half a millisecond (P-384 about 1.4 ms, P-521 3.4 ms). `ecdsa_sign` derives its nonce by RFC 6979 (there is no nonce parameter); `ecdsa_sign_hedged` mixes `extra`, fresh random bytes, into the same derivation (RFC 6979 section 3.6), so no two signatures of one digest share a nonce, and whatever `extra` holds the nonce is no weaker than RFC 6979's (#3376). Both take the digest itself, of any length (its leftmost qlen bits are signed: pass a SHA-512 digest to P-521 as its 64 bytes, not padded to 66). `ecdh`, `scalar_mult` and `ecdsa_verify` refuse a public point off the curve or with a coordinate of p or more, and `ecdh` and `ecdsa_sign` a private key outside [1, n-1], with null (or 0). `std.cryptography.ct` holds the constant-time helpers (`eq`, `lt`, `select`, `bytes_eq`) and `declassify`, which marks a secret-derived value the protocol makes public; the constant-time test (`tests/integration/crypto_ct_valgrind`, under Valgrind's memcheck in CI) hooks it. + +`std.cryptography.shield` holds a long-lived secret (a private key) encrypted in memory, in the clear only while it is used, as OpenSSH shields its keys (#3376): `shield.wrap(secret)` → `(handle, string)` seals the buffer (consumed and wiped) with ChaCha20-Poly1305 under SHA-512 of a fresh 16 KiB random pre-key; `shield.unwrap(handle)` → `(ptr, string)` gives the secret back in a fresh buffer the caller frees with `bytes.wipe_free` after its one use, or refuses when a bit of the sealed secret or the pre-key changed in memory; `shield.free(handle)` wipes and frees both. Reading the secret out of memory then takes the whole pre-key without an error, where a plain key takes a few hundred bits. An unwrap costs about 0.1 ms. The pure TLS server keeps its private key this way: `tls13_server.ServerKey` holds the shield handle (`shielded`) in place of the plain scalar, seed or RSA key, and each signature unwraps it, signs and wipes it. + +Functions this batch added to the submodules (the rest of each module is +described in its header comment): + +- `x448.agree(scalar, u)` → `(ptr, string)` - X448 (RFC 7748) shared secret, or `(null, error)` for an all-zero result (a low-order peer point), as `x25519.agree` refuses it. +- `rsa.key_from_bytes(n, e, d)` → `ptr` - An RSA key from big-endian byte slices (`d` empty for a public key) without putting the private exponent through `std.bignum`, or null when `n` or `e` is empty or `d` is longer than `n`; the private-key operations are constant time and blinded. +- `rsa.key_from_pkcs1_der(der)` → `(ptr, string)` - An RSA key from PKCS#1 RSAPrivateKey DER, built as `key_from_bytes` builds one: the private exponent goes into the key byte by byte, never through `std.bignum`, so the key's `d` bignum is null (as for `key_from_bytes`), and the DER copies it passes through are wiped as they are freed (#3382). +- `pem.parse_at(text, from)` → `(ptr, string, string)` - `pem.parse` for the first block at or after offset `from`. Both decode the base64 body where it lies in `text`, into a buffer of its final size, so a private key's block leaves no copy as a string or a grown buffer (#3382). +- `tls13_cert.validate_chain(leaf, intermediates, anchors, at, purpose, max_depth)` → `string` - `""` when there is a valid path from `leaf` through `intermediates` to one of `anchors` (lists of `*LeafCert`) at `at` (`"YYYYMMDDHHMMSS"`, `""` for now) for `tls13_cert.PURPOSE_SERVER` or `PURPOSE_CLIENT`, through at most `max_depth` intermediates that are not self-issued (negative for the default), else why not. RFC 5280 section 6 as the Web PKI profiles it; the comment above `PURPOSE_SERVER` in the module lists the rules. +- `tls13_cert.TRUST_PARTIAL_CHAIN` - Or'd into `validate_chain`'s `purpose`: any anchor ends a path, not only a self-signed root, an anchor that is not a root held to an intermediate's rules, and a leaf that is itself an anchor trusted as it stands (OpenSSL's `X509_V_FLAG_PARTIAL_CHAIN`; `client.set_partial_chain` sets it, #3311). +- `tls13_cert.check_validity_at(c, at)` → `string` - `""` when `at` is inside the certificate's validity period. +- `tls13_cert.sign_server_cert_verify_ecdsa(kind, priv, transcript_hash)`, `sign_server_cert_verify_ed25519(seed, transcript_hash)`, `sign_server_cert_verify_rsa_pss(key, transcript_hash)` → `ptr` - A server CertificateVerify signature (RFC 8446 4.4.3) with an ECDSA key of `kind` (`KEY_P256`, `KEY_P384`, `KEY_P521`, hashed with SHA-256, -384, -512), an Ed25519 seed, or an `rsa` key as rsa_pss_rsae_sha256 with a fresh 32-byte salt; DER for ECDSA; null when the key cannot sign (#3326). +- `tls13_server.load_private_key(pem_text)` → `(ptr, string)` - The server's key from PEM: PKCS#8 (EC P-256, P-384, P-521, Ed25519, RSA), SEC1 `EC PRIVATE KEY` (an EC PARAMETERS block before it is skipped) or PKCS#1 `RSA PRIVATE KEY`; RSA of 2048 to 16384 bits; an encrypted key is refused with how to decrypt it. `accept` takes it; `free_private_key(key)` wipes and frees it (#3326). +- `tls13_server.parse_cert_chain_pem(pem_text)` → `(ptr, ptr)`, `parse_cert_chain_pem_checked(pem_text)` → `(ptr, ptr, string)` - The chain a server sends: the DER of every `BEGIN CERTIFICATE` block, in order, each parsed as a certificate when loaded; other blocks (a private key in a combined file) are skipped; a certificate block that does not decode or parse refuses the whole text, with why (#3378). +- `tls13_record.seal_records(ctx, content_type, plaintext)` → `ptr` - `plaintext` sealed as records of at most 2^14 bytes of content each, back to back, or null when one cannot be sealed; the TLS client and server send handshake messages and application data this way (RFC 8446 5.1, #3380). +- `tls13_hs.ChParsed.sig_algs` - The `signature_algorithms` schemes a ClientHello offered that a server key signs with, as `SIGBIT_ECDSA_P256`, `SIGBIT_ECDSA_P384`, `SIGBIT_ECDSA_P521`, `SIGBIT_RSA_PSS_SHA256` and `SIGBIT_ED25519` bits; `saw_sig_algs` says whether the extension was there (#3326). +- `tls13_client.connect_socket(sock, host, cafile)` → `(ptr, string)` - The verified TLS 1.3 handshake for `host` over an already-connected `std.tcp` socket, trusting the PEM bundle `cafile` (`""` for the system store); the connection owns the socket. +- `tls13_hs.check_extensions(kind, exts, offered)` → `(int, string)` - `(0, "")` when the extensions block `exts` of a server message of `kind` (`MSG_SERVER_HELLO`, `MSG_ENCRYPTED_EXTENSIONS`, `MSG_CERTIFICATE_ENTRY`, ...) answers only what the ClientHello offered (`OFFER_*` bits) where RFC 8446 4.2 allows it, else the alert to send and why. +- `tls13_hs.check_certificate(body, offered)` → `(int, string)` - The same for the body of a server's Certificate message: an empty request context, at least one entry, the list filling the message, each entry's extensions allowed, a status_request holding one OCSP response. +- `tls13_hs.check_certificate_request(body)` → `(int, string)` - The same for the body of a CertificateRequest in the handshake: an empty request context, extensions that fill the message, signature_algorithms among them (missing_extension without, #3324). +- `tls13_hs.offers_sig_scheme(scheme)` → `int` - 1 when `scheme` is one of the signature schemes every ClientHello here offers, the only ones a server may sign its CertificateVerify with (RFC 8446 4.4.3). +- `tls13_cert.check_leaf_cert_verify(leaf, scheme, transcript_hash, sig)` → `(int, string)` - `(0, "")` when the CertificateVerify signature `sig` under `scheme` verifies with the key of the end-entity certificate `leaf`, else the alert and why: illegal_parameter for a scheme that does not fit the key, decrypt_error for a signature that does not verify (ECDSA P-256, P-384, P-521, rsa_pss_rsae_sha256, Ed25519; #3324, #3335). +- `tls13_client.conn_key_update(conn, request)`, `tls13_server.conn_key_update(conn, request)` → `string` - Update the connection's sending keys now with a KeyUpdate (RFC 8446 4.6.3); `request` `tls13_hs.KEY_UPDATE_REQUESTED` asks the peer to update its own, which the next `conn_recv` takes in its stride, `KEY_UPDATE_NOT_REQUESTED` does not. `conn_recv` on either side handles the peer's KeyUpdates and answers a request once before the next data (#3320). +- `tls13_hs.key_update_msg(request)` → `ptr`, `tls13_hs.key_update_request(body)` → `(int, int, string)` - A KeyUpdate message, and the request_update of a KeyUpdate body or the alert and why (decode_error for a body that is not one byte, illegal_parameter for a value other than 0 and 1) (#3320). +- `tls13_ks.next_traffic_secret(algo, secret)`, `tls13_ks.key_update(algo, ctx, secret)` → `ptr`; `tls13_ks.free_secret(secret)` - The traffic secret after `secret` (RFC 8446 7.2), and the same with the record context `ctx` keyed afresh from it; both consume `secret` (zeroed, then freed). `free_secret` zeroes a secret and frees it (#3320). +- `tls13_record.rekey(ctx, key, iv12)` - Key a record context afresh, its AEAD unchanged and its sequence number 0; the old key is zeroed (#3320). +- `tls13_hs.parse_new_session_ticket(body, out)` → `string` - A NewSessionTicket body into `tls13_hs.NstParsed` (lifetime, age_add, where the nonce and ticket are, max_early_data), or why not with `out.alert` (RFC 8446 4.6.1: an empty ticket, trailing bytes, a duplicate or malformed extension are refused; one this client does not know is ignored) (#3319). +- `tls13_client.alert_event(level, desc, warnings)` → `(int, int, string)` - What an alert record after the handshake means (`EV_CLOSED`, `EV_NONE`, `EV_ERROR`), the alert to answer with and why: a close_notify closes, a user_canceled is read past (at most four in a row), any other alert is an error, answered with decode_error when it claims the warning level (#3323). +- `tls13_hs.hello_retry_request(session_id, cipher_suite, group)` → `ptr` - A HelloRetryRequest asking for a share of `group` (RFC 8446 4.1.4) (#3325). +- `tls13_hs.client_hello_retry(ch1, group, share, cookie, out)` - The second ClientHello: `ch1` with only the changes a HelloRetryRequest allows (one key share for `group` when it is not 0, the `cookie` when it is not empty, no early_data, the PSK binders zeroed for the caller to compute again) into a `PskCh` (#3325). +- `tls13_hs.ShParsed.session_id_off`, `session_id_len`, `selected_identity`, `cookie_off`, `cookie_len` - Where the session id echo and a HelloRetryRequest's cookie are in the message, and the pre_shared_key's selected_identity (-1 without one), for the client to check (#3318, #3325). +- `tls13_hs.ChParsed.groups`, `retry_group`, `has_early_data`, `has_cookie` - The `GROUPBIT_X25519MLKEM768` and `GROUPBIT_X25519` groups `supported_groups` names, the group a HelloRetryRequest should ask for when no key share is usable (else 0), and whether early_data and cookie were there (#3325). +- `tls13_client.transcript_retry(tr, ch1, hrr, algo)` → `ptr` - The transcript after a HelloRetryRequest: `tr` (freed) replaced by message_hash of `ch1` and then `hrr` (RFC 8446 4.4.1) (#3325). +- `tls13_client.conn_early_accepted(conn)` → `int` - 1 when the server took the 0-RTT data `connect_resume_0rtt` sent; 0 when it was not delivered (no 0-RTT offered, refused, or a HelloRetryRequest) and the caller sends it again if it still wants it delivered (#3355). +- `tls13_client.ticket_cipher_suite(ticket)` → `int` - The cipher suite of the connection a `SessionTicket` came on, which its 0-RTT data uses (RFC 8446 4.2.10); a ticket that names none sends no early data (#3339). +- `rsa.sign_pss_hash(k, algo, mhash, salt)` → `ptr`, `rsa.verify_pss_hash(k, algo, mhash, sig, slen)` → `int` - RSASSA-PSS with `algo` `"sha256"`, `"sha384"` or `"sha512"` for the message hash and MGF1; `sign_pss` and `verify_pss` are the `"sha256"` cases (#3357). +- `tls13_cert.sign_server_cert_verify_rsa_pss_scheme(key, scheme, transcript_hash)` → `ptr` - A server CertificateVerify signature as rsa_pss_rsae_sha256, `SCHEME_RSA_PSS_RSAE_SHA384` or `SCHEME_RSA_PSS_RSAE_SHA512`, with a salt of the hash's length; null for another scheme (#3357). +- `tls13_cert.verify_cert_verify_rsa_pss_scheme(spki_key, scheme, transcript_hash, sig)` → `int` - 1 when `sig` is a valid rsa_pss_rsae_sha256, `_sha384` or `_sha512` CertificateVerify signature by the RSAPublicKey `spki_key`, as `sign_server_cert_verify_rsa_pss_scheme` signs; 0 otherwise, another scheme included (#3376). +- `tls13_cert.leaf_signature_holds(leaf, scheme, transcript_hash, sig)` → `int` - 1 when `sig` is a valid CertificateVerify signature under `scheme` by the key of the parsed certificate `leaf`, for every scheme the module signs (ECDSA P-256, P-384, P-521, rsa_pss_rsae_sha256/384/512, Ed25519); 0 otherwise, a scheme that does not fit the key included. The pure server checks its own signature with it before sending it (#3376). +- `tls13_cert.client_cert_verify_holds(leaf, transcript_hash, sig)` → `int` - 1 when `sig` is a valid client CertificateVerify signature (the "client" context) under ecdsa_secp256r1_sha256 by the key of the parsed certificate `leaf`; 0 otherwise. The mTLS client checks its own signature with it before sending it (#3376). +- `tls13_hs.ChParsed.alert` - The alert `parse_client_hello` says to send when it refuses a ClientHello (decode_error, illegal_parameter, missing_extension, unexpected_message, protocol_version, handshake_failure; #3357); `GROUPBIT_SECP256R1` and `SIGBIT_RSA_PSS_SHA384`, `SIGBIT_RSA_PSS_SHA512` join its `groups` and `sig_algs` bits. +- `tls13_hs.client_hello_offer(random, session_id, group, pubkey, group2, pubkey2, group3, pubkey3, host, named_only)` → `ptr` - `client_hello_full`, with `named_only` (0 for none) last in supported_groups and no key share for it; `GROUP_SECP384R1` (0x0018) is the group the pure client names that way, and follows a HelloRetryRequest for (#3369). + --- ## Encodings (`std.encoding`) @@ -2245,7 +2296,9 @@ Raw externs: `http_server_bind_raw`, `http_server_start_raw`, and `http_server_e - `http.server_use_middleware_loop_safe(server, middleware, user_data, release)` - Add a middleware that never blocks (CPU work, a short mutex), so the proxy event loop may run it (#3214): on Linux and macOS a server with a `std.http.proxy` mount stays on the loop only when every middleware before the first mount and every request hook is loop-safe. `release` frees `user_data` with the server, or `null` keeps it the program's. A middleware added with `server_use_middleware` is not loop-safe. **Server Configuration:** -- `http.server_set_tls(server, cert_path, key_path)` → `string` - Enable HTTPS with PEM cert + key. +- `http.server_set_tls(server, cert_path, key_path)` → `string` - Enable HTTPS with PEM cert + key. The first certificate in the file is the server's, the rest the chain it sends (#3370). +- `http.server_set_tls_pem(server, cert_pem, key_pem)` → `string` - The same, from PEM text in memory, so the key need not be written to disk (#3362). Same checks, same results, both backends; the server keeps its own copy and wipes it when replaced or freed. +- `http.server_set_tls_backend(server, backend)` → `string` - Choose the TLS backend: `"openssl"`, `"aether"` (the pure-Aether TLS 1.3 server; the program must `import std.cryptography.tls13_server`) or `""` for the default (OpenSSL where it is built in, the pure server where it is not or with `AETHER_PURE_TLS=1`). The choice wins over the environment variable. Call it before `server_start`, before or after `server_set_tls`. Returns `""`, or why the backend cannot be used (not built in, not linked, an unknown name). - `http.server_set_keepalive(server, enable, max_requests, idle_timeout)` → `string` - HTTP/1.1 keep-alive with a `Duration` idle timeout (`max_requests=0` is unlimited per connection). Keep-alive is **on by default**, so this is for changing the limits or turning it off. A response with no definite body length is never kept open; everything else is, and a connection with no request in flight waits in a poller rather than on a worker, so the number a server can hold open is a descriptor count and not a thread count. Measured on an 8-core box (16 workers), 3000 requests per cell: 78,700 rps at 8 concurrent clients, 74,400 at 50 and 70,000 at 200, against 22,000 with a close per response. See [http-server.md](http-server.md#idle-connections-wait-in-a-poller-not-on-a-worker) for the handoff rule and the full table. The idle timeout also bounds a read or a send that makes no progress: a client that stops sending its request, or stops reading its response, for that long has its connection closed (#3243, #3257); with the pure-Aether TLS stack (`AETHER_PURE_TLS=1`) a send is bounded as std.tcp bounds one, between once and twice that. - `http.server_set_h2(server, max_concurrent_streams)` → `string` - Enable HTTP/2 (h2 + h2c + ALPN). `max_concurrent_streams=0` uses libnghttp2's default (100). Returns error string when the build is missing libnghttp2. - `http.server_set_multi_accept(server, on)` → `string` - In actor dispatch mode, one listener per core (up to 16) on the server's one port, each with `SO_REUSEPORT` and an accept thread of its own, for connection rates where one `accept()` is the bottleneck. Off by default; thread-pool mode does not use it. Call it before starting, after `server_bind` or without it: a bound socket stays the first listener and gives the others its port, a port 0's included, and `server_port` reports the port they share. Returns an error where the kernel does not spread connections across the listeners: it does on Linux and DragonFly (`SO_REUSEPORT`) and FreeBSD (`SO_REUSEPORT_LB`), not on Windows, macOS, OpenBSD or NetBSD (#3194) @@ -2448,6 +2501,8 @@ in. These knobs relax that per request; precedence for proxy is `ignore > explicit > env > direct`. - `client.set_insecure(req, on)` → `string` - `1` skips TLS peer + hostname verification for this request only (`curl -k` / `wget --no-check-certificate`). Relaxed per-connection, never on the shared process-wide `SSL_CTX`, so other requests keep verifying. Default `0`. Use only against hosts trusted out-of-band (self-signed dev/staging/appliance certs), it removes MITM protection for that request. - `client.set_cafile(req, path)` → `string` - pin a custom CA for this request: verify the peer against the PEM bundle at `path` instead of the system trust store, while **keeping peer and hostname verification on**. This is the "verify, but against THIS cert" knob, strictly stronger than `set_insecure`, for machine-to-machine calls to a host with a private/self-signed CA couriered out-of-band (courier the CA once over SSH, then pin it instead of blind-trusting). Per-connection via a per-`SSL` `X509_STORE`; never touches the shared `SSL_CTX`. `""` clears the pin (revert to the system store). A certificate the pinned CA doesn't cover fails the handshake, fails closed, never open. +- `client.set_partial_chain(req, on)` → `string` - trust partial chains for this request (#3311): any certificate in the trust store (the system one, or `set_cafile`'s) ends a certification path, not only a self-signed root. For a CA file that holds an intermediate CA alone: a private PKI that hands out its issuing CA, or pinning one CA below a public root. Peer and hostname verification stay on, and a trusted certificate that is not a root must still be a CA (v3, `cA`, `keyCertSign`). Off by default, and the same on both TLS backends (OpenSSL's `X509_V_FLAG_PARTIAL_CHAIN`; without it, a trust anchor must be a self-signed root, #3310). Pooled connections are kept apart by it. +- `client.set_tls_backend(req, backend)` → `string` - Choose this request's TLS backend: `"openssl"`, `"aether"` (the pure-Aether TLS 1.3 client; the program must `import std.cryptography.tls13_client`) or `""` for the default (OpenSSL where it is built in, the pure client where it is not or with `AETHER_PURE_TLS=1`). The choice wins over the environment variable, and pooled connections are kept apart per backend. Returns `""`, or why the backend cannot be used (not built in, not linked, an unknown name). - `client.use_env_proxy(req, on)` → `string` - `1` follows `$HTTP_PROXY`/`$HTTPS_PROXY`/`$NO_PROXY` (Go-compatible). **Off by default**, the deliberate inverse of the default-follow that caused the httpoxy vulnerability class (CVE-2016-5385). It is a code-visible opt-in, never ambient, and carries two guards: the CGI-injectable uppercase `HTTP_PROXY` is refused when `$REQUEST_METHOD`/`$GATEWAY_INTERFACE` is set (lowercase `http_proxy` stays honoured), and a proxy resolving to a loopback/link-local IP literal (127/8, 169.254/16 IMDS, `::1`, `fc00::/7`, `fe80::/10`) is rejected (SSRF). - `client.use_http_proxy(req, "http://host:port")` → `string` - Pin an explicit forward proxy; env is ignored entirely (empty url reverts to direct). A team-controlled proxy (recorder / toxiproxy) is thus immune to whatever the shell or CI has set. HTTP goes through the proxy with an absolute-form request line; HTTPS establishes a `CONNECT` tunnel with TLS end-to-end to the origin. - `client.ignore_http_proxy(req)` → `string` - Force a direct connection regardless of env or any proxy a higher layer set (the determinism escape hatch, e.g. a VCR that must record the origin, not a proxy's view). @@ -2668,6 +2723,8 @@ main() { **Functions (Go-style):** - `tcp.connect(host, port)` → `(ptr, string)` - Connect, return `(socket, err)` +- `tcp.connect_buffered(host, port, read_buffer)` → `(ptr, string)` - Connect with the receive buffer (`SO_RCVBUF`) set to `read_buffer` bytes before the connection is made, so the window it opens with is that size; for a client meant to take little while it does not read. `tcp.set_read_buffer` on a connected socket leaves the window already advertised (#3338). +- `tcp.available(sock)` → `int` - Bytes waiting in the socket's receive buffer, readable without blocking (`FIONREAD`), or -1. - `tcp.write(sock, data)` → `(int, string)` - Write text-shaped data, return `(bytes_sent, err)`. Uses the legacy strlen-shaped raw send; use `tcp.write_n` for binary payloads. - `tcp.write_n(sock, data: byte[])` → `(int, string)` - Length-aware write, return `(bytes_sent, err)`. Sends the slice's bytes, preserving embedded NUL bytes; send a string as `string.bytes(s)`, a prefix as `string.bytes(s)[0..n]`. - `tcp.read(sock, max)` → `(string, string)` - Read text-shaped data, return `(data, err)`. Use `tcp.read_n` for binary payloads. @@ -2679,7 +2736,7 @@ main() { - `tcp.close(sock)` - Close socket (infallible) - `tcp.server_close(server)` - Close server socket -Raw externs: `tcp_connect_raw`, `tcp_send_raw`, `tcp_send_n_raw`, `tcp_receive_raw`, `tcp_receive_n_raw`, `tcp_send_bytes_raw`, `tcp_receive_into_raw`, `tcp_listen_raw`, `tcp_accept_raw`. +Raw externs: `tcp_connect_raw`, `tcp_connect_buffered_raw`, `tcp_available_raw`, `tcp_send_raw`, `tcp_send_n_raw`, `tcp_receive_raw`, `tcp_receive_n_raw`, `tcp_send_bytes_raw`, `tcp_receive_into_raw`, `tcp_listen_raw`, `tcp_accept_raw`. ### UDP slices (`std.udp`) @@ -3685,9 +3742,7 @@ import std.audit run_sandboxed(perms: ptr, code: fn) { sandbox_push(perms) - sandbox_install() call(code, perms) - sandbox_uninstall() sandbox_pop() } @@ -3864,6 +3919,25 @@ lengths = worker.map(paths, | p: ptr | { return measure(p) }) item, which the runtime cannot see. Numbers and method in [`docs/cross-references/bend.md`](cross-references/bend.md). +### Streams between actors (`std.stream`) + +A send never blocks, so a producer that outruns its consumer grows memory +without bound. A stream bounds it: the producer holds `window` credits, each +chunk it sends costs one, and the consumer returns one per chunk handled, so +at most `window` chunks are ever in flight. Running out of credit is a return +value (`stream.offer` gives 0, `stream.write` returns short), never a block. + +- `stream.connect(producer, consumer, window)` - The stream, for both actors. +- `stream.write(s, data, at, chunk)` / `stream.offer(s, data)` - The + producer's sends; `stream.finish(s)` ends the stream. +- `stream.credit(s, n)` - The producer's, for each `StreamCredit`; 0 when the + consumer has ended the stream, which is freed then. +- `stream.consumed(s, last)` - The consumer's, after each `StreamChunk`. + +The whole contract, a runnable producer and consumer, and the choices made +(chunks as the unit of credit, the mailbox as the buffer, no reordering) are +in [`std/stream/README.md`](../std/stream/README.md). + --- ## Memory @@ -3954,6 +4028,7 @@ byte 255 - `mem.get_int` / `get_long` / `get_ptr` / `get_float32` / `get_float64`, and their `set_` twins - `mem.get_u16_le` / `get_u16_be` / `get_u32_le` / `get_u32_be` / `get_u64_le` / `get_u64_be`, and their `set_` twins - Explicit byte order, for wire formats - `mem.copy(dst, src, n)` / `move(dst, src, n)` / `compare(a, b, n)` / `set(dst, value, n)` - `memcpy`, `memmove`, `memcmp`, `memset` +- `mem.wipe(dst, n)` - Zero `n` bytes so the writes happen even right before a free, where `set` is a dead store the C compiler may drop: the raw-memory form of `bytes.wipe`, for a struct a module frees itself, such as a hash context holding secret state (#3402) - `mem.copy_at` / `move_at` / `fill_at` / `compare_at` - The same with a byte offset on each buffer - `mem.bits_of_float` / `float_from_bits`, `mem.clz32` / `clz64`, `mem.udiv64_32` - Bit-level helpers - `mem.ptr_to_long(p)` / `long_to_ptr(addr)` - A pointer as a 64-bit address and back, for tagged-pointer arithmetic or storing an integer in a pointer slot @@ -4525,6 +4600,14 @@ handle_request(req: ptr, res: ptr, ud: ptr) { Models BEAM's `erlang:register` / `whereis`. The registry doesn't track actor liveness, if the actor exits, `whereis` keeps returning the stale ref until something explicitly calls `unregister`. Match BEAM's behaviour for non-link'd registrations. +The rest of the module is about the end of an actor: `actors.monitor(ref)` +asks for a `Down { ref, reason }` message when it ends, `actors.link(ref)` +ties two actors' ends together, `actors.kill(ref)` and `actors.stop()` end +one, and `actors.dead_sends()` counts the messages that reached a dead one. +The [guide](../std/actors/README.md) has each, and +[`std.supervise`](../std/supervise/README.md) builds supervisors that restart +actors on them. + **Why the module name is plural**: `actor` (singular) is a reserved keyword in Aether and can't appear as a namespace prefix. `actors.register(...)` parses; `actor.register(...)` does not. The plural also reads correctly, "the actors registry." --- @@ -4567,6 +4650,7 @@ export process_order(order_id: long) -> int { - `host.notify(event, id)` → `int` - 1 if the host had a handler for `event`, 0 if not - `host.caller_identity()` → `string`, `host.caller_attribute(key)` → `string`, `host.caller_deadline_ms()` → `long` - `""` / 0 outside a host call - `host.describe`, `host.input`, `host.event`, `host.bindings`, and the per-language `java` / `python` / `ruby` / `go` helpers - The manifest DSL a namespace's `manifest.ae` uses to describe its ABI +- `host.last_outcome()` → `int`, `host.last_outcome_text()` → `string` - How the last call into a library export on this thread ended: `host.OK`, `host.PANICKED`, `host.DEADLINE`, `host.CANCELLED`, `host.MEMORY_CAP` or `host.OUT_OF_MEMORY`, and its reason (`""` for OK). A program importing a library in binary form gets a failed call raised again as a panic; these read it without one (#2956) ## See Also diff --git a/docs/structured-concurrency.md b/docs/structured-concurrency.md index 22c0da256..f354aed23 100644 --- a/docs/structured-concurrency.md +++ b/docs/structured-concurrency.md @@ -1,17 +1,18 @@ # Structured Concurrency for Aether, Design Direction -Status: **proposal**. Nothing shipped yet. This doc captures the design -choices we've committed to before writing code, so the implementation PRs -have a target to aim at. +Status: **Phase 1 (supervision) shipped** (#2974); **Phase 2 +(capability-scoped concurrency) is a proposal**. This doc captured the +design choices before the code, and now records where Phase 1 landed. ## What we're trying to fix -Aether has actors. It does not have: +Aether had actors. It did not have: - **A way for a parent to know when its children die.** If an actor - handler panics or exits, no notification reaches the actor that spawned - it. Long-running supervisor patterns (Erlang-style restart-on-crash, - graceful shutdown of a subtree) can't be written. + handler panicked or exited, no notification reached the actor that + spawned it. Long-running supervisor patterns (Erlang-style + restart-on-crash, graceful shutdown of a subtree) could not be written. + Phase 1 fixed this. - **Any containment on which actors a block can reach.** A function body can send a message to any actor reference it can name, and `hide` / `seal except` don't extend that restriction to concurrency sites (spawn, @@ -54,12 +55,12 @@ for half their callers. ### The two we're building **Supervision trees** solve the "who's responsible when a child dies" -problem. They're a library-level pattern on top of primitives the runtime -*doesn't yet expose but easily could*: actor failure notification. -Parents register interest in a child; if the child dies, the parent gets -a `Down { ref, reason }` message. Everything else, restart strategies, -escalation, graceful shutdown, is ordinary actor code on top of that -one primitive. +problem. They're a library-level pattern on top of a primitive the runtime +now exposes: actor failure notification. Parents register interest in a +child; if the child dies, the parent gets a `Down { ref, reason }` +message. Everything else, restart strategies, escalation, graceful +shutdown, is ordinary actor code on top of that primitive, plus restart +in place so a restarted child keeps its ref. **Capability-scoped concurrency** solves the "who can reach whom" problem. Aether already has `hide` and `seal except` for compile-time @@ -73,72 +74,88 @@ existing scope-denial check applied at two more compile-time sites The two compose: a supervised subtree that is also sealed gives you both bounded lifetime and bounded blast radius. -## Phase 1, supervision primitives - -The runtime change needed is small: actor failure notification. - -### What the runtime must expose - -1. When an actor's handler returns abnormally (an explicit `exit(reason)`, - or a panic caught by the scheduler), the scheduler delivers a - `Down { ref, reason }` message to every actor that registered - interest in the failed actor. -2. Registration is one of two kinds: - - **Monitor**, one-way. I get a `Down` if it dies; it doesn't hear - about me dying. - - **Link**, two-way (Erlang semantics). If either of us dies, the - other gets a `Down`. -3. Delivery is best-effort at-most-once, routed through the normal - mailbox. No new transport. +## Phase 1, supervision: shipped + +Phase 1 is in (#2974). What shipped, and where it went past or away from +the proposal: + +### What the runtime exposes + +1. An actor ends once, by the first of: a panic in its handler (caught by + the step's barrier, on both schedulers where the target has setjmp), + `actors.kill(ref)`, `actors.stop()` from its own handler, the death of + an actor it is linked to, or a C host's release. Its death is finalized + on the thread that owns its mailbox while it steps nothing: what was + queued for it is dropped and counted, and every actor watching it gets + one `Down { ref, reason }`: `"normal"`, `"killed"`, or the panic's + message. A `Down` never comes while the dead actor's handler still + runs, and comes after the last messages it sent its watcher. +2. **Monitor**, one-way: `actors.monitor(ref)` returns an id; + `actors.demonitor(id)` drops it, and no `Down` of it is handled after, + one already on its way included. A monitor of an actor that has ended + already is answered at once. +3. **Link**, two-way: `actors.link(ref)`. When either ends, the other ends + with the same reason, unless it is `"normal"`; an actor that called + `actors.trap_exits(true)` gets a `Down` instead and lives on. +4. **Restart in place**: a supervisor restarts a dead child under the ref + its siblings and clients hold (`actors.rebirth_begin`, the child's + factory, `actors.rebirth_end`); nothing sent before the restart reaches + the new incarnation. +5. Delivery is at most once and counted, through the normal mailbox (see + docs/actor-concurrency.md, "The delivery contract"), with an opt-in + mailbox bound (`@mailbox(bound: N)`, `try_send`). ### Language / stdlib surface +The proposal had bare builtins (`monitor`, `link`, `demonitor`, +`unlink`). They are `std.actors` functions instead, since bare `kill`, +`stop`, `unlink` and `monitor` collide with std.os, std.audio, std.fs and +std.http under glob imports: + ```aether,fragment -// Builtins -monitor(actor_ref) -> monitor_id // one-way -link(actor_ref) // two-way -demonitor(monitor_id) // cancel monitor -unlink(actor_ref) // cancel link - -// System message -message Down { - ref: actor_ref - reason: string // "normal", "killed", or a user-supplied atom -} +import std.actors + +id = actors.monitor(ref) // one-way, from a handler +actors.demonitor(id) +actors.link(ref) // two-way, from a handler +actors.unlink(ref) +actors.trap_exits(true) +actors.kill(ref) // from anywhere +actors.stop() // from the actor's own handler ``` +`Down` is built into the compiler (`message Down { ref: actor_ref, reason: +string }`, the reserved id 254), since the runtime writes its payload; a +program's own `message Down` is refused. On wasi a panic still ends the +program (no setjmp), so `monitor`, `link` and `trap_exits` are refused at +compile time there. + ### Supervisor as a library actor -No language-level supervisor primitive. A `std.supervise` module provides -an ordinary actor type: +`std.supervise` is an ordinary actor on these pieces, with the builder +shape the proposal sketched: ```aether,fragment import std.supervise main() { - sup = spawn(supervisor(strategy: "one_for_one")) { - child("worker_a", || spawn(WorkerA())) - child("worker_b", || spawn(WorkerB())) - child("db_writer", || spawn(DbWriter())) + sup = supervise.start(strategy: "one_for_one", max_restarts: 3, within_secs: 5) { + supervise.child("worker_a", || -> spawn(WorkerA())) + supervise.child("worker_b", || -> spawn(WorkerB())) + supervise.child_restart("db_writer", || -> spawn(DbWriter()), "transient") } } ``` -The supervisor actor internally links each child, handles `Down` -messages according to its strategy, and decides whether to restart, -escalate, or shut the subtree down. Strategies mirror Erlang: `one_for_one` -(restart just the dead one), `one_for_all` (restart all siblings on any -death), `rest_for_one` (restart the dead one and everything started -after it). - -Note the builder-DSL shape (`spawn(...) { child(...) child(...) }`) uses -the trailing-block + closure pattern that shipped with the closures and -embedded-namespaces features. The supervisor actor is the `_ctx` holder; -`child()` registers into it. +Strategies mirror Erlang: `one_for_one`, `one_for_all`, `rest_for_one`; +restart kinds `permanent`, `transient`, `temporary`; past `max_restarts` +within `within_secs` the supervisor kills its children and ends with a +reason that says why, so a supervisor above it takes the failure. See +std/supervise/README.md and examples/02-actors/12-supervisor.ae. ## Phase 2, capability-scoped concurrency -Once Phase 1 is in, this is a compile-time-only change. No new runtime +Phase 1 is in; this is a compile-time-only change. No new runtime mechanism. ### What the compiler must enforce @@ -198,22 +215,27 @@ crypto-miner actor because it cannot name one. ## Order of work -1. **Runtime, `Down` message delivery.** Scheduler catches handler - exits, iterates the monitor/link table, enqueues `Down` messages. -2. **Builtins, `monitor`, `link`, `demonitor`, `unlink`.** Thin - wrappers over the runtime table. -3. **`std.supervise` module.** Supervisor actor type with the three - strategies. Builder DSL for child registration. -4. **Tests.** Restart-on-crash, escalation, graceful shutdown, monitor - vs link asymmetry, double-death ordering. -5. **Compiler, capability-scoped spawn/send/ask.** Extend existing - `hide`/`seal except` denial to concurrency sites. -6. **Docs and worked example.** Update `docs/actor-concurrency.md` with - the supervision section. One worked example in `examples/02-actors/` - showing a supervised, sealed subtree. - -Phase 1 can ship without Phase 2. Phase 2 can ship independently once -Phase 1 is in, they touch different compiler subsystems. +1. **Runtime, `Down` message delivery.** Done (#2974): the death is + finalized on the dead actor's own thread, its watchers sent `Down`. +2. **`monitor`, `link`, `demonitor`, `unlink`.** Done (#2974), as + `std.actors` functions, with `trap_exits`, `kill`, `stop`, and restart + in place. +3. **`std.supervise` module.** Done (#2974): the three strategies, restart + kinds, a restart budget, the builder DSL. +4. **Tests.** Done (#2974): restart on crash, escalation past the budget, + monitor and link, kills mid-handler, restarts racing senders on three + cores (tests/runtime/test_actor_death.c, test_actor_rebirth.c, + tests/regression/test_actor_down_notices.ae, test_actor_links.ae, + test_supervise.ae). +5. **Compiler, capability-scoped spawn/send/ask.** Phase 2, not started. + Extend existing `hide`/`seal except` denial to concurrency sites. +6. **Docs and worked example.** Done for Phase 1: + `docs/actor-concurrency.md` ("The delivery contract", "The end of an + actor", "Supervisors") and examples/02-actors/12-supervisor.ae. The + sealed subtree waits for Phase 2. + +Phase 1 shipped without Phase 2. Phase 2 can ship independently, they +touch different compiler subsystems. ## References diff --git a/docs/writing-security-sensitive-code.md b/docs/writing-security-sensitive-code.md index 7dd6c505b..b05a7364d 100644 --- a/docs/writing-security-sensitive-code.md +++ b/docs/writing-security-sensitive-code.md @@ -132,11 +132,150 @@ Where you must write something new: ## Nonces, IVs and counters - A nonce is **per message**, never reused with the same key. For ECDSA that is - a hard requirement (nonce reuse across two signatures recovers the key); for - AEAD, reuse breaks confidentiality and authenticity both. + a hard requirement (nonce reuse across two signatures recovers the key, and + so does a nonce whose top bits are predictable); for AEAD, reuse breaks + confidentiality and authenticity both. +- **Do not take a signing nonce from the caller.** `ecdsa_sign` in + `std.cryptography.p256` derives its nonce by RFC 6979 (HMAC-DRBG over the + private key and the digest), so the same digest signed twice gives the same + signature and no failing or biased random source can leak the key. It used + to take the nonce as a parameter, and the TLS server and the mTLS client + each had to draw one; an API that makes every caller get this right is an + API that will be called wrong. +- **Hedge a deterministic nonce where the key lives long.** A deterministic + nonce makes two signatures of one message share it, and a fault in one of + them then gives the key away. `ecdsa_sign_hedged` (RFC 6979 section 3.6) + and `ed25519.sign_hedged` mix fresh random bytes into the derivation. The + bytes are not a nonce: whatever they hold (repeated, zero, an attacker's), + the nonce is no weaker than the deterministic one, so taking them from + the caller is safe where taking the nonce is not. The TLS CertificateVerify + signatures, server and client, are hedged (#3376). - Do not derive a nonce from something the peer controls. - Where a counter is the nonce, the overflow case is a failure, not a wrap. +## Keys in memory, and the signatures that leave it + +A long-lived private key is exposed by what it leaves behind and by what a +fault makes it compute (#3376). + +- **Wipe a secret before you free it, with `bytes.wipe_free`.** A loop that + zeroes a buffer right before `free` is a dead store, and a compiler that + inlines it may drop it. `bytes.wipe` and `bytes.wipe_free` (and + `aether_wipe` in C, `runtime/utils/aether_wipe.h`) zero the whole + capacity through a call the compiler cannot see through. That covers key + material, nonces and the text of a private key. A `string` cannot be + wiped, so a secret that must be wiped lives in a `bytes` buffer, sized + for its final length, because a buffer that grows leaves its old contents + in the memory it moved out of. +- **A primitive wipes its own state.** Wiping the key is not enough if the + hash, MAC or cipher that used it frees its state as it was: SHA-512's + last block held an Ed25519 seed, HMAC's padded key is the key, and a + SHA-2 context holds the digest that the shield uses as its AEAD key. + So the rule is the whole tree's, not a choice per call, which missed one + module after another: every module under `std/cryptography` frees every + `bytes` buffer with `bytes.wipe_free` (`make check-crypto-wipe` fails on + a plain `bytes.free` there, and `make ci` runs it), and wipes a context + struct, a working matrix or an array of secret coefficients with + `mem.wipe` (the raw-memory form) before it frees it (#3402, #3414). A + public buffer pays a memset, which is nothing next to the cryptography + that made it. The freed-memory probe (`tests/integration/crypto_key_wipe`) + checks key loading, signing with each key type, an HMAC, a shield wrap + and unwrap, an X25519 exchange and the TLS key schedule. +- **Verify a signature before it leaves the process.** One faulty RSA-CRT + signature factors the modulus (Boneh, DeMillo and Lipton), and a fault in + one of two signatures of the same message under a deterministic nonce + (RFC 6979 ECDSA, Ed25519) gives the key. A fault can come from a voltage + or clock glitch, or from Rowhammer, from software. The pure TLS server + checks every CertificateVerify against its certificate's key + (`tls13_cert.leaf_signature_holds`), and a signature that fails is wiped + and never sent: the handshake ends with internal_error. The mTLS client + checks its own the same way (`tls13_cert.client_cert_verify_holds`). + Verify against the key the peer will use (the certificate's), not the + private key's own public half, so a fault in the key's memory is caught + too. + +## Constant time, checked by a tool + +Code that handles a secret must not branch on it, index memory with it, or +loop a number of times that depends on it: each of those makes the running +time or the cache footprint a function of the secret, and both are +measurable, across a network for the slow cases. A variable-time bignum +behind ECDSA signing leaks the nonce a few bits per signature; a few hundred +signatures then give the key. + +Write it with masks: + +```aether,fragment +// WRONG -- a branch on a secret bit +if bit == 1 { acc = add(acc, p) } + +// RIGHT -- always compute, keep the result with a mask +mask = 0 - bit // all ones or zero +r = r ^ (mask & (r ^ candidate)) // r = candidate when bit is 1 +``` + +and select from a table by reading every entry, never `table[secret]`. +`std.cryptography.ct` has `eq`, `lt`, `select` and `bytes_eq`, and +`std.cryptography.ecc` is the constant-time field and curve engine to build +on. + +Reading the code is not enough to know it is constant time: the C compiler can +turn a mask back into a branch. The check is mechanical. +`tests/integration/crypto_ct_valgrind` runs each secret-key operation with its +secret inputs marked undefined for Valgrind's memcheck, which then reports +every conditional jump and every memory address computed from them; the CI job +`Crypto constant-time (Valgrind)` fails on any such report. Shown main's +bignum P-256 before the rewrite, it reported 68 secret-dependent branches. + +A value the protocol reveals anyway (an ECDSA signature's retry decision, an +AEAD tag verdict, whether a key is in range) may decide a branch, and the +code says so with `ct.declassify(v)`, which the test hooks. Anything else +that memcheck reports is a leak to fix, not to declassify. + +## TLS conformance corpora + +The pure TLS stack is measured against outside test suites, each fetched at a +pinned commit into `$CONFORMANCE_CACHE` (default +`~/.cache/aether-conformance`) and never committed (#1298): + +- **x509-limbo and bettertls** (`make conformance-x509`, + `tests/conformance/x509_limbo`): every path-validation case of + [x509-limbo](https://github.com/C2SP/x509-limbo), including the bettertls + name-constraint suite it imports, through `tls13_cert.validate_chain` and + `check_hostname`. `limbo.json` is checked against its SHA-256. The Linux + job `ci-crypto-ct` runs it. `expected.txt` lists each case that is skipped + or does not agree, with the reason and OpenSSL's verdict: the two TLS + backends of `std.http` accept the same chains, except where OpenSSL accepts + something unsafe, which the pure backend refuses. +- **BoGo** (`make conformance-bogo`, `tests/conformance/bogo`): BoringSSL's + `ssl/test/runner` drives `shim.ae`, which runs `tls13_client` or + `tls13_server` for each test. It needs Go (1.27 at the pinned commit) and + git, takes about five minutes, and no CI job runs it. `config.json` + disables each test that fails, with the reason: TLS 1.2 and earlier, which + the pure stack does not implement, and the TLS 1.3 tests where it differs + from BoringSSL by design. `BOGO_FLAGS="-test 'Basic-*'"` narrows a run. + The shim handles only the flags its header lists; a test that passes any + other counts as unimplemented (about 7450 tests at the pinned commit, + against about 390 that pass), so BoGo does not cover: + - session resumption (`-resume-count`, the `-on-resume-*` and + `-expect-session-miss` checks) and with it 0-RTT (`-enable-early-data`, + `-on-resume-shim-writes-first`, `-on-resume-expect-accept-early-data`). + The client's resumption and 0-RTT are tested in `crypto_tls13_resume`, + against a scripted server and `openssl s_server`; the pure server issues + no tickets; + - ALPN (`-advertise-alpn`, `-select-alpn`, `-expect-alpn`); + - client certificates (`-require-any-client-certificate` and `-verify-peer` + on a server, a certificate on a client): the pure server asks for none, + and the client's mutual TLS is tested in `crypto_tls13_mtls_signature`; + - SNI (`-host-name`), configured groups and signature algorithms + (`-curves`, `-key-shares`, `-signing-prefs`, `-verify-prefs`), and the + `-expect-*` checks of what was negotiated; + - ECH, external PSKs, OCSP stapling and certificate compression options, + renegotiation, DTLS and QUIC. + +A case that changes verdict fails its run, so a change to the TLS code that +makes any of them pass or fail differently has to say so in the same commit. + ## Review checklist For any diff touching crypto, TLS or authentication: @@ -144,6 +283,11 @@ For any diff touching crypto, TLS or authentication: - [ ] Every randomness call checks both the error **and** the length - [ ] No security-relevant default on a failure path (no zeros, no reuse) - [ ] MAC/tag/token comparisons are constant-time +- [ ] No branch, loop bound or memory index depends on a secret, and the + operation is in `tests/integration/crypto_ct_valgrind` +- [ ] No signing API takes a nonce from its caller +- [ ] A signature is verified before it is sent, and secrets are freed with + `bytes.wipe_free` - [ ] Every verification failure returns an error naming the check - [ ] Failure paths free what they allocated - [ ] No `println` in library code diff --git a/examples/02-actors/10-crash-isolation.ae b/examples/02-actors/10-crash-isolation.ae index bc62dd2d7..cd6a8a2f9 100644 --- a/examples/02-actors/10-crash-isolation.ae +++ b/examples/02-actors/10-crash-isolation.ae @@ -5,13 +5,13 @@ // other actors keep running. The dead actor handles no more messages; // what is sent to it is dropped. An actor that should survive a bad // message catches the panic in its own handler with `try`, as any code -// does. That is the supervision Aether has today: crashes stay where they -// happen. Nothing tells another actor that one died yet, so nothing can -// restart it: links, monitors and supervisors are proposed in #2974 (and -// docs/structured-concurrency.md), and are not built. An ask is how a -// caller finds out: `v, err = actor ? Msg {}` ends at once with -// "the actor is dead" for a dead actor, and with "the actor died before it -// replied" for one that dies handling the ask. +// does. Crashes stay where they happen. Another actor learns of one +// through `actors.monitor` (a `Down` message) or `actors.link`, and +// std.supervise restarts a crashed actor under the same ref +// (12-supervisor.ae). An ask is how a caller finds out on its own: +// `v, err = actor ? Msg {}` ends at once with "the actor is dead" for a +// dead actor, and with "the actor died before it replied" for one that +// dies handling the ask. // // Run: ae run examples/02-actors/10-crash-isolation.ae // See: docs/actor-concurrency.md#panic-and-stack-traces diff --git a/examples/02-actors/12-supervisor.ae b/examples/02-actors/12-supervisor.ae new file mode 100644 index 000000000..5cb3e57b2 --- /dev/null +++ b/examples/02-actors/12-supervisor.ae @@ -0,0 +1,74 @@ +// Supervision: a crashed actor is restarted, under the ref its clients hold. +// +// std.supervise starts each child from a factory (a closure that spawns +// it), links it, and when it dies restarts it in place: the factory runs +// again and its spawn takes the dead child's block, so every ref to the +// child reaches the new one. A one_for_one supervisor restarts the dead +// child alone; past its restart budget it gives up and ends itself, and +// whoever monitors it gets a Down that says why. +// +// Run: ae run examples/02-actors/12-supervisor.ae +// See: std/supervise/README.md, docs/actor-concurrency.md#supervisors + +import std.actors +import std.supervise + +message Divide { + a: int + b: int +} +message Handled {} +message Watch { who: actor_ref } + +divide(a: int, b: int) -> int { + if b == 0 { + panic("division by zero") + } + return a / b +} + +actor Calculator { + state handled = 0 + receive { + Divide(a, b) -> { + println("calculator: ${a} / ${b} = ${divide(a, b)}") + handled = handled + 1 + } + Handled() -> { reply handled } + } +} + +actor Watcher { + receive { + Watch(who) -> { _id = actors.monitor(who) } + Down(ref, reason) -> { println("watcher: the supervisor ended: ${reason}") } + } +} + +main() { + sup = supervise.start(strategy: "one_for_one", max_restarts: 2, within_secs: 60) { + supervise.child("calculator", || -> spawn(Calculator())) + } + watcher = spawn(Watcher()) + watcher ! Watch { who: sup } + calc = supervise.which(sup, "calculator") + + calc ! Divide { a: 10, b: 2 } + calc ! Divide { a: 1, b: 0 } // panics: the calculator is restarted + wait_for_idle() + calc ! Divide { a: 9, b: 3 } // the same ref reaches the new one + wait_for_idle() + int n = calc ? Handled {} + println("restarts: ${supervise.restarts(sup)}, handled since: ${n}") + + calc ! Divide { a: 1, b: 0 } // restart 2 + wait_for_idle() + calc ! Divide { a: 1, b: 0 } // a third crash within the minute: it gives up + wait_for_idle() +} + +// Output: +// calculator: 10 / 2 = 5 +// calculator: 9 / 3 = 3 +// restarts: 1, handled since: 1 +// watcher: the supervisor ended: supervisor gave up: more than 2 restarts within 60 s (calculator: division by zero) diff --git a/examples/README.md b/examples/README.md index dcd38e97c..77e026dff 100644 --- a/examples/README.md +++ b/examples/README.md @@ -52,8 +52,9 @@ behind each step; every row below links the part it builds on. | [07-worker-pool.ae](02-actors/07-worker-pool.ae) | fan-out to workers, fan-in to a collector | [Actor runtime](../docs/actor-concurrency.md) | | [08-state-machine.ae](02-actors/08-state-machine.ae) | an enum as state, a transition table | [Enums](../docs/language-reference.md#enums) | | [09-timeouts.ae](02-actors/09-timeouts.ae) | `receive ... after`, a watchdog | [Actor timeouts](../docs/actor-concurrency.md#actor-timeouts) | -| [10-crash-isolation.ae](02-actors/10-crash-isolation.ae) | a panic stays in its actor; supervision today | [Panics](../docs/actor-concurrency.md#panic-and-stack-traces) | +| [10-crash-isolation.ae](02-actors/10-crash-isolation.ae) | a panic stays in its actor, and the others go on | [Panics](../docs/actor-concurrency.md#panic-and-stack-traces) | | [11-sharded-map.ae](02-actors/11-sharded-map.ae) | keys split over owner actors instead of a lock | [Sharded actor map](../docs/design/sharded-actor-map.md) | +| [12-supervisor.ae](02-actors/12-supervisor.ae) | a crashed actor restarted under the same ref, and a supervisor that gives up | [Supervisors](../docs/actor-concurrency.md#supervisors) | ## 3. The standard library diff --git a/include/libaether.h b/include/libaether.h index 3f2a6ef75..8dfca5171 100644 --- a/include/libaether.h +++ b/include/libaether.h @@ -5,6 +5,14 @@ * plugin systems, language gateways) link against to drive * Aether-generated `.so` / `.a` artifacts. * + * Compatibility (#2957, docs/abi-policy.md): this surface is versioned by + * AETHER_HOST_ABI below. Within a level it only grows: new functions, + * new constants, new outcomes; a removal, a layout change of a struct + * here or a change of meaning bumps the level. A library records the + * level it was built at in its catalog (aether_lib_meta, host_abi; none + * is level 0, Aether 0.856 and before). A deprecated name stays, accepted + * and documented as such, until the next level removes it. + * * Today this surface exposes the per-call resource caps from issue * #343 (memory ceiling + wall-clock deadline). Future additions * land as additive symbols — never break the existing names. @@ -20,6 +28,9 @@ #include +/* The host ABI level of this header (#2957). */ +#define AETHER_HOST_ABI 1 + #ifdef __cplusplus extern "C" { #endif @@ -44,29 +55,193 @@ extern "C" { * allocations are gated. Existing ones drain via their normal * free paths and decrement the counter. * + * Inside an export, a refused allocation (or one the allocator could + * not satisfy) also ends the call at its next safe point or its return, + * with the outcome AETHER_MEMORY_CAP (or AETHER_OUT_OF_MEMORY) and the + * zero value, its defers run (#2956); see aether_last_outcome(). + * * Thread-safe: relaxed atomic store. */ AETHER_API void aether_set_memory_cap(uint64_t bytes); -/* Per-thread wall-clock deadline. After this call, every - * codegen-emitted tripwire (at every loop head in `--emit=lib` - * builds) checks `now >= deadline_at`. On positive trip the - * sticky flag flips, and `aether_deadline_tripped()` returns 1 - * for every subsequent call until you arm a fresh deadline. +/* Per-thread wall-clock deadline. After this call, every safe point + * (every loop head in `--emit=lib` builds, and every 4096 values a + * JSON parse reads) checks `now >= deadline_at`. Inside an export the + * first one past the deadline ends the call (#2956): the export returns + * its zero value with the outcome AETHER_DEADLINE, its defers and owned + * locals released on the way, and a `try` in the script does not catch + * it. Code that runs no safe point is not cut: straight-line code, + * recursion without a loop, and C-backed std operations other than the + * JSON parse (docs/emit-lib.md, "Per-call resource caps"). On a trip the + * sticky flag flips, and `aether_deadline_tripped()` returns 1 for every + * subsequent call until you arm a fresh deadline. * * Pass `0` to disable. Calling with a non-zero value clears the * sticky flag (a fresh deadline is a fresh chance). */ AETHER_API void aether_set_call_deadline(int64_t ms); -/* Codegen-tripwire target. Returns 1 when the per-thread deadline - * has passed OR when `__aether_abort_call` was explicitly invoked - * since the last `aether_set_call_deadline`. Returns 0 otherwise. +/* Returns 1 when the per-thread deadline has passed OR when + * `__aether_abort_call` was explicitly invoked since the last + * `aether_set_call_deadline`. Returns 0 otherwise. * - * Embedders typically don't call this directly — the codegen - * emits checks at every loop head. The host's job is to set the - * deadline before invoking the entry function and inspect any - * error string the entry returns. */ + * Embedders typically don't call this directly: aether_last_outcome() + * says AETHER_DEADLINE for a call the deadline ended. The host's job is + * to set the deadline before invoking the entry function. */ AETHER_API int aether_deadline_tripped(void); +/* How the last call into an export on this thread ended (#2956). Every + * aether_ export runs inside an execution frame: a panic the export + * does not catch ends the call, not the process, and the export returns + * the zero value of its result (NULL for a string, which the release + * accepts). A zero value with an outcome other than AETHER_OK is not a + * result; release only what an AETHER_OK call returned. That holds for a + * call that ran to its return with a stop noted after its last safe point + * (an allocation it survived, a JSON parse that stopped, the deadline's + * flag) too: it returns the zero value, and the library releases what it + * would have returned, a string or a struct's or closure's parts (#3388). + * A `ptr` result owns nothing, so what it pointed to is not released, as on + * a panic. The library is usable again at once; nothing done before the + * failure is rolled back. + * + * New outcomes may be added within a host ABI level: a host treats any + * outcome it does not know as a failure, as it does AETHER_PANICKED. + * AETHER_INCOMPATIBLE (#2957) means the library was made for another + * runtime than the one it found (a --shared-runtime library, or generated C + * compiled into a host, against a runtime of another version): none of its + * code ran, every call returns its zero value with it, and + * aether_last_outcome_text() names both runtimes. + * + * Nested calls (an export calls a host service, which calls an export) + * each have their own outcome, and the outer call's caller info, + * deadline and sandbox grants are back when the inner one returns, on + * every exit. The frame costs a context save per call (about 5.7 ns on + * Windows MinGW for an export that calls a function; none for one that + * can neither panic, allocate, loop nor call anything): docs/emit-lib.md, + * "Failures and nested calls at the boundary". */ +#ifndef AETHER_OK +#define AETHER_OK 0 /* returned normally */ +#define AETHER_PANICKED 1 /* a panic the export did not catch */ +#define AETHER_DEADLINE 2 /* its deadline passed (see above) */ +#define AETHER_CANCELLED 3 /* cancelled from another thread */ +#define AETHER_MEMORY_CAP 4 /* an allocation went past the memory cap */ +#define AETHER_OUT_OF_MEMORY 5 /* the allocator could not satisfy one */ +#define AETHER_INCOMPATIBLE 6 /* the library cannot run on the runtime it found (#2957) */ +#endif +AETHER_API int aether_last_outcome(void); + +/* The last outcome's reason: "" for AETHER_OK, the panic's message (cut at + * 1023 bytes) or a fixed description otherwise. Borrowed: valid until the + * next call into the library on this thread. */ +AETHER_API const char* aether_last_outcome_text(void); + +/* Cancellation from any thread (#2956). aether_exec_token() names the call + * running on the calling thread or, outside one, the next call that thread + * makes; pass it to another thread, whose aether_exec_cancel(token) ends + * that call at its next safe point (a loop head of the + * library's code, or every few thousand units of a long std operation) + * with AETHER_CANCELLED. A call nested inside the cancelled one ends first, + * then the cancelled one once the host code between them returns. A token + * stays valid for the process; cancelling a call that already ended does + * nothing. Returns 0, or -1 for a token that names nothing. A deadline set + * with aether_set_call_deadline ends a call the same way, with + * AETHER_DEADLINE: the export returns its zero value, its defers and owned + * locals released, and a `try` in it does not catch the interruption. */ +#ifndef AETHER_EXEC_TOKEN_DEFINED +#define AETHER_EXEC_TOKEN_DEFINED +typedef struct AetherExecToken { void* cell; uint64_t serial; } AetherExecToken; +#endif +AETHER_API AetherExecToken aether_exec_token(void); +AETHER_API int aether_exec_cancel(AetherExecToken token); + +/* Execution accounts (#2954): a memory allowance and a deadline that belong + * to one logical execution, a script that yields to its host and resumes + * later while others run on the same thread in between, not to the thread + * or the call. + * + * aether_exec_account_new makes one: NULL when out of memory, or when + * limits->parent is no live account of these accounts (one the host freed, + * or one a copy that does not share them made: refused rather than + * dropped, #3412, so a child is never wider than its parent); + * aether_exec_use puts it in use on the + * calling thread (NULL: none) and gives back the one it replaces, which the + * host puts back when it switches scripts. While an account is in use: + * - every cap-aware allocation (strings, collections, JSON, the std + * buffers) is charged to it and to its parent chain; one that would take + * any of them past its memory_bytes is refused, the call ends at its next + * safe point with AETHER_MEMORY_CAP, and aether_exec_account_tripped has + * AETHER_ACCOUNT_TRIPPED_MEMORY; + * - an export's call has the account's deadline, the earliest on its + * chain and the call deadline's if that is earlier: ELAPSED counts wall + * time from aether_exec_account_new, so time suspended counts; ACTIVE + * counts only the time spent in calls under the account, once however + * calls nest (#3409). Past it the + * call ends with AETHER_DEADLINE, and the account has + * AETHER_ACCOUNT_TRIPPED_DEADLINE, which no later call clears. + * A block's bytes stay charged to the account that allocated it until the + * block is freed, wherever that happens: in a call under another account, or + * in the host outside any call. A parent can only narrow: what a child + * holds counts against its parent too. The process cap + * (aether_set_memory_cap) stays the outer ceiling. + * + * Threads: an account is in use on one thread at a time; aether_exec_use + * returns -1 for one in use on another thread. It may be used on another + * thread later. Allocations made on other threads (an actor's handler) are + * not charged to it, nor those of an actor stepped on this thread while it + * is in use (a send's inline step). aether_exec_account_free detaches its + * blocks, which then count only against the process cap (its ancestors are + * credited with what they were charged for them), and puts its children + * under its own parent (#3408). + * Nothing is charged and nothing more is paid while no account exists. + * + * Ownership: an account is the host's, from new to free; the runtime keeps + * no reference to one after free, and none to `limits` after new: freed + * while in use on the calling thread, it is in use no more; in use on + * another thread, nothing more is charged to it there, and that thread + * lets go of it at its next allocation, call or aether_exec_use; an + * execution that began under it puts no account back when it returns + * (#3407). `previous` is written (when not NULL) and not kept. + * + * Runtime copies: a library built without --shared-runtime has a runtime of + * its own, and the accounts are still one per process. Every copy works + * through the accounts of the first loaded image that has them (the same + * rule as the sandbox's levels), found once, at its first account call + * or execution: one lock, one table, each thread's account in use in one + * place, so an account is charged whichever copy allocates and is refused + * on another thread whichever copy asks. The copy that owns them must stay + * loaded while any other runs: unloading it first is not supported; any + * other copy may be unloaded, and leaves them as it goes (#3411). Each copy + * is found by a record in its image, so a program's own copy is found + * though its executable exports nothing (Windows, Linux without -rdynamic; + * #3445). Records are read on Windows, macOS, Linux and FreeBSD, NetBSD and + * OpenBSD; elsewhere (Cygwin, illumos, DragonFly) copies look each other up + * by name in the global scope, and where two do not find each other, a copy + * refuses (-1) to put in use an account the other made. + * Copies of different + * layouts (another accounts version) never share: a copy that finds the + * other's says so once on stderr, its aether_exec_account_new returns NULL + * and its aether_exec_use -1 for an account, its allocations are charged to + * none, and it reads nothing of the other's but the version and sizes. */ +#ifndef AETHER_EXEC_ACCOUNT_DEFINED +#define AETHER_EXEC_ACCOUNT_DEFINED +typedef struct AetherExecAccount AetherExecAccount; +typedef struct { + uint64_t memory_bytes; /* the live bytes it may hold; 0: no limit of its own */ + int64_t deadline_ms; /* 0: no deadline of its own */ + int deadline_counts; /* AETHER_DEADLINE_ELAPSED or AETHER_DEADLINE_ACTIVE */ + AetherExecAccount* parent; /* NULL, or the account its charges also go to */ +} AetherExecLimits; +#define AETHER_DEADLINE_ELAPSED 0 /* wall time since the account was made */ +#define AETHER_DEADLINE_ACTIVE 1 /* time spent in executions under it */ +#endif +#ifndef AETHER_ACCOUNT_TRIPPED_DEADLINE +#define AETHER_ACCOUNT_TRIPPED_DEADLINE 1 +#define AETHER_ACCOUNT_TRIPPED_MEMORY 2 +#endif +AETHER_API AetherExecAccount* aether_exec_account_new(const AetherExecLimits* limits); +AETHER_API void aether_exec_account_free(AetherExecAccount* account); +AETHER_API int aether_exec_use(AetherExecAccount* account, AetherExecAccount** previous); +AETHER_API uint64_t aether_exec_account_used(const AetherExecAccount* account); +AETHER_API int aether_exec_account_tripped(const AetherExecAccount* account); + #ifdef __cplusplus } #endif diff --git a/new_changelogs/20261027T211042Z-changed-2600-migrating-p256-ecdsa-sign.md b/new_changelogs/20261027T211042Z-changed-2600-migrating-p256-ecdsa-sign.md new file mode 100644 index 000000000..67951779f --- /dev/null +++ b/new_changelogs/20261027T211042Z-changed-2600-migrating-p256-ecdsa-sign.md @@ -0,0 +1,17 @@ +- **Migrating: `p256.ecdsa_sign` takes no nonce (#2600).** The nonce is + derived by RFC 6979 from the key and the digest, so the parameter is + gone, and so is the `k` argument of `tls13_cert.sign_client_cert_verify_ecdsa_p256` + and `sign_server_cert_verify_ecdsa_p256`. Drop the argument and the code + that drew it: + + ```aether + k = random_32_bytes() + r, s = p256.ecdsa_sign(priv, digest, k) // before + r, s = p256.ecdsa_sign(priv, digest) // now: the same digest gives the same signature + ``` + + `ecdsa_sign` returns `(null, null)` for a private key outside [1, n-1] + (check one with `p256.private_key_valid(priv)`), and `ecdh` and + `scalar_mult` return null for a public point that is not on the curve + (`p256.public_key_valid(x, y)`). The digest may be any length: its leftmost + 256 bits are signed, as FIPS 186-5 says. diff --git a/new_changelogs/20261027T211042Z-security-2600-p256-constant-time.md b/new_changelogs/20261027T211042Z-security-2600-p256-constant-time.md new file mode 100644 index 000000000..6da195d2d --- /dev/null +++ b/new_changelogs/20261027T211042Z-security-2600-p256-constant-time.md @@ -0,0 +1,21 @@ +- **P-256 signing and ECDH are constant time, with RFC 6979 nonces + (#2600, #1298, #3291).** `std.cryptography.p256` ran on the variable-time + `std.bignum`: every bit of the private key and of the nonce chose a branch, + and the pure TLS 1.3 server and the mTLS client signed with it. It now runs + on the new `std.cryptography.ecc`, fixed-width Montgomery field arithmetic + on 32-bit limbs with no allocation, the complete addition formulas of + Renes, Costello and Batina, and a fixed 4-bit window scalar multiplication + that reads every table entry. `ecdsa_sign` derives its nonce by RFC 6979 + (HMAC-DRBG over SHA-2) and takes no nonce from its caller. A new CI job, + "Crypto constant-time (Valgrind)", runs `tests/integration/crypto_ct_valgrind` + with the secret inputs marked undefined for memcheck and fails on any + branch or memory index that depends on them; on the old code it reported + 68. `ecdh`, `scalar_mult` and `ecdsa_verify` now refuse a public point off + the curve or with a coordinate of p or more: before, `ecdh` computed with + any point it was given, the invalid-curve attack (#3291; all 355 + Wycheproof `ecdh_secp256r1_ecpoint` cases now run, 16 of them failed + before). Per operation, with the default `ae build`: sign 415 us (was + 4050), verify 470 us (was 7850), ECDH 365 us (was 3900), so the + Wycheproof P-256 ECDSA drivers sweep all their vectors. The TLS client's + random-bytes helper also returned zeros when the CSPRNG failed, which would + have made its key shares predictable; it fails the handshake now. diff --git a/new_changelogs/20261027T215552Z-added-2974-death-notices.md b/new_changelogs/20261027T215552Z-added-2974-death-notices.md new file mode 100644 index 000000000..24a72191b --- /dev/null +++ b/new_changelogs/20261027T215552Z-added-2974-death-notices.md @@ -0,0 +1,16 @@ +- **An actor can be ended, and its death watched.** `import std.actors`: + `actors.kill(ref)` ends an actor from anywhere and `actors.stop()` ends the + one whose handler calls it, each once the running handler returns; a panic + ends it as before. `actors.monitor(ref)` asks for one `Down { ref, reason }` + when `ref` ends (`"normal"`, `"killed"`, or the panic's message), at once + for one that has ended already, and `actors.demonitor(id)` drops it, one + already on its way included. `Down` is declared by the compiler (a + program's own `message Down` is refused) and handled in a + `Down(ref, reason)` arm. A death is finalized on the thread that owns the + actor's mailbox while it steps nothing, so a `Down` never comes while its + handler still runs and comes after the last messages it sent; what was + queued for it, and every send to it after, is dropped and counted + (`actors.dead_sends()`, `scheduler_dead_sends()`); an ask its killed + handler waits in ends at once with "the asking actor was killed". On wasi, + where a panic ends the program, `actors.monitor` is refused at compile + time (#2974). diff --git a/new_changelogs/20261027T215553Z-fixed-3298-actor-thread-panic-inbox.md b/new_changelogs/20261027T215553Z-fixed-3298-actor-thread-panic-inbox.md new file mode 100644 index 000000000..c27bd25ba --- /dev/null +++ b/new_changelogs/20261027T215553Z-fixed-3298-actor-thread-panic-inbox.md @@ -0,0 +1,6 @@ +- **An actor with a thread of its own that panics gives up its inbox.** Its + thread left at the death and the messages still waiting in its inbox were + never dropped or credited, so `scheduler_wait()`, and with it the + shutdown, waited for them forever. The death is now finalized on that + thread at the end of its loop, the inbox with the mailbox, and a sender + that pushes after that drain drops what it pushed (#3298). diff --git a/new_changelogs/20261027T220105Z-added-2974-links.md b/new_changelogs/20261027T220105Z-added-2974-links.md new file mode 100644 index 000000000..a06000662 --- /dev/null +++ b/new_changelogs/20261027T220105Z-added-2974-links.md @@ -0,0 +1,6 @@ +- **Linked actors share their deaths.** `actors.link(ref)` ties two actors + both ways: when either ends, the other ends too with the same reason, unless + the reason is `"normal"`; one that called `actors.trap_exits(true)` gets a + `Down` instead and lives on. `actors.unlink(ref)` removes the tie, and a + link to an actor that has ended already takes its death at once. A failure + so climbs a tree of links up to the first actor that traps exits (#2974). diff --git a/new_changelogs/20261027T220500Z-security-2600-digest-and-keys-off-strings.md b/new_changelogs/20261027T220500Z-security-2600-digest-and-keys-off-strings.md new file mode 100644 index 000000000..42c30a9a6 --- /dev/null +++ b/new_changelogs/20261027T220500Z-security-2600-digest-and-keys-off-strings.md @@ -0,0 +1,12 @@ +- **SHA-2 digests and fresh keys no longer pass through a string + (#2600, #3293).** `sha2.final_bytes` (and so every HMAC, HKDF and the + RFC 6979 nonce derivation) copied the digest into a string and back with + `bytes.copy_from_string`, whose C side asks whether its source is a + string by reading the source's first four bytes: a branch on HMAC's + secret state, which the constant-time test found in P-256 signing, and a + misread of any digest that happens to start like a string header. The + digest now stays in its byte buffer. The TLS client and server key shares + and randoms and the ML-KEM seeds went the same way through + `cryptography.random_bytes`; they use the new + `cryptography.random_buffer(n) -> (bytes, err)`, which fills a `std.bytes` + buffer straight from the OS CSPRNG. diff --git a/new_changelogs/20261027T220832Z-changed-2974-coop-panic-barrier.md b/new_changelogs/20261027T220832Z-changed-2974-coop-panic-barrier.md new file mode 100644 index 000000000..bee6f496d --- /dev/null +++ b/new_changelogs/20261027T220832Z-changed-2974-coop-panic-barrier.md @@ -0,0 +1,8 @@ +- **A panic on the cooperative scheduler ends the actor, not the program.** + The cooperative scheduler (Emscripten, esp32, `-DAETHER_NO_THREADING`) + stepped actors with no panic barrier, so a handler's panic ended the + program. Both schedulers now step inside one barrier + (`runtime/actors/aether_step_barrier.h`): the actor ends, the message and + the ask it held are dropped and failed, its death is finalized and its + watchers get a `Down`. On wasi, which has no setjmp, a panic still ends + the program, and watching a death is refused at compile time (#2974). diff --git a/new_changelogs/20261027T222134Z-added-2974-restart-in-place.md b/new_changelogs/20261027T222134Z-added-2974-restart-in-place.md new file mode 100644 index 000000000..78f03f7d4 --- /dev/null +++ b/new_changelogs/20261027T222134Z-added-2974-restart-in-place.md @@ -0,0 +1,17 @@ +- **An ended actor can be restarted in place, under the same ref.** + `actors.rebirth_begin(child)` claims an ended child once its death is + finalized and no message of it is in flight (0 means "not yet, try again"; + -1 that it cannot be), the child's factory then runs, and its spawn of the + child's actor type takes the child's own block: the old state destroyed, + the fields zeroed, the state initializers run, `incarnation` one more, and + the child alive again when the generated spawn's new last line + (`scheduler_spawn_ready`) runs. `actors.rebirth_end(child)` says whether it + is, or undoes an unfinished restart. Messages queued at the death and sends + while the child is dead are dropped once and counted; sends after the + spawn reach the new incarnation, and a ref held by a client keeps working. + `actors.incarnation(ref)` counts the restarts. This is what `std.supervise` + restarts children with (#2974). +- **A panic while an actor's full mailbox was being made room in no longer + strands messages.** The core went on putting the rest of a run of messages + into the dead actor's mailbox, where nothing handled or credited them; it + now drops them, counted with the death's (#2974). diff --git a/new_changelogs/20261027T223000Z-changed-2600-migrating-ecdsa-sign-other-curves.md b/new_changelogs/20261027T223000Z-changed-2600-migrating-ecdsa-sign-other-curves.md new file mode 100644 index 000000000..ab23a2422 --- /dev/null +++ b/new_changelogs/20261027T223000Z-changed-2600-migrating-ecdsa-sign-other-curves.md @@ -0,0 +1,16 @@ +- **Migrating: `p384`, `p521` and `secp256k1`'s `ecdsa_sign` take no nonce + either (#2600).** As for `p256`: drop the third argument, and the + signature of a digest under a key is always the same one (RFC 6979). + + ```aether + r, s = p384.ecdsa_sign(priv, digest, k) // before + r, s = p384.ecdsa_sign(priv, digest) // now + ``` + + P-521 also changes how the digest is read: the leftmost 521 bits of the + buffer, as FIPS 186-5 says, so a SHA-512 digest is passed as its 64 + bytes. The old API took a 66-byte buffer as a number, and callers padded + the digest with two zero bytes; that buffer is now read as a 528-bit + digest and truncated, which signs a different number. `ecdsa_verify` + reads the digest the same way. secp256k1's `s` is as computed, not in + Bitcoin's low-S form. diff --git a/new_changelogs/20261027T223000Z-security-2600-p384-p521-secp256k1-constant-time.md b/new_changelogs/20261027T223000Z-security-2600-p384-p521-secp256k1-constant-time.md new file mode 100644 index 000000000..f61acf5d1 --- /dev/null +++ b/new_changelogs/20261027T223000Z-security-2600-p384-p521-secp256k1-constant-time.md @@ -0,0 +1,13 @@ +- **P-384, P-521 and secp256k1 are constant time too, with RFC 6979 nonces + (#2600, #3291).** `std.cryptography.p384`, `p521` and `secp256k1` run on + `std.cryptography.ecc` like P-256: signing, ECDH and the scalar + multiplications no longer branch on the key or the nonce, and the + constant-time test covers each curve's signing and ECDH. ECDH refuses a + point off the curve (#3291): every case of Wycheproof's + `ecdh_secp384r1_ecpoint`, `ecdh_secp521r1_ecpoint` and + `ecdh_secp256k1_webcrypto` runs. Per operation with the default + `ae build` (was, on `std.bignum`): P-384 sign 1230 us (7700), verify 1410 + (15500), ECDH 1110 (7400); P-521 sign 3040 (15000), verify 3400 (28600), + ECDH 2920 (14800); secp256k1 sign 340 (3800), verify 370 (7850), ECDH 285 + (3750). The Wycheproof ECDSA drivers for the three curves now sweep every + vector instead of every tenth (8 s, 18 s and 4 s at the -O0 of `ae run`). diff --git a/new_changelogs/20261027T224529Z-added-2974-supervise.md b/new_changelogs/20261027T224529Z-added-2974-supervise.md new file mode 100644 index 000000000..ce29ebcc9 --- /dev/null +++ b/new_changelogs/20261027T224529Z-added-2974-supervise.md @@ -0,0 +1,17 @@ +- **`std.supervise`: supervisors that restart actors.** `supervise.start` + takes a block of children (`supervise.child(name, || -> spawn(Worker()))`, + or `supervise.child_restart` with a restart kind: `"permanent"`, + `"transient"` or `"temporary"`), starts each from its factory before it + returns, and links them. A child that dies is restarted in place, under the + ref its siblings and clients already hold, by the strategy + (`"one_for_one"`, `"one_for_all"`, `"rest_for_one"`); past + `max_restarts` restarts within `within_secs` seconds the supervisor kills + its children and ends with a reason that says why, so its own watchers, or + a supervisor above it, take the failure. `supervise.which(sup, name)` gives + a child's ref and `supervise.restarts(sup)` the restarts made. The runtime's + spawn capture (`aether_actor_capture_begin` and `aether_actor_capture_end`) + tells which actor a factory started (#2974). +- **A module's actor can handle `Down`.** The runtime's `Down` message was + taken for one of the program's own declarations, so a module's `Down(ref, + reason)` arm did not see its fields' types; it is now visible to modules as + any runtime declaration is (#2974). diff --git a/new_changelogs/20261027T231500Z-security-2600-rsa-private-key-constant-time.md b/new_changelogs/20261027T231500Z-security-2600-rsa-private-key-constant-time.md new file mode 100644 index 000000000..8bda7e29d --- /dev/null +++ b/new_changelogs/20261027T231500Z-security-2600-rsa-private-key-constant-time.md @@ -0,0 +1,13 @@ +- **RSA private-key operations are constant time and blinded (#2600).** + `std.cryptography.rsa`'s `raw_decrypt`, and through it `decrypt_pkcs1`, + `decrypt_oaep`, `sign_pkcs1` and `sign_pss`, ran std.bignum's + variable-time `mod_pow` on the private exponent. They now use Montgomery + arithmetic on fixed-width limb vectors and a fixed 4-bit window + exponentiation that reads every table entry, with the base blinded by a + fresh random r. PKCS#1 v1.5 and OAEP decryption check the padding without + an early exit (the Bleichenbacher and Manger timing oracles). A 2048-bit + private-key operation takes about 11 ms with the default `ae build` (27 ms + before). New `rsa.key_from_bytes(n, e, d)` builds a key from big-endian + byte strings without putting d through std.bignum; `raw_decrypt` and the + signers return null (or an error) for a public-only key instead of + crashing. The constant-time test covers all four private-key paths. diff --git a/new_changelogs/20261027T231632Z-added-2974-mailbox-bound.md b/new_changelogs/20261027T231632Z-added-2974-mailbox-bound.md new file mode 100644 index 000000000..8315d6080 --- /dev/null +++ b/new_changelogs/20261027T231632Z-added-2974-mailbox-bound.md @@ -0,0 +1,10 @@ +- **An actor's mailbox can be bounded: `@mailbox(bound: N)`.** Mailboxes stay + unbounded by default, so a send never blocks. With a bound the actor takes + at most N messages it has not handled yet, wherever they wait (a core's + queue, a sender's deferred sends, its overflow, its mailbox), counted + exactly. Past it a send is refused at once, never blocked on: the new + `try_send(ref, Msg { ... })` returns `"the mailbox is full"` (or `"the + actor is dead"`, and `""` when it sent), an ask fails with `"the mailbox + is full"`, and a plain `!` is dropped, counted by + `actors.full_drops(ref)` and reported once on stderr. A `Down` always gets + in. Both schedulers (#2974). diff --git a/new_changelogs/20261027T233259Z-changed-2974-delivery-contract-docs.md b/new_changelogs/20261027T233259Z-changed-2974-delivery-contract-docs.md new file mode 100644 index 000000000..1d8e3d7ab --- /dev/null +++ b/new_changelogs/20261027T233259Z-changed-2974-delivery-contract-docs.md @@ -0,0 +1,9 @@ +- **The actor delivery contract is written down.** docs/actor-concurrency.md + says what a send promises: each sender's messages in order, at most once + (every message not handled released and counted), unbounded by default + because a blocked sender can deadlock the scheduler, and a bound when the + actor asks for one. docs/structured-concurrency.md records Phase 1 + (death notices, links, restart in place, std.supervise) as shipped and + where it differs from the proposal; Phase 2 stays a proposal. A new + example, examples/02-actors/12-supervisor.ae, restarts a crashed actor + under its ref and shows a supervisor giving up (#2974). diff --git a/new_changelogs/20261028T001000Z-added-1298-x448-agree.md b/new_changelogs/20261028T001000Z-added-1298-x448-agree.md new file mode 100644 index 000000000..875625ee4 --- /dev/null +++ b/new_changelogs/20261028T001000Z-added-1298-x448-agree.md @@ -0,0 +1,6 @@ +- **`x448.agree(scalar, u) -> (shared, err)` refuses an all-zero shared + secret, as `x25519.agree` does (#1298).** X448 had only the raw RFC 7748 + function, which returns the zeros a small-order peer point produces, so + the two key agreements answered that case differently. `agree` is the + key-agreement entry point for both curves now; `x448.x448` stays the raw + function. `x448` also returns null for a scalar or u that is not 56 bytes. diff --git a/new_changelogs/20261028T001000Z-security-2600-eddsa-x448-constant-time.md b/new_changelogs/20261028T001000Z-security-2600-eddsa-x448-constant-time.md new file mode 100644 index 000000000..bfb7a64d4 --- /dev/null +++ b/new_changelogs/20261028T001000Z-security-2600-eddsa-x448-constant-time.md @@ -0,0 +1,12 @@ +- **Ed25519, Ed448 and X448 are constant time (#2600, #1298).** Their + secret-key operations (EdDSA public-key derivation and signing, the X448 + ladder) ran on the variable-time `std.bignum`. They now run on + `std.cryptography.ecc`'s fixed-width field: the complete Edwards formulas + of Bernstein, Birkner, Joye, Lange and Peters with the fixed-window + scalar multiplication that reads every table entry, S = r + k a mod L on + fixed-width limbs, and an X448 ladder of 448 masked swaps. Per operation + with the default `ae build` (was, on `std.bignum`): Ed25519 sign 665 us + (7850), verify 410 us (8350); Ed448 sign 2.3 ms (23.2), verify 1.6 ms + (25.5); X448 1.0 ms (6.6). The Wycheproof Ed25519 and X448 drivers sweep + every case (they sampled every 5th and 25th), and the constant-time test + covers EdDSA signing, X25519 and X448. diff --git a/new_changelogs/20261028T003000Z-security-2600-shake-output-off-strings.md b/new_changelogs/20261028T003000Z-security-2600-shake-output-off-strings.md new file mode 100644 index 000000000..afba17dd4 --- /dev/null +++ b/new_changelogs/20261028T003000Z-security-2600-shake-output-off-strings.md @@ -0,0 +1,5 @@ +- **SHA-3 and SHAKE output no longer passes through a string (#2600).** + `sha3.final_bytes` copied the digest into a string and back, as + `sha2.final_bytes` did; Ed448 signing runs its secret scalar and nonce + through SHAKE256, and the constant-time test reported the string's + header check reading them. The output now stays in its byte buffer. diff --git a/new_changelogs/20261028T010000Z-security-2600-aes-ghash-constant-time.md b/new_changelogs/20261028T010000Z-security-2600-aes-ghash-constant-time.md new file mode 100644 index 000000000..80ebaea43 --- /dev/null +++ b/new_changelogs/20261028T010000Z-security-2600-aes-ghash-constant-time.md @@ -0,0 +1,13 @@ +- **AES and GHASH are constant time, and AES-GCM is 15 times faster + (#2600, #1298).** The AES core indexed a 256-byte S-box with key- and + data-dependent bytes, which leaks the key through the cache; it is now + the 64-bit bitsliced AES of BearSSL's `aes_ct64` (the Boyar-Peralta S-box + circuit, four blocks per pass), about 185 MB/s in CTR mode. GHASH's + GF(2^128) multiply branched on the bits of the state and of H; it is a + masked shift-and-add on 64-bit halves now, which took AES-128-GCM from + 1.5 MB/s to 23 MB/s. Every AEAD tag check (GCM, CCM, EAX, OCB, + ChaCha20-Poly1305) and the AES key-wrap integrity check compare all bytes + and declassify one verdict. The constant-time test covers AES-128/256, + AES-GCM seal and open, ChaCha20-Poly1305 seal and open, Poly1305, + HMAC-SHA-256, HKDF and SHA-256 with their keys secret, which is the + Poly1305 audit #1298 asked for. diff --git a/new_changelogs/20261028T020000Z-added-1298-tls-backend-select.md b/new_changelogs/20261028T020000Z-added-1298-tls-backend-select.md new file mode 100644 index 000000000..1b4651e37 --- /dev/null +++ b/new_changelogs/20261028T020000Z-added-1298-tls-backend-select.md @@ -0,0 +1,14 @@ +- **Choose the TLS backend per HTTPS request and per server (#1298).** + `client.set_tls_backend(req, "aether" | "openssl" | "")` and + `http.server_set_tls_backend(server, ...)` pick the pure-Aether TLS 1.3 + stack or OpenSSL explicitly; `""` keeps the default, which is unchanged: + OpenSSL where it is built in, the pure stack where it is not, and + `AETHER_PURE_TLS=1` to move a whole process. An explicit choice wins over + the variable, and pooled client connections are kept apart per backend. + Both return an error string, before any network is touched, when the + backend cannot be used: `"openssl"` in a build without OpenSSL, `"aether"` + in a program that does not import `std.cryptography.tls13_client` (or + `tls13_server`), or an unknown name. `server_set_tls_backend` may come + before or after `server_set_tls`. `tests/integration/https_tls_backend_select` + runs every client backend against every server backend and checks which + one ran from each side's TLS 1.2 peer. diff --git a/new_changelogs/20261028T023924Z-fixed-3330-coop-ask-timeout.md b/new_changelogs/20261028T023924Z-fixed-3330-coop-ask-timeout.md new file mode 100644 index 000000000..0d198f856 --- /dev/null +++ b/new_changelogs/20261028T023924Z-fixed-3330-coop-ask-timeout.md @@ -0,0 +1,13 @@ +- **An ask on the cooperative runtime honours its timeout.** With no + threads (Emscripten, esp32, a `-DAETHER_NO_THREADING` build) an ask ignored + `timeout_ms`: a handler slower than the timeout still had its reply taken, + and an ask behind more than 100 000 queued messages ended "timed out" long + before its timeout, its polls counted instead of timed. The polls now stop + at the deadline and only there, and a reply that comes after it is + discarded as late, so the ask ends with "ask timed out after N ms", as it + does with threads. An ask that ended with its message still queued left + the message pointing at the asker's stack, and its handler later wrote its + reply there: the next ask from the same place received that reply (a + crash when the types differ). The message now forgets the asker when the + ask ends, and is handled as a plain send. The ask failure reasons test runs + on the cooperative runtime in CI (#3330). diff --git a/new_changelogs/20261028T030000Z-security-3303-tls13-chain-validation.md b/new_changelogs/20261028T030000Z-security-3303-tls13-chain-validation.md new file mode 100644 index 000000000..b98741857 --- /dev/null +++ b/new_changelogs/20261028T030000Z-security-3303-tls13-chain-validation.md @@ -0,0 +1,29 @@ +- **The pure TLS client validates the whole certificate path (#3303).** + This extends #3340 by Adil Alperen Çiftci, which made every issuing + certificate carry basicConstraints cA and, with a keyUsage, keyCertSign, + read into `LeafCert.is_ca`, `has_key_usage` and `key_cert_sign`. The rest + of RFC 5280 path validation that #3303 listed is here: pathLenConstraint, + extendedKeyUsage, name constraints, critical extensions the client does + not process, and the version and validity of everything above the leaf. + New + `tls13_cert.validate_chain(leaf, intermediates, anchors, at, purpose, + max_depth)` checks all of these (RFC 5280 section 6 as the Web PKI + profiles it), trying every candidate path with a bounded search, at `at` + ("YYYYMMDDHHMMSS", "" for now), for `PURPOSE_SERVER` or `PURPOSE_CLIENT`, + through at most `max_depth` intermediates that are not self-issued + (negative for the default, 8); the client uses it, and + `tls13_cert.chain_verify_path` now calls it for a server at the current + time. #3340's decoders also stop reading three malformed encodings + leniently: a keyUsage that is not a BIT STRING no longer counts as absent, + a repeated extension no longer lets the last copy win, and a BOOLEAN other + than 0x00 or 0xFF (a cA, or an extension's critical flag) is no longer + read as TRUE; each makes the certificate refused. + A basicConstraints field repeated or out of order (the last one won), or + a byte after the extension's SEQUENCE, + and a pathLenConstraint that is negative or carries a leading zero it does + not need is refused too; one of four bytes or more, too large to limit + any path, reads as unbounded, as OpenSSL reads it. + `tls13_cert.check_validity_at(c, at)` checks one certificate at a + given time. A self-signed certificate pinned as its own anchor is trusted + as it stands, whatever its basicConstraints say, as OpenSSL trusts it. + `std/http/README.md` lists what the pure client validates. diff --git a/new_changelogs/20261028T040000Z-added-3311-partial-chain.md b/new_changelogs/20261028T040000Z-added-3311-partial-chain.md new file mode 100644 index 000000000..b1c8ed58e --- /dev/null +++ b/new_changelogs/20261028T040000Z-added-3311-partial-chain.md @@ -0,0 +1,11 @@ +- **`client.set_partial_chain(req, 1)` trusts an intermediate CA on its + own.** Since #3310 a trust anchor must be a self-signed root on both TLS + backends, as on OpenSSL by default, so a CA file holding an intermediate + CA alone (a private PKI that hands out its issuing CA, or pinning one CA + below a public root) no longer anchors a path on the pure-Aether backend. + `set_partial_chain` is the explicit way to trust one, the same on both + backends (OpenSSL's `X509_V_FLAG_PARTIAL_CHAIN`, the pure client's + `tls13_cert.TRUST_PARTIAL_CHAIN`): any certificate in the trust store ends + a path, a trusted certificate that is not a root must still be a CA, and + peer and hostname verification stay on. Off by default; pooled connections + are kept apart by it (#3311). diff --git a/new_changelogs/20261028T040000Z-security-3308-der-length-overflow.md b/new_changelogs/20261028T040000Z-security-3308-der-length-overflow.md new file mode 100644 index 000000000..8679b1c3a --- /dev/null +++ b/new_changelogs/20261028T040000Z-security-3308-der-length-overflow.md @@ -0,0 +1,7 @@ +- **A stapled OCSP response can no longer stop the pure TLS client + (#3308).** `std.cryptography.tls13_cert`'s DER reader, under + `parse_ocsp_status` and the OCSP signature check, read a length of four + bytes into a 32-bit int: from 0x80000000 up it overflowed into a negative + length that passed the bounds check, and the next read indexed before the + buffer, a panic in the client on a response the server controls. Lengths + of more than three bytes (16 MB) are refused now. diff --git a/new_changelogs/20261028T043000Z-fixed-3307-cert-signature-algorithms.md b/new_changelogs/20261028T043000Z-fixed-3307-cert-signature-algorithms.md new file mode 100644 index 000000000..45c297e9f --- /dev/null +++ b/new_changelogs/20261028T043000Z-fixed-3307-cert-signature-algorithms.md @@ -0,0 +1,10 @@ +- **The pure TLS client verifies chains signed with SHA-384 and SHA-512 + RSA, ECDSA on P-521 or with SHA-512, and Ed25519 (#3307).** + `std.cryptography.tls13_cert` checked certificate (and OCSP responder) + signatures only for sha256WithRSAEncryption, ecdsa-with-SHA256 on P-256 + and ecdsa-with-SHA384 on P-384, so www.bing.com and www.microsoft.com + (sha384WithRSAEncryption, as many DigiCert chains) and any chain through a + P-521 or Ed25519 CA failed with "certificate does not chain to a trusted + anchor". RSA PKCS#1 v1.5 now takes SHA-256, SHA-384 or SHA-512; ECDSA + takes any of the three hashes on a P-256, P-384 or P-521 key; Ed25519 + (id-Ed25519) is verified too. diff --git a/new_changelogs/20261028T050000Z-added-3326-pure-server-keys.md b/new_changelogs/20261028T050000Z-added-3326-pure-server-keys.md new file mode 100644 index 000000000..e152fa493 --- /dev/null +++ b/new_changelogs/20261028T050000Z-added-3326-pure-server-keys.md @@ -0,0 +1,16 @@ +- **The pure-Aether TLS server signs with any key a certificate carries.** + It took ECDSA P-256 alone, so an RSA, P-384, P-521 or Ed25519 + certificate made a server that dropped every connection at the signature + step. `tls13_server.load_private_key` reads PKCS#8, SEC1 (after an + `openssl ecparam` EC PARAMETERS block) and PKCS#1 PEM; CertificateVerify + is signed with the key's own scheme (0x0403, 0x0503, 0x0603, Ed25519, or + RSA as rsa_pss_rsae_sha256, constant time and blinded) when the client's + signature_algorithms offers it, and a client that offers none of them gets + `handshake_failure` (`missing_extension` when it sent none). RSA keys under + 2048 bits are refused, as on the OpenSSL backend. The pure client offers + ecdsa_secp521r1_sha512 and verifies it. A signing failure now releases + the whole handshake state and sends `internal_error` (#3326). + Migration: `tls13_server.accept`'s private key is the handle + `load_private_key` returns (free it with `free_private_key`), not the raw + P-256 scalar; `parse_ec_private_key_pem` stays as the low-level P-256 + reader. diff --git a/new_changelogs/20261028T050000Z-security-1298-x509-limbo-path-validation.md b/new_changelogs/20261028T050000Z-security-1298-x509-limbo-path-validation.md new file mode 100644 index 000000000..3e078d5c3 --- /dev/null +++ b/new_changelogs/20261028T050000Z-security-1298-x509-limbo-path-validation.md @@ -0,0 +1,18 @@ +- **Path validation measured against x509-limbo and bettertls, and against + OpenSSL; what they found is fixed (#1298).** `std.cryptography.tls13_cert` + runs both corpora in Linux CI (`tests/conformance/x509_limbo`, pinned to a + commit and a SHA-256, fetched into a cache): all 9572 bettertls cases + agree, and `expected.txt` gives, for each limbo case that is skipped (CRLs, + ML-DSA) or does not agree, the reason and OpenSSL's verdict. The two TLS + backends accept the same chains except where OpenSSL accepts something + unsafe, which the pure backend refuses. Fixed on the way: path validation + accepted RSA keys under 2048 bits, DSA and P-192 keys, and EC keys with + explicit curve parameters; a wildcard SAN such as `*.example.com` passed a + CA's excluded `bar.example.com` (CVE-2025-61727, which OpenSSL 3.6.4 still + accepts); mailbox, URI, directoryName and unrecognised name-constraint + forms were ignored rather than applied or refused; malformed names or + constraints, and an empty permittedSubtrees, were read as well formed; + name-constraint checks had no bound (2048 subtrees against 2048 names is + four million comparisons; now 250000, as Go and rustls-webpki); `*.com` + matched every .com host; and a critical key identifier or authority + information access, or a CA with an empty subject, was accepted. diff --git a/new_changelogs/20261028T050001Z-fixed-3328-pure-server-check.md b/new_changelogs/20261028T050001Z-fixed-3328-pure-server-check.md new file mode 100644 index 000000000..7c2177624 --- /dev/null +++ b/new_changelogs/20261028T050001Z-fixed-3328-pure-server-check.md @@ -0,0 +1,8 @@ +- **`server_set_tls` on the pure-Aether backend checks the certificate and + key.** It returned "" without reading either file, so a missing file, an + unusable or encrypted key, or a key belonging to another certificate made + a server that started and then dropped every connection, with nothing + logged. It now loads both, checks they are a pair (the key signs a test + CertificateVerify the certificate's key verifies) and returns why it + cannot serve them, as the OpenSSL backend does; a refused + `server_set_tls_backend` keeps the server's earlier backend (#3328). diff --git a/new_changelogs/20261028T060000Z-fixed-3331-builder-ctx-defaults.md b/new_changelogs/20261028T060000Z-fixed-3331-builder-ctx-defaults.md new file mode 100644 index 000000000..50c8f1eaf --- /dev/null +++ b/new_changelogs/20261028T060000Z-fixed-3331-builder-ctx-defaults.md @@ -0,0 +1,9 @@ +- **A builder setter's defaulted parameters can be left out in its block.** + Inside a builder's trailing block `_ctx` is injected and hidden from the + caller, but the arity check counted it among the arguments the call + gives: `item("a")` for `item(_ctx, name, kind = "plain")` was refused + (E0200), and `item("b", "fancy")` was read as `_ctx = "b"` with `kind` + defaulted, every argument one place off. In a block the call now gives + the parameters after `_ctx` and the defaults fill the rest; a call that + passes the context itself (`_ctx`, `builder_context()`) or every + parameter passes it explicitly, as outside a block (#3331). diff --git a/new_changelogs/20261028T060000Z-security-3309-leaf-key-usage.md b/new_changelogs/20261028T060000Z-security-3309-leaf-key-usage.md new file mode 100644 index 000000000..74994cc12 --- /dev/null +++ b/new_changelogs/20261028T060000Z-security-3309-leaf-key-usage.md @@ -0,0 +1,9 @@ +- **A certificate whose keyUsage does not allow signing is no longer + accepted as a TLS peer (#3309).** `tls13_cert.validate_chain`, and so the + pure TLS client, took any leaf for `PURPOSE_SERVER` or `PURPOSE_CLIENT` + whatever its keyUsage said, a CA certificate with keyCertSign and cRLSign + only among them. TLS 1.3 signs the CertificateVerify with the leaf's key, + and RFC 8446 4.4.2.2 requires digitalSignature when keyUsage is present; + OpenSSL refuses such a leaf as an unsuitable purpose, so the two backends + disagreed. The leaf's keyUsage, when present, must now allow + digitalSignature. diff --git a/new_changelogs/20261028T060001Z-fixed-3332-reply-actor-ref.md b/new_changelogs/20261028T060001Z-fixed-3332-reply-actor-ref.md new file mode 100644 index 000000000..3f1cb7a39 --- /dev/null +++ b/new_changelogs/20261028T060001Z-fixed-3332-reply-actor-ref.md @@ -0,0 +1,5 @@ +- **A reply of an actor reference keeps its type.** `reply w`, where `w` is + an `actor_ref`, was sent as an `int` (the pointer cut to its low half), + and the ask's result typed `int`, so a send through it did not compile. + Both sides now carry it as a pointer, bare or as a reply message's field + (#3332). diff --git a/new_changelogs/20261028T060002Z-fixed-3333-actor-ref-field.md b/new_changelogs/20261028T060002Z-fixed-3333-actor-ref-field.md new file mode 100644 index 000000000..bb6526f4d --- /dev/null +++ b/new_changelogs/20261028T060002Z-fixed-3333-actor-ref-field.md @@ -0,0 +1,5 @@ +- **A field access through a bare `actor_ref` is a type error.** It passed + the checker and failed in the C compiler (a bare reference is a `void*` + and names no actor). The checker refuses it with a hint to type the + reference (`actor_ref[Counter]`), and refuses a typed reference naming a + state its actor does not have, which reached C the same way (#3333). diff --git a/new_changelogs/20261028T060003Z-added-476-aether-vs-hcl.md b/new_changelogs/20261028T060003Z-added-476-aether-vs-hcl.md new file mode 100644 index 000000000..a8b3b6faf --- /dev/null +++ b/new_changelogs/20261028T060003Z-added-476-aether-vs-hcl.md @@ -0,0 +1,9 @@ +- **docs/aether-vs-hcl.md compares an Aether configuration with HCL.** What + HCL does well (repetition, typed and validated variables, plan before + apply, its providers), where it stops (no functions of your own, ternary + conditionals, templates as a second language, the provider split), the + same gateway configuration written both ways, `std.schema` beside HCL's + validated variables, what Aether offers for review before a configuration + runs, and when to pick which. config-is-code.md, + aether-embedded-in-host-applications.md and containment-sandbox.md link + to it (#476). diff --git a/new_changelogs/20261028T063000Z-security-3310-anchor-must-be-root.md b/new_changelogs/20261028T063000Z-security-3310-anchor-must-be-root.md new file mode 100644 index 000000000..53e19ef09 --- /dev/null +++ b/new_changelogs/20261028T063000Z-security-3310-anchor-must-be-root.md @@ -0,0 +1,12 @@ +- **On the pure TLS backend, as on OpenSSL's, only a self-signed root ends + a certificate path (#3310).** `tls13_cert.validate_chain` let any trusted + certificate end a path, an intermediate CA among them, and a self-issued + root whose authorityKeyIdentifier names another key, issuer or serial. + OpenSSL, without partial chains, ends a path only at a certificate that is + self-signed by X509_check_akid's rule (issuer equal to subject, and an AKI, + if any, naming the certificate itself), so the two backends accepted + different chains. The pure backend applies the same rule now. +- **Migrating:** a CA file holding an intermediate rather than its root + (`client.set_cafile`, `SSL_CERT_FILE`) no longer anchors a path on the + pure backend, as it never did on OpenSSL; trust the root. Pinning an + intermediate on both backends is #3311. diff --git a/new_changelogs/20261028T070000Z-added-426-std-stream.md b/new_changelogs/20261028T070000Z-added-426-std-stream.md new file mode 100644 index 000000000..a55e0afa3 --- /dev/null +++ b/new_changelogs/20261028T070000Z-added-426-std-stream.md @@ -0,0 +1,12 @@ +- **std.stream: credit-windowed streams between actors.** A send never + blocks, so a producer that outran its consumer grew the consumer's mailbox, + and then the scheduler's overflow, without bound. `stream.connect(producer, + consumer, window)` gives the two actors a stream: each chunk the producer + sends (`stream.write` cuts a string into chunks, `stream.offer` sends one) + costs one of `window` credits, and the consumer returns one per chunk + handled (`stream.consumed`). At most `window` chunks are in flight, and + running out of credit is a return value, not a block. `stream.finish` ends + the stream; the consumer's end comes back to the producer behind its last + credit, and `stream.credit` frees the stream there. Chunks are strings, so + binary data streams too. Sequencing and reordering wait for a transport + that needs them (#426). diff --git a/new_changelogs/20261028T070000Z-security-3312-openssl-security-level.md b/new_changelogs/20261028T070000Z-security-3312-openssl-security-level.md new file mode 100644 index 000000000..d0e44098d --- /dev/null +++ b/new_changelogs/20261028T070000Z-security-3312-openssl-security-level.md @@ -0,0 +1,12 @@ +- **The OpenSSL TLS backend refuses RSA keys under 2048 bits everywhere + (#3312).** `std.http`'s client and server contexts ran at the security + level the system configured: OpenSSL's own default, level 1, accepts a + 1024-bit RSA certificate, which the pure-Aether backend refuses, so the + same program accepted a different set of servers depending on the backend + and the distribution. Every OpenSSL client and server context now runs at + level 2 at least (RSA, DSA and DH of 2048 bits or more, elliptic curves of + 224 bits or more); a system configured higher stays higher. +- **Migrating:** a server certificate with an RSA key under 2048 bits, or a + chain with one, no longer verifies on the OpenSSL backend, and + `http.server_set_tls` refuses to load one; reissue it with a 2048-bit (or + larger) RSA key or an EC key. diff --git a/new_changelogs/20261028T070001Z-fixed-3341-message-order.md b/new_changelogs/20261028T070001Z-fixed-3341-message-order.md new file mode 100644 index 000000000..e4df0c150 --- /dev/null +++ b/new_changelogs/20261028T070001Z-fixed-3341-message-order.md @@ -0,0 +1,9 @@ +- **A message is known to every actor and function, wherever it is + declared.** Codegen emitted a message, and gave it the id its sends and + receive arms use, only when it reached it in the program. An actor declared + before a message it handled dropped that message without a word (the arm + never ran). A function that sent a message declared after it, with an + actor between them, did not compile. And a program's own actor could not + handle a message an imported module exports, because the merge puts the + module's declarations before `main`. Messages are now emitted ahead of the + actors and functions, as structs already were (#3341). diff --git a/new_changelogs/20261028T070002Z-fixed-3342-sweep-aether-home.md b/new_changelogs/20261028T070002Z-fixed-3342-sweep-aether-home.md new file mode 100644 index 000000000..15966c431 --- /dev/null +++ b/new_changelogs/20261028T070002Z-fixed-3342-sweep-aether-home.md @@ -0,0 +1,6 @@ +- **`make test-ae` tests the tree, not an install.** With `AETHER_HOME` set + to an install, as `install.sh` sets it, a test that ran the tree's + `aetherc` without pinning the variable compiled against the installed std, + and failed where the two differed (c_callback_table, + heap_box_across_functions). The sweep's runners now drop an inherited + `AETHER_HOME`; a test that wants an install names one itself (#3342). diff --git a/new_changelogs/20261028T073000Z-security-3313-openssl-never-check-subject.md b/new_changelogs/20261028T073000Z-security-3313-openssl-never-check-subject.md new file mode 100644 index 000000000..25cccfb85 --- /dev/null +++ b/new_changelogs/20261028T073000Z-security-3313-openssl-never-check-subject.md @@ -0,0 +1,10 @@ +- **The OpenSSL TLS backend matches the host against subjectAltName only + (#3313).** `std.http`'s OpenSSL client (and the wss:// client) let + OpenSSL fall back to the subject common name when a certificate had no + DNS subjectAltName, which the pure-Aether backend never does (RFC 9525, + as browsers): a CN-only certificate was accepted on one backend and + refused on the other. Both clients now set + X509_CHECK_FLAG_NEVER_CHECK_SUBJECT. +- **Migrating:** a server certificate that names its host only in the + subject CN no longer verifies on the OpenSSL backend; reissue it with the + name in subjectAltName. diff --git a/new_changelogs/20261028T080000Z-added-1298-bogo-conformance.md b/new_changelogs/20261028T080000Z-added-1298-bogo-conformance.md new file mode 100644 index 000000000..b17716b50 --- /dev/null +++ b/new_changelogs/20261028T080000Z-added-1298-bogo-conformance.md @@ -0,0 +1,9 @@ +- **BoGo, BoringSSL's TLS test suite, runs against the pure TLS stack + (#1298).** `make conformance-bogo` fetches BoringSSL at a pinned commit, + builds its `ssl/test/runner` (Go 1.27) and drives + `tests/conformance/bogo/shim.ae`, which runs `tls13_client` or + `tls13_server` for each test; `config.json` disables each failing test + with its reason. Not part of CI (it takes about five minutes). + `make conformance-x509` runs x509-limbo and bettertls locally, as CI does. +- **Added:** `tls13_client.connect_socket(sock, host, cafile)` runs the + verified handshake over an already-connected std.tcp socket. diff --git a/new_changelogs/20261028T080000Z-fixed-3346-win-lock-debug-record.md b/new_changelogs/20261028T080000Z-fixed-3346-win-lock-debug-record.md new file mode 100644 index 000000000..9ee8ce317 --- /dev/null +++ b/new_changelogs/20261028T080000Z-fixed-3346-win-lock-debug-record.md @@ -0,0 +1,10 @@ +- **On Windows a runtime lock allocates nothing once it is made.** A + critical section took a 48-byte debug record from the heap the first time + a thread waited on it, at an arbitrary later point. heap_in_use counts + every block on Windows, so an exact leak check (`std.mem.steady_growth`) + read it as growth, and a program that spawned and released actors failed + it now and then. The runtime's mutexes are made without the record, which + only served a debugger's lock listing. The Windows process table in + `std.os` is a statically initialized SRW lock now: it was made on first + use behind an unguarded flag, so two threads spawning at once could both + initialize it (#3346). diff --git a/new_changelogs/20261028T080001Z-fixed-3351-compiler-fast.md b/new_changelogs/20261028T080001Z-fixed-3351-compiler-fast.md new file mode 100644 index 000000000..c979c31c6 --- /dev/null +++ b/new_changelogs/20261028T080001Z-fixed-3351-compiler-fast.md @@ -0,0 +1,5 @@ +- **`make compiler-fast` links.** Its monolithic build listed the runtime + pieces aetherc needs on its own and had lost `aether_spawn.c` and + `aether_sandbox.c`, so it failed at the link; it also did not wait for the + generated headers. It now uses the same source list and headers as the + regular aetherc rule (#3351). diff --git a/new_changelogs/20261028T090000Z-fixed-3338-send-timeout-stall.md b/new_changelogs/20261028T090000Z-fixed-3338-send-timeout-stall.md new file mode 100644 index 000000000..49968e316 --- /dev/null +++ b/new_changelogs/20261028T090000Z-fixed-3338-send-timeout-stall.md @@ -0,0 +1,19 @@ +- **http_proxy_send_timeout tests the server, and std.tcp can size a + receive buffer before connecting.** The test's worker-path case received + every byte now and then, on two causes, both the test's: + - Its client set its receive buffer after connecting, so the window the + connection opened with still stood. On Linux it took that much through + the stall, then drained it for sixteen seconds after the cut. + - macOS's loopback, about two seconds into a client's stall, in some runs + has the sender's kernel report tens of megabytes sent and acknowledged, + while the client sends nothing and the bytes cross only once it reads + (measured with TCP_CONNECTION_INFO and a capture of lo0). No sender can + tell that from a client that reads, so the server rightly went on. The + worker path's cut, at its first full buffer plus the one second timeout, + met it at two seconds: 21 runs in 480. + + New `tcp.connect_buffered(host, port, read_buffer)` sets the buffer before + the connection is made, and `tcp.available(sock)` says how many bytes wait + in the receive buffer. The test connects with the first and uses a 300 ms + idle timeout, so every cut comes before macOS's absorption: 80 runs of 80 + on macOS, 40 of 40 on Linux (#3338). diff --git a/new_changelogs/20261028T090000Z-security-3315-tls13-extension-checks.md b/new_changelogs/20261028T090000Z-security-3315-tls13-extension-checks.md new file mode 100644 index 000000000..b038c5812 --- /dev/null +++ b/new_changelogs/20261028T090000Z-security-3315-tls13-extension-checks.md @@ -0,0 +1,12 @@ +- **The pure TLS client refuses extensions it never asked for (#3315).** + `std.cryptography.tls13_client` accepted any extension in a ServerHello + or EncryptedExtensions: unknown types, TLS 1.2 ones + (extended_master_secret, renegotiation_info, ec_point_formats), a + key_share or supported_versions out of place, a pre_shared_key in a full + handshake, duplicates, an ALPN protocol it did not offer, and an + EncryptedExtensions with no extensions block at all. RFC 8446 4.2 requires + refusing them; now the client does, with unsupported_extension for what it + never offered, illegal_parameter for an offered extension out of place or + repeated, and decode_error for a block that does not parse, and it sends + that alert. New `tls13_hs.check_extensions(kind, exts, offered)` holds the + rules. diff --git a/new_changelogs/20261028T093000Z-security-3316-certificate-entry-extensions.md b/new_changelogs/20261028T093000Z-security-3316-certificate-entry-extensions.md new file mode 100644 index 000000000..e08e14779 --- /dev/null +++ b/new_changelogs/20261028T093000Z-security-3316-certificate-entry-extensions.md @@ -0,0 +1,8 @@ +- **The pure TLS client checks the server's Certificate message (#3316).** + It accepted any CertificateEntry extension (an unknown type, an SCT it + never asked for, a server_name out of place, a repeated or empty + status_request) and did not check that the message's lists fill it. RFC + 8446 4.4.2 and 4.2 make these errors; the client now refuses them with + unsupported_extension, illegal_parameter or decode_error, and a + status_request must hold exactly one OCSP response. New + `tls13_hs.check_certificate(body, offered)` holds the rules. diff --git a/new_changelogs/20261028T100000Z-security-3317-tls13-record-layer.md b/new_changelogs/20261028T100000Z-security-3317-tls13-record-layer.md new file mode 100644 index 000000000..8914740af --- /dev/null +++ b/new_changelogs/20261028T100000Z-security-3317-tls13-record-layer.md @@ -0,0 +1,14 @@ +- **The pure TLS stack's record layer enforces RFC 8446 5 (#3317).** The + client and the server read records of any type and length: a plaintext + record over 2^14 bytes, a protected one over 2^14 + 256 or with an inner + plaintext over 2^14 + 1 (padding included), a content type TLS 1.3 does + not know, a change_cipher_spec after the handshake, more than one or of a + value other than 1, an empty handshake record, a plaintext record inside + the encrypted flight, and bytes after the server's Finished in its record + (which crashed the client). An unlimited run of empty records cost the + reader CPU without end; more than 32 in a row are now refused as a denial + of service, as BoringSSL bounds them. Each is refused with + record_overflow, unexpected_message or bad_record_mac, and the alert is + sent. New `tls13_record.check_header` checks a record's header before its + body is read; `OpenedRecord.alert` says what to send when a record does + not open. diff --git a/new_changelogs/20261028T103000Z-fixed-3321-split-handshake-messages.md b/new_changelogs/20261028T103000Z-fixed-3321-split-handshake-messages.md new file mode 100644 index 000000000..7c71f48b4 --- /dev/null +++ b/new_changelogs/20261028T103000Z-fixed-3321-split-handshake-messages.md @@ -0,0 +1,7 @@ +- **The pure TLS stack reassembles a handshake message split across + records (#3321).** RFC 8446 5.1 lets a peer split a handshake message over + several records. The client read the ServerHello, and the server the + ClientHello, from one record only, so a split one failed with "truncated + header" or "length overruns buffer". Both now gather the fragments, and + refuse with unexpected_message the bytes a peer puts after the message in + its last record. diff --git a/new_changelogs/20261028T110000Z-fixed-3322-middlebox-ccs.md b/new_changelogs/20261028T110000Z-fixed-3322-middlebox-ccs.md new file mode 100644 index 000000000..9b05d6892 --- /dev/null +++ b/new_changelogs/20261028T110000Z-fixed-3322-middlebox-ccs.md @@ -0,0 +1,8 @@ +- **The pure TLS stack sends the dummy change_cipher_spec of middlebox + compatibility mode (#3322).** The client always sends a non-empty + legacy_session_id, which RFC 8446 4.1.2 ties to that mode, but never sent + the dummy change_cipher_spec D.4 asks for, so BoringSSL (and middleboxes + that expect TLS 1.2's shape) refused it. The client now sends it before + its second flight (an alert included), or right after the ClientHello + when it offers early data; the server sends one right after its + ServerHello when the client's legacy_session_id is not empty. diff --git a/new_changelogs/20261028T120000Z-fixed-3335-ed25519-server-certificate.md b/new_changelogs/20261028T120000Z-fixed-3335-ed25519-server-certificate.md new file mode 100644 index 000000000..1a900ca45 --- /dev/null +++ b/new_changelogs/20261028T120000Z-fixed-3335-ed25519-server-certificate.md @@ -0,0 +1,6 @@ +- **The pure TLS client verifies an Ed25519 server (#3335).** It offered + ed25519 in signature_algorithms but had no Ed25519 branch where it checks + the CertificateVerify, so a server with an Ed25519 certificate was refused + with "unsupported CertificateVerify scheme". `tls13_cert.check_leaf_cert_verify` + now verifies every scheme the client offers (ECDSA P-256, P-384, P-521, + rsa_pss_rsae_sha256, Ed25519) with the certificate's own key. diff --git a/new_changelogs/20261028T120100Z-security-3324-tls13-right-alerts.md b/new_changelogs/20261028T120100Z-security-3324-tls13-right-alerts.md new file mode 100644 index 000000000..e6776cb89 --- /dev/null +++ b/new_changelogs/20261028T120100Z-security-3324-tls13-right-alerts.md @@ -0,0 +1,13 @@ +- **The pure TLS stack checks each handshake message to its last byte and + sends the alert RFC 8446 names (#3324).** The client took a server + flight with messages out of their place (a NewSessionTicket or an unknown + message among them, no CertificateVerify), bytes after a CertificateVerify's + signature, a Finished longer than the hash, and a CertificateRequest with a + request context or without signature_algorithms; it now refuses each with + unexpected_message, decode_error or missing_extension. A CertificateVerify + with a scheme the client did not offer, or one that does not fit the + certificate's key, is illegal_parameter, and a signature that does not + verify decrypt_error, also when a resumption's Finished does not match. + The server read the client Finished with no alert at all; it now answers, + under its application keys, bad_record_mac, unexpected_message, + decode_error or decrypt_error. diff --git a/new_changelogs/20261028T120200Z-security-3337-server-short-finished.md b/new_changelogs/20261028T120200Z-security-3337-server-short-finished.md new file mode 100644 index 000000000..6d7314249 --- /dev/null +++ b/new_changelogs/20261028T120200Z-security-3337-server-short-finished.md @@ -0,0 +1,9 @@ +- **A short client Finished no longer crashes the pure TLS server (#3337).** + The server compared the Finished's verify_data as the hash's length + without checking the message held that much, so a client that sent a + shorter one made the server process panic (a slice out of range), after + the server's first flight and before any authentication. A longer one was + taken (only the first bytes were compared) and one split over two records + refused. The server now reassembles the client Finished across records, + refuses verify_data of any other length with decode_error, and anything + after it in its record with unexpected_message. diff --git a/new_changelogs/20261028T165750Z-security-2955-sandbox-levels-per-thread.md b/new_changelogs/20261028T165750Z-security-2955-sandbox-levels-per-thread.md new file mode 100644 index 000000000..42e09ed07 --- /dev/null +++ b/new_changelogs/20261028T165750Z-security-2955-sandbox-levels-per-thread.md @@ -0,0 +1,24 @@ +- **A sandbox's grants belong to the thread that entered them, in the + runtime.** Every `sandbox.enforce` installed one process-wide checker + pointer, and any thread leaving its block cleared it: a deny-all block on + one thread then ran unchecked while another thread entered and left an + allow-all block, or called the pointer just cleared and the process died + of an access violation. The grant levels lived in each program's or + library's generated code, so a library's export called from inside + another library's block checked against an empty stack of its own, or, + on one runtime, replaced the outer checker with its own (an inner + `grant_all` widened the block) and cleared it on the way out (the rest of + the block ran unchecked). A panic caught by an actor's barrier left the + block's level on the scheduler thread, so the next handler there was held + to grants already freed; and a 65th nested level was silently dropped, + its pop taking an outer level off. The levels are now each thread's, + kept by the runtime (`aether_sandbox_push` / `aether_sandbox_pop` for a C + host): nesting across libraries on one runtime narrows; libraries with + copies of the runtime of their own (macOS, Windows, built without + `--shared-runtime`) refuse what they cannot check, naming + `--shared-runtime`; a caught panic ends the levels pushed inside its + frame, whoever catches; a 65th level is refused with a panic. The contrib + host bridges push their grants as levels of the thread instead of + swapping the checker, so a bridge inside a block gets the intersection, + and the LD_PRELOAD library reads the calling thread's levels. + `sandbox_install()` and `sandbox_uninstall()` now do nothing (#2955). diff --git a/new_changelogs/20261028T173630Z-fixed-2956-execution-frames-outcomes.md b/new_changelogs/20261028T173630Z-fixed-2956-execution-frames-outcomes.md new file mode 100644 index 000000000..28d8bf535 --- /dev/null +++ b/new_changelogs/20261028T173630Z-fixed-2956-execution-frames-outcomes.md @@ -0,0 +1,29 @@ +- **A panic in a library export ends the call, not the host, and nested + calls give the outer call its state back.** A panic an `--emit=lib` + export did not catch printed "panic outside any try/catch or actor" and + aborted the process that loaded the library. Every export now runs in an + execution frame: the panic ends the call with `AETHER_PANICKED`, the + export returns its zero value, `aether_last_outcome()` and + `aether_last_outcome_text()` (libaether.h) say how the call ended, and + the library takes the next call. An export that called a host service, + which set another caller and deadline and called an export again, saw the + inner call's caller and deadline from then on, and a sandbox level the + inner call left pushed stayed in force; each call now has its own, and + the outer one's are back when the inner one returns, however it ends. An + Aether program importing a library in binary form gets a failed call + raised again as a panic with the same reason, which a `try` catches, and + `host.last_outcome()` / `host.last_outcome_text()` read the outcome. A + library built with its own runtime now carries the host API + (`aether_set_call_deadline`, `aether_set_memory_cap`, + `aether_deadline_tripped`, `aether_set_caller`, ...) even when its code + calls none of it: a DLL had no `aether_set_call_deadline`, so a host could + not bound its calls. Every catalog is schema "1.10", with `outcome` + naming the library's outcome accessor; on Windows dbghelp is loaded when + a panic's stack trace is printed, so a C host needs no `-ldbghelp`. The + frame costs the context save a catchable panic needs: an export that + calls a function is 5.7 ns a call on Windows MinGW against 0.4 ns + before (the stub saves with `__builtin_setjmp` on x86, and Win64 makes it + keep its callee-saved registers), fib(30) through its stub is unchanged, + and an export that can neither panic, allocate, loop nor call anything + gets no frame (0.57 ns against 0.19 ns). A `try` frame no longer calls + into the allocation journal when it is pushed (#2955, #2956). diff --git a/new_changelogs/20261028T174100Z-fixed-3320-tls13-key-update.md b/new_changelogs/20261028T174100Z-fixed-3320-tls13-key-update.md new file mode 100644 index 000000000..f20a62e7e --- /dev/null +++ b/new_changelogs/20261028T174100Z-fixed-3320-tls13-key-update.md @@ -0,0 +1,14 @@ +- **The pure TLS client and server handle KeyUpdate (#3320).** After the + peer updated its keys, every following record failed to decrypt, so a + long-lived connection to a server that rotates keys died, and a KeyUpdate + asking for one in return was never answered. Both sides now move their + receiving keys on at each KeyUpdate (RFC 8446 4.6.3, 7.2), answer + update_requested with one KeyUpdate of their own before their next data + however many asked, and refuse a KeyUpdate that is not one byte of 0 or 1 + (decode_error, illegal_parameter), one that shares its record with + another message, and more than 32 without data between them + (unexpected_message). New `tls13_client.conn_key_update(conn, request)` + and `tls13_server.conn_key_update(conn, request)` update a connection's + own keys, asking for the peer's with `tls13_hs.KEY_UPDATE_REQUESTED`. A + record key or traffic secret that a KeyUpdate replaces is zeroed before it + is freed. diff --git a/new_changelogs/20261028T174101Z-security-3319-new-session-ticket-checks.md b/new_changelogs/20261028T174101Z-security-3319-new-session-ticket-checks.md new file mode 100644 index 000000000..d17c0c017 --- /dev/null +++ b/new_changelogs/20261028T174101Z-security-3319-new-session-ticket-checks.md @@ -0,0 +1,13 @@ +- **The pure TLS client checks the NewSessionTickets it reads (#3319, + #3354, #3356).** It took an empty ticket, a repeated early_data, an + early_data that is not four bytes, empty TLS flags or flags ending in a + zero byte, bytes after the extensions, and a ticket extension it knows + that a ticket may not carry; it now refuses each with decode_error or + illegal_parameter (RFC 8446 4.6.1, 4.2). It refused an extension it does + not know, which 4.6.1 says to ignore, and now ignores it. A ticket may + span records. `conn_get_ticket` dropped the application data that came + before the ticket; it now keeps it for the next `conn_recv`, up to 256 KiB + (past them it gives up, the connection still open), and a ticket + `conn_recv` reads is kept for `conn_get_ticket`. A CertificateRequest + after the handshake (no post-handshake authentication is offered) is + unexpected_message. diff --git a/new_changelogs/20261028T174102Z-fixed-3323-tls13-alerts.md b/new_changelogs/20261028T174102Z-fixed-3323-tls13-alerts.md new file mode 100644 index 000000000..94d3c47ed --- /dev/null +++ b/new_changelogs/20261028T174102Z-fixed-3323-tls13-alerts.md @@ -0,0 +1,8 @@ +- **The pure TLS stack treats alerts as TLS 1.3 says (#3323).** It ended + the connection at a user_canceled alert, and took any alert at warning + level as one that ends it. A user_canceled is now read past (up to four in + a row, as BoringSSL bounds them) and the close_notify that follows it ends + the stream; TLS 1.3 has no other non-fatal alert, so one sent at warning + level is answered with decode_error, and an alert level other than 1 or 2 + with illegal_parameter (RFC 8446 6). The client applies the same rules to + an alert in the server's flight, the server to the client's alerts. diff --git a/new_changelogs/20261028T180000Z-fixed-3325-hello-retry-request.md b/new_changelogs/20261028T180000Z-fixed-3325-hello-retry-request.md new file mode 100644 index 000000000..409b5711d --- /dev/null +++ b/new_changelogs/20261028T180000Z-fixed-3325-hello-retry-request.md @@ -0,0 +1,18 @@ +- **The pure TLS client and server do HelloRetryRequest (#3325).** The + client gave up at any HelloRetryRequest and the server refused a + ClientHello whose key shares held no group it takes. The client now + follows one that asks for a cookie or, on a resumption, for a secp256r1 + share (its first ClientHello there carries x25519's only): the + change_cipher_spec, then the same ClientHello with the cookie and the + share, without early data and with its PSK binder computed again, in a + record of version 0x0303, under the message_hash transcript (RFC 8446 + 4.1.4, 4.4.1). It refuses one naming a group it already has a share for + or never offered, one asking for no change (illegal_parameter), an empty + cookie (decode_error), a second one (unexpected_message), and a + ServerHello after it with another cipher suite or group. The server asks + for X25519MLKEM768 or X25519 when `supported_groups` names it but no share + is for it (4.2.8), sends its compatibility change_cipher_spec after the + HelloRetryRequest, and refuses a second ClientHello without that share + or with another session id or suite (illegal_parameter). + New `tls13_hs.hello_retry_request(session_id, cipher_suite, group)` and + `tls13_hs.client_hello_retry(ch1, group, share, cookie, out)`. diff --git a/new_changelogs/20261028T180001Z-security-3318-server-hello-checks.md b/new_changelogs/20261028T180001Z-security-3318-server-hello-checks.md new file mode 100644 index 000000000..e41a88742 --- /dev/null +++ b/new_changelogs/20261028T180001Z-security-3318-server-hello-checks.md @@ -0,0 +1,11 @@ +- **The pure TLS client checks the ServerHello's fixed fields and its key + share (#3318, #3353).** It took a ServerHello whose legacy_version is not + 0x0303, whose legacy_session_id_echo is not the session id it sent, whose + extensions do not fill the message or whose key_share has bytes after the + key, and on a resumption a selected_identity it never offered, a suite + whose hash is not the ticket's, a ServerHello without key_share (an + X25519 agreement over no share at all) and a key_share for a group it had + no share for. Each is now refused, with illegal_parameter, decode_error + or missing_extension (RFC 8446 4.1.3, 4.2.8, 4.2.11); an unoffered suite + or group and a key share that is not a valid point, which got no alert at + all, are illegal_parameter. diff --git a/new_changelogs/20261028T181000Z-fixed-3336-zero-rtt-keys.md b/new_changelogs/20261028T181000Z-fixed-3336-zero-rtt-keys.md new file mode 100644 index 000000000..58b92d286 --- /dev/null +++ b/new_changelogs/20261028T181000Z-fixed-3336-zero-rtt-keys.md @@ -0,0 +1,16 @@ +- **0-RTT resumption works on the pure TLS client (#3336, #3352, #3339, + #3355).** When the server took the early data, the client derived its + application traffic secrets over a transcript that included its + EndOfEarlyData (RFC 8446 7.1 derives them over ClientHello..server + Finished) and sealed the EndOfEarlyData under its handshake keys instead + of the early traffic keys (4.5), so the server could read neither, and it + protected the early data with a cipher chosen from the ticket's hash, so a + ChaCha20-Poly1305 ticket sent early data under AES-128-GCM (4.2.10). The + secrets now come from ClientHello..server Finished, the EndOfEarlyData + goes under the early traffic keys, a SessionTicket records the suite of + the connection that got it (`tls13_client.ticket_cipher_suite`), early + data goes under that suite, a ticket without one sends none, and a server + that takes the early data under another suite is refused + (illegal_parameter). New `tls13_client.conn_early_accepted(conn)` says + whether the early data was delivered. Tested against a scripted server + and against `openssl s_server -early_data` in all three suites. diff --git a/new_changelogs/20261028T190000Z-fixed-3361-hello-retry-random.md b/new_changelogs/20261028T190000Z-fixed-3361-hello-retry-random.md new file mode 100644 index 000000000..4e25fc9d7 --- /dev/null +++ b/new_changelogs/20261028T190000Z-fixed-3361-hello-retry-random.md @@ -0,0 +1,9 @@ +- **The pure TLS stack knows a HelloRetryRequest when it sees one (#3361).** + `std.cryptography.tls13_hs` compared a ServerHello's random against a + mistyped copy of RFC 8446's HelloRetryRequest value (the second half was + wrong), so the client read a real server's HelloRetryRequest as a broken + ServerHello ("bad key_share") and real clients refused the pure server's. + The value is now computed as the RFC defines it, SHA-256 of + "HelloRetryRequest", and checked against the RFC's bytes; the client + follows `openssl s_server -groups P-256`'s HelloRetryRequest on a + resumption, and `openssl s_client` completes the pure server's. diff --git a/new_changelogs/20261028T190830Z-changed-send-buffer-thread-local.md b/new_changelogs/20261028T190830Z-changed-send-buffer-thread-local.md new file mode 100644 index 000000000..89fb9c9c9 --- /dev/null +++ b/new_changelogs/20261028T190830Z-changed-send-buffer-thread-local.md @@ -0,0 +1,18 @@ +- **The send buffer is no longer an extern thread-local.** `g_send_buffer`, + a thread-local of several fields declared in `aether_send_buffer.h` and + written field by field from every file that buffered a send, is now a + `static` thread-local of `aether_send_buffer.c`, reached through + `send_buffer_here()`. GCC 16.2 for x86_64-w64-mingw32 at -O2 merged two + adjacent stores into an extern thread-local aggregate into one store + addressed through the variable's non-thread address, so the calling + thread's copy kept its old value (the per-thread frames block was hit; + the send buffer's code was not, yet). The rule, written at + `aether_frames.h`: an extern thread-local is a scalar written whole; an + aggregate stays static to its file, reached by accessor. `send_buffered()` + stays inline, writing through the pointer: 1.57 ns a buffered send + against 1.55 ns before on Windows MinGW, loops aligned (5.7 ns as a call, + the message copied twice). Fixes #3372. `runtime/examples/buffered_send_bench.c` no longer hangs in its + second benchmark: it stopped the scheduler without a shutdown, and + `scheduler_init()` does nothing on a scheduler that is only stopped; and + its actor took up to 128 messages a step where the scheduler counts one, + so a shutdown waited for messages it would never see handled. diff --git a/new_changelogs/20261028T191217Z-fixed-2956-interruptions-cancel.md b/new_changelogs/20261028T191217Z-fixed-2956-interruptions-cancel.md new file mode 100644 index 000000000..1bdb64567 --- /dev/null +++ b/new_changelogs/20261028T191217Z-fixed-2956-interruptions-cancel.md @@ -0,0 +1,27 @@ +- **A call's deadline ends the call, and another thread can cancel one.** + A deadline set with `aether_set_call_deadline` broke the loop it was + checked in, and the export returned whatever the loop had, an + ordinary-looking value only `aether_deadline_tripped()` said was partial; + a JSON parse had no check at all and ran to the end of a large document; + and + nothing could stop a call from another thread. Inside an export, the + first safe point past the deadline now ends the call: the interruption + unwinds like a panic, every frame on the way runs its defers and releases + its owned locals, a `try` passes it on after its own cleanup instead of + running its handler, and the export returns its zero value with the + outcome `AETHER_DEADLINE`. `aether_exec_token()` names the call running + on a thread (or its next one), and `aether_exec_cancel(token)` from any + thread ends that call the same way with `AETHER_CANCELLED`; a cancel of a + call that already ended does nothing. The safe points are loop heads and + `json.parse`, every 4096 values; straight-line code, recursion without a + loop (a safe point at every function entry made fib(30) 71% slower, so + there is none), other C-backed std operations and `extern` calls run to + their end (docs/emit-lib.md, "Per-call resource caps"). On Windows + MinGW, an execution's record was cleared by a store into the + thread-local's template instead of the calling thread's copy (GCC 16.2 + at -O2 merges two adjacent stores into an extern thread-local aggregate + and addresses the merged one through the variable's non-thread + address), so a call could keep the next-deadline and caller-info flags + of an earlier call at the same depth: the per-thread frames block is now + a `static` thread-local of `aether_unwind.c`, reached from every other + file through `aether_frames_here()` (#2956). diff --git a/new_changelogs/20261028T191218Z-fixed-2955-panic-links-no-sandbox.md b/new_changelogs/20261028T191218Z-fixed-2955-panic-links-no-sandbox.md new file mode 100644 index 000000000..ad3376335 --- /dev/null +++ b/new_changelogs/20261028T191218Z-fixed-2955-panic-links-no-sandbox.md @@ -0,0 +1,8 @@ +- **A program that panics links no sandbox.** Since a panic ends the + sandbox levels pushed inside the frame it leaves (#2955), it called into + `aether_sandbox.c` directly, and every program with a `panic` or a `try` + linked the sandbox and its audit log (17 KB more for a small program on + Windows MinGW). The sandbox now hands the panic its unwind through a + pointer, set before any level can exist, so such a program links what it + did before (`tests/integration/panic_link_surface` reads its symbol + table). diff --git a/new_changelogs/20261028T193000Z-security-3357-server-client-hello-checks.md b/new_changelogs/20261028T193000Z-security-3357-server-client-hello-checks.md new file mode 100644 index 000000000..5f6e6d6bf --- /dev/null +++ b/new_changelogs/20261028T193000Z-security-3357-server-client-hello-checks.md @@ -0,0 +1,17 @@ +- **The pure TLS server checks the ClientHello and speaks the rest of + TLS 1.3's baseline (#3357).** BoGo's server tests found it taking a + ClientHello with compression methods other than null alone, a repeated + extension, two key shares for one group, a server_name, key_share or + certificate_authorities that does not parse, bytes after the extensions, + and a pre_shared_key that is not the last extension; it now refuses each + with illegal_parameter or decode_error (RFC 8446 4.1.2, 4.2), a + ClientHello without supported_groups or key_share with missing_extension + (9.2), and another handshake type with unexpected_message. It takes a + secp256r1 key share, which 9.1 says a TLS 1.3 implementation MUST support, + signs with rsa_pss_rsae_sha384 and rsa_pss_rsae_sha512 when the client + offers only those (new `rsa.sign_pss_hash`, `rsa.verify_pss_hash` and + `tls13_cert.sign_server_cert_verify_rsa_pss_scheme`), reads past 0-RTT + data it does not take (4.2.10), and sends its compatibility + change_cipher_spec whatever the client's session id. On both sides a + Finished of the wrong length is now decrypt_error (4.4.4), and a + protected record shorter than its tag bad_record_mac (5.2). diff --git a/new_changelogs/20261028T194500Z-removed-3373-msg-pool-profile-env.md b/new_changelogs/20261028T194500Z-removed-3373-msg-pool-profile-env.md new file mode 100644 index 000000000..3a07c762b --- /dev/null +++ b/new_changelogs/20261028T194500Z-removed-3373-msg-pool-profile-env.md @@ -0,0 +1,12 @@ +- **`AETHER_MSG_POOL_SIZE` and `AETHER_PROFILE` are gone; setting them + never had an effect.** The two environment variables were documented as + the message pool's size and a memory profile (`micro`, `small`, `medium`, + `large`), but the only code that read the size they set was + `runtime/actors/aether_message_pool.h`, a header nothing included, whose + pool variable was defined nowhere. They changed one line of the + `AETHER_VERBOSE` configuration print and nothing else, so a program that + sets them is unaffected. The header, its section in + `docs/runtime-config.md`, the two rows of the environment table, the + `msg_pool_size` and `profile` fields of the runtime configuration and the + profile helpers are removed (#3373). The compile-time `-DAETHER_PROFILE` + switch of the profiler is unrelated and unchanged. diff --git a/new_changelogs/20261028T200000Z-added-3362-server-set-tls-pem.md b/new_changelogs/20261028T200000Z-added-3362-server-set-tls-pem.md new file mode 100644 index 000000000..cc7dcedbd --- /dev/null +++ b/new_changelogs/20261028T200000Z-added-3362-server-set-tls-pem.md @@ -0,0 +1,11 @@ +- **`http.server_set_tls_pem(server, cert_pem, key_pem)`: a TLS server from + PEM text.** A program that held its certificate and key in memory had to + write the key to disk for `server_set_tls`. The new call takes the text, + with the same checks and results on both backends: OpenSSL loads it from + memory, and the pure server parses the chain and key once, when the server + is set up. The pure server used to read and parse both files on every + connection; with either call it now parses once, and every connection + shares the result. The server keeps its own copy of the text, for a later + `server_set_tls_backend`, and wipes it when it is replaced or the server is + freed. An encrypted key is refused at once: the OpenSSL backend used to ask + for its passphrase on the terminal (#3362). diff --git a/new_changelogs/20261028T200000Z-fixed-3368-alert-before-close.md b/new_changelogs/20261028T200000Z-fixed-3368-alert-before-close.md new file mode 100644 index 000000000..517f19eed --- /dev/null +++ b/new_changelogs/20261028T200000Z-fixed-3368-alert-before-close.md @@ -0,0 +1,8 @@ +- **The pure TLS client's alert reaches the server when a handshake fails + (#3368).** After refusing a server's message, `std.cryptography.tls13_client` + sent its alert and closed the socket with the rest of the server's flight + still unread. Closing a socket that holds unread bytes sends a TCP reset, + and a peer that gets one before reading (Windows does this) drops the + alert, so the server never learned why. The client now reads and drops + what has already arrived (at most 256 KiB, waiting at most 5 ms for each + more) before it closes. diff --git a/new_changelogs/20261028T200001Z-fixed-3370-openssl-server-chain.md b/new_changelogs/20261028T200001Z-fixed-3370-openssl-server-chain.md new file mode 100644 index 000000000..1240e0e2a --- /dev/null +++ b/new_changelogs/20261028T200001Z-fixed-3370-openssl-server-chain.md @@ -0,0 +1,7 @@ +- **The OpenSSL server backend sends the certificate chain.** It loaded the + certificate file with `SSL_CTX_use_certificate_file`, which reads the first + certificate alone, so the intermediates in a CA's bundle never reached the + client, and a client that did not already hold them refused the server. The + pure backend sent the whole chain, so the two backends served different + chains from one file. Both now send the leaf and every certificate after it + (#3370). diff --git a/new_changelogs/20261028T200002Z-fixed-3371-tls-reload-race.md b/new_changelogs/20261028T200002Z-fixed-3371-tls-reload-race.md new file mode 100644 index 000000000..111e08f9f --- /dev/null +++ b/new_changelogs/20261028T200002Z-fixed-3371-tls-reload-race.md @@ -0,0 +1,8 @@ +- **Re-loading a running server's TLS is safe.** `server_set_tls` on a + server that was already accepting freed the old OpenSSL context without a + lock, and both accept paths read the pointer and then called `SSL_new` on + it, so a connection accepted in between used freed memory. The TLS state is + swapped under a lock now, and each accept takes a reference to what it + uses (the `SSL_CTX`, or the pure server's credentials), so what a re-load + replaces is freed by its last user. Two re-loads at once, from two + threads, are safe too: the configuration calls take turns (#3371). diff --git a/new_changelogs/20261028T201000Z-fixed-3364-decode-error-alerts.md b/new_changelogs/20261028T201000Z-fixed-3364-decode-error-alerts.md new file mode 100644 index 000000000..c67f4da5a --- /dev/null +++ b/new_changelogs/20261028T201000Z-fixed-3364-decode-error-alerts.md @@ -0,0 +1,10 @@ +- **The pure TLS client sends decode_error for a repeated EncryptedExtensions + extension and a nonzero compression method (#3364).** + `std.cryptography.tls13_hs.check_extensions` judged each extension as it + came, so an extension the server sent twice was refused as unsolicited + (unsupported_extension) before the repeat was seen. The block is now + checked whole first: a repeated type is decode_error in + EncryptedExtensions and illegal_parameter in the other messages, as + BoringSSL sends them. A ServerHello or HelloRetryRequest whose + legacy_compression_method is not 0 is decode_error instead of + illegal_parameter (RFC 8446 4.1.3). diff --git a/new_changelogs/20261028T201500Z-fixed-3374-no-payload-pool.md b/new_changelogs/20261028T201500Z-fixed-3374-no-payload-pool.md new file mode 100644 index 000000000..79e25962f --- /dev/null +++ b/new_changelogs/20261028T201500Z-fixed-3374-no-payload-pool.md @@ -0,0 +1,17 @@ +- **Nothing claims a message payload pool any more.** The pool's acquire + side was removed long ago (an actor can move to another core after a + message is sent, so a pooled payload would be released by a thread that + does not own the pool), and every typed payload is a heap copy. Yet + `AETHER_FLAG_LOCKFREE_POOLS` was documented as a working option and + reported enabled, the `AETHER_VERBOSE` print listed "[ON] TLS Message + Pools", five docs presented the pool as running, every actor program + asked `aether_message_pool_stats` for counters nothing incremented, and + every `aether_free_message` first asked the pool whether it owned the + payload, which it never did. The pool code is gone and + `aether_free_message` is a `free()` (8.20 ns against 8.27 ns a free on + Windows MinGW). `AETHER_FLAG_LOCKFREE_POOLS` stays + defined, accepted and ignored, and is deprecated: + `aether_runtime_has_feature()` reports it off and `use_lockfree_pools` + in the runtime configuration is always 0 (kept, so the fields after it + keep their offsets). `aether_message_pool_stats` stays, deprecated, + reporting zeros, and generated programs no longer call it (#3374). diff --git a/new_changelogs/20261028T201534Z-fixed-2956-allocation-failure-ends-call.md b/new_changelogs/20261028T201534Z-fixed-2956-allocation-failure-ends-call.md new file mode 100644 index 000000000..85357c504 --- /dev/null +++ b/new_changelogs/20261028T201534Z-fixed-2956-allocation-failure-ends-call.md @@ -0,0 +1,17 @@ +- **An allocation that fails inside a library call ends the call.** Past + `aether_set_memory_cap`, or with the allocator out of memory, a + cap-aware allocation returned NULL, the std operation returned its error + (a string operation, NULL), and the script went on with it: the export + returned whatever it reached with `AETHER_OK`. A send whose payload copy + failed, or a captured variable or `make([]T, n)` that could not be + allocated, stopped the process or panicked as an ordinary panic. Inside + an export the failure is now noted on the call, which ends at its next + safe point or its return with `AETHER_MEMORY_CAP` or + `AETHER_OUT_OF_MEMORY` and the zero value, its defers run and a `try` + in it not catching it; an allocation the code cannot go on without ends + the call at once with `AETHER_OUT_OF_MEMORY`, a send's message values + released. Outside an export nothing changes. Neither reporting nor + unwinding allocates any more: the allocation journal's drain, which took + a copy of the entries it released with `malloc` (and skipped them when + that failed), drains in place, and no longer releases a second time an + entry a release of the same frame forgot (#2956). diff --git a/new_changelogs/20261028T202000Z-fixed-3365-certificate-authorities.md b/new_changelogs/20261028T202000Z-fixed-3365-certificate-authorities.md new file mode 100644 index 000000000..1d4686959 --- /dev/null +++ b/new_changelogs/20261028T202000Z-fixed-3365-certificate-authorities.md @@ -0,0 +1,7 @@ +- **The pure TLS client checks a CertificateRequest's certificate_authorities + (#3365).** `std.cryptography.tls13_hs.check_certificate_request` ignored + the extensions' bodies, so the client answered a CertificateRequest whose + certificate_authorities was an empty list or carried bytes after its + names. Such a list, an empty name in it, and a signature_algorithms or + signature_algorithms_cert list that is empty, odd or does not fill its + extension are now refused with decode_error (RFC 8446 4.2.4, 4.2.3). diff --git a/new_changelogs/20261028T203000Z-fixed-3366-flight-in-small-records.md b/new_changelogs/20261028T203000Z-fixed-3366-flight-in-small-records.md new file mode 100644 index 000000000..d9dcfea49 --- /dev/null +++ b/new_changelogs/20261028T203000Z-fixed-3366-flight-in-small-records.md @@ -0,0 +1,7 @@ +- **The pure TLS client reads a server flight split into small records + (#3366).** `std.cryptography.tls13_client` gave up on the server's + encrypted flight after 256 records ("flight too long"), so a valid flight + sent in one-byte records, which RFC 8446 5.1 allows, was refused. The + bound is now on the flight's bytes (512 KiB) and on each message's + length as soon as its header arrives (64 KiB, a Certificate 256 KiB), + with unexpected_message past either. diff --git a/new_changelogs/20261028T203706Z-fixed-3375-aether-verbose-env.md b/new_changelogs/20261028T203706Z-fixed-3375-aether-verbose-env.md new file mode 100644 index 000000000..fd63cfae0 --- /dev/null +++ b/new_changelogs/20261028T203706Z-fixed-3375-aether-verbose-env.md @@ -0,0 +1,8 @@ +- **`AETHER_VERBOSE=1` prints the runtime configuration.** The variable was + documented as printing the optimization configuration at startup, but it + only set a flag nothing read: only a program compiled with + `-DAETHER_VERBOSE` printed it. The runtime now prints it when the + scheduler starts and the variable is set, whatever the program was + compiled with; a program compiled with `-DAETHER_VERBOSE` (or a host + passing `AETHER_OPT_VERBOSE`) still prints it, and it is printed once + however many of them ask (#3375). diff --git a/new_changelogs/20261028T204000Z-fixed-3367-unexpected-flight-message.md b/new_changelogs/20261028T204000Z-fixed-3367-unexpected-flight-message.md new file mode 100644 index 000000000..47aaa0e67 --- /dev/null +++ b/new_changelogs/20261028T204000Z-fixed-3367-unexpected-flight-message.md @@ -0,0 +1,8 @@ +- **The pure TLS client refuses a message its server's flight cannot hold + at once (#3367).** The client read the encrypted flight until a Finished + arrived and only then checked what came before it, so a server that sent + its Finished as another message type (or any message that does not belong + in the flight) left the client waiting until the connection closed or + timed out. Only EncryptedExtensions, CertificateRequest, Certificate, + CertificateVerify and Finished may come there; any other type is now + unexpected_message as soon as its header arrives. diff --git a/new_changelogs/20261028T205000Z-fixed-3363-psk-modes-in-client-hello.md b/new_changelogs/20261028T205000Z-fixed-3363-psk-modes-in-client-hello.md new file mode 100644 index 000000000..b02bc5d48 --- /dev/null +++ b/new_changelogs/20261028T205000Z-fixed-3363-psk-modes-in-client-hello.md @@ -0,0 +1,6 @@ +- **The pure TLS client gets session tickets from Go and BoringSSL servers + (#3363).** Its full-handshake ClientHello carried no + psk_key_exchange_modes, and a server may resume only with a mode the + client lists (RFC 8446 4.2.9), so Go's and BoringSSL's servers sent it no + NewSessionTicket and `conn_get_ticket` had nothing to return. + `tls13_hs.client_hello_full` now offers { psk_dhe_ke }. diff --git a/new_changelogs/20261028T205320Z-fixed-3377-generated-alloc-checked.md b/new_changelogs/20261028T205320Z-fixed-3377-generated-alloc-checked.md new file mode 100644 index 000000000..d7e2afc98 --- /dev/null +++ b/new_changelogs/20261028T205320Z-fixed-3377-generated-alloc-checked.md @@ -0,0 +1,16 @@ +- **Every allocation the compiler makes for a program is checked.** The + helpers the generated C uses for a captured variable's cell, `ref`, + `lazy`, a closure's environment, a closure boxed into a `ptr` and + `heap.new` used `malloc`'s result unchecked (only the cell checked it): + out of memory, the program dereferenced NULL, a crash with no message, + inside a library call or not. They now go through one checked + allocation: inside a library call the call ends with + `AETHER_OUT_OF_MEMORY`, and elsewhere it is a panic naming what ran out + ("out of memory in heap.new"), which a `try` catches and which otherwise + stops the program with that message. A `malloc` the program declares + and calls itself still returns NULL, as C's does, and ends a library + call when it next reaches a safe point or returns. The runtime's + allocation test hook covers these allocations too. No measurable cost: + a closure made and called per step 577 against 551 ms, a `ref` and a + `heap.new` per step 880 against 873 ms, 20 million steps, Windows MinGW + (#3377). diff --git a/new_changelogs/20261028T210000Z-fixed-3369-hello-retry-group.md b/new_changelogs/20261028T210000Z-fixed-3369-hello-retry-group.md new file mode 100644 index 000000000..070bbf8e1 --- /dev/null +++ b/new_changelogs/20261028T210000Z-fixed-3369-hello-retry-group.md @@ -0,0 +1,10 @@ +- **The pure TLS client follows a HelloRetryRequest for secp384r1 and checks + the ServerHello's group after one (#3369).** The client put a key share on + every group it offered, so no server ever had a group to ask for, and + after a HelloRetryRequest it did not check that the ServerHello used the + group it asked for (RFC 8446 4.2.8). It now names secp384r1 in + supported_groups without a share, as BoringSSL does, answers a + HelloRetryRequest for it with a P-384 share alone, and refuses with + illegal_parameter a ServerHello whose group has no share in the + ClientHello it answers. New `tls13_hs.client_hello_offer` (a ClientHello + naming one group without a share) and `tls13_hs.GROUP_SECP384R1`. diff --git a/new_changelogs/20261028T213503Z-fixed-3383-wasm-runtime-list.md b/new_changelogs/20261028T213503Z-fixed-3383-wasm-runtime-list.md new file mode 100644 index 000000000..c23e6dbbd --- /dev/null +++ b/new_changelogs/20261028T213503Z-fixed-3383-wasm-runtime-list.md @@ -0,0 +1,13 @@ +- **`ae build --target=wasm` links the runtime again.** The Emscripten build + compiled a runtime list `ae` kept for itself, which had drifted from + `runtime/coop_runtime_sources.txt`, the list ci-wasm and the ESP32 build + read: it lacked `aether_resource_caps.c`, which every string release + calls, and `aether_exec.c`, `aether_host.c`, `aether_locale_num.c`, + `aether_supervision.c`, `capsicum_autosandbox.c` and the alloc, + http_park, quote and udp modules, so a program did not link. `ae` now + reads that file (installed with the runtime), so the lists cannot drift. + The file gains the C of `std.floatarr`, `std.strarr` and the StringList of + `std.collections`, which a program reaches from Aether as it reaches + `std.intarr` and `std.longarr`. ci-wasm builds a program with `ae build + --target=wasm` and runs it, and the release layout check asserts the + file ships (#3383). diff --git a/new_changelogs/20261028T214749Z-fixed-3385-undelivered-test-settles.md b/new_changelogs/20261028T214749Z-fixed-3385-undelivered-test-settles.md new file mode 100644 index 000000000..9bae6977d --- /dev/null +++ b/new_changelogs/20261028T214749Z-fixed-3385-undelivered-test-settles.md @@ -0,0 +1,15 @@ +- **The undelivered-message leak test no longer fails now and then on + Windows.** `tests/regression/test_undelivered_message_releases_fields.ae` + failed now and then with "dropped messages grew the heap ... in every run" + (18 runs in 100 on main, before #3346; up to one in a hundred after). A + heap diff of a failing run showed what grew: one actor's block (1,799 + bytes) per window, kept by the scheduler for the next spawn of its size + (#2517). A released actor's block is kept once it is reclaimed and no + message is on its way to it, and a spawn before that takes a fresh one, + so the number of blocks kept followed the cores' timing, not the program. + Each round of the test now ends settled (its released actors' messages + dropped, their blocks reclaimed), so the next round reuses the blocks the + first one made; its check is as exact as it was. The scheduler's comment, + which said the blocks kept never exceed what was once live at the same + time, now counts the released actors still being reclaimed or awaited by + a message too (#3385). diff --git a/new_changelogs/20261028T220000Z-security-3378-chain-only-certificates.md b/new_changelogs/20261028T220000Z-security-3378-chain-only-certificates.md new file mode 100644 index 000000000..fe50869fb --- /dev/null +++ b/new_changelogs/20261028T220000Z-security-3378-chain-only-certificates.md @@ -0,0 +1,12 @@ +- **The pure TLS server never sends a non-certificate PEM block as part of + its chain (#3378).** `std.cryptography.tls13_server.parse_cert_chain_pem` + split the certificate text at each END CERTIFICATE line and decoded the + first PEM block of each piece, whatever its label. A private key between + two certificates (or a combined key-and-certificate file) went into the + chain, and the server sent the key's PKCS#8 DER to every client as a + CertificateEntry; `server_set_tls` checked only the leaf, so it accepted + the files. Only `BEGIN CERTIFICATE` blocks now form the chain, every other + block is skipped as OpenSSL skips it, and every entry is parsed as a + certificate when the chain is loaded: a certificate block that does not + decode or parse refuses the whole text, and `server_set_tls` says which. + New `tls13_server.parse_cert_chain_pem_checked`. diff --git a/new_changelogs/20261028T221000Z-security-3380-records-over-16k.md b/new_changelogs/20261028T221000Z-security-3380-records-over-16k.md new file mode 100644 index 000000000..4926f0cab --- /dev/null +++ b/new_changelogs/20261028T221000Z-security-3380-records-over-16k.md @@ -0,0 +1,11 @@ +- **The pure TLS server no longer dies on a certificate chain or a write + over 16 KiB (#3380).** `std.cryptography.tls13_server` put each handshake + message, and each `conn_send`, into one record. Over 2^14 bytes + `seal_record` refuses, and the null went on to a slice that panicked + outside any try, so a chain over 16 KiB killed an HTTP server on its first + connection, and so did a response written in one piece over 16 KiB; the + client's `conn_send` had the same fault. Handshake messages and data now + go in as many records as they take (new `tls13_record.seal_records`, RFC + 8446 5.1), and every seal and send is checked on both sides: a record that + cannot be sealed or sent ends that connection with an error, never the + process. diff --git a/new_changelogs/20261028T222000Z-fixed-3381-server-recv-tail.md b/new_changelogs/20261028T222000Z-fixed-3381-server-recv-tail.md new file mode 100644 index 000000000..7181015b3 --- /dev/null +++ b/new_changelogs/20261028T222000Z-fixed-3381-server-recv-tail.md @@ -0,0 +1,8 @@ +- **The pure TLS server's reads no longer drop the end of a record + (#3381).** `std.cryptography.tls13_server`'s recv for `std.http` copied + as much of a decrypted record as the caller's buffer took and freed the + rest. The HTTP server reads with 16383 bytes of room, and its + Content-Length loop asks for just what a body still lacks, so a record of + 16384 bytes lost a byte and a request body over 16 KiB never completed + (the request hung until its timeout). What does not fit is now kept for + the next read, as the pure client's bridge already did. diff --git a/new_changelogs/20261028T223000Z-fixed-3384-pure-tls-pending.md b/new_changelogs/20261028T223000Z-fixed-3384-pure-tls-pending.md new file mode 100644 index 000000000..769266048 --- /dev/null +++ b/new_changelogs/20261028T223000Z-fixed-3384-pure-tls-pending.md @@ -0,0 +1,12 @@ +- **A keep-alive connection on the pure TLS server no longer stalls on a + request it already holds, nor reads TLS records as HTTP (#3384).** + `std.http` decides whether the next request is there before it parks a + connection, and asks OpenSSL with `SSL_pending`; the pure server's + buffered plaintext (the kept tail of a record, #3381) and the whole + records it had already read were invisible, so a pipelined request in + the record that ended a body waited for the idle timeout. On Linux and + macOS the same check read the socket directly for any connection without + OpenSSL, the pure TLS ones included, taking the client's next TLS record + as plaintext. New `aether_pure_tls_server_pending` callback, checked + where `SSL_pending` is (also for WebSocket readiness), and the direct + read is for plain connections only. diff --git a/new_changelogs/20261028T224000Z-security-3379-record-reader-memory.md b/new_changelogs/20261028T224000Z-security-3379-record-reader-memory.md new file mode 100644 index 000000000..7ec5e9815 --- /dev/null +++ b/new_changelogs/20261028T224000Z-security-3379-record-reader-memory.md @@ -0,0 +1,14 @@ +- **A pure TLS connection's memory no longer grows with what it receives + (#3379).** The record readers of `std.cryptography.tls13_server` and + `tls13_client` appended every byte read and only moved an offset past the + records they returned, so a connection kept everything it had ever + received: a pure server reading a 200 MiB stream grew by 240 MiB, one + long keep-alive connection could exhaust its memory, and the lengths would + overflow at 2^31. The readers now drop what they have consumed before + reading more, so a connection holds about one record and one read. Before + the handshake, the server no longer reads past application_data records + (empty ones cost nothing, so any number went by) except the refused 0-RTT + data after a HelloRetryRequest, and the client takes one + change_cipher_spec, of the byte 1, in its whole handshake (RFC 8446 5, + D.4) instead of dropping any number of any content before the + ServerHello; anything else is unexpected_message. diff --git a/new_changelogs/20261028T224400Z-fixed-3386-actor-ref-state-store.md b/new_changelogs/20261028T224400Z-fixed-3386-actor-ref-state-store.md new file mode 100644 index 000000000..e0b9d1d34 --- /dev/null +++ b/new_changelogs/20261028T224400Z-fixed-3386-actor-ref-state-store.md @@ -0,0 +1,14 @@ +- **A store into another actor's state through its reference takes what it + stores and releases what it replaces (#3386).** `w.label = s`, + `w.kept = r`, `w.items[i] = r` and `w.names = arr`, with `w` an actor's + reference, were plain C assignments (the whole-array one did not compile): + the state shared what its source still owned, so once the source's owner + let go the actor held released strings and closures, and released them a + second time when it ended, and what a store replaced leaked. A part of + such a field (`w.kept.name = s`, `w.items[i].name = s`) took the new + value but leaked the old one. They are R3's stores now, as the actor's + own are, and what they take is published, since the actor reads it on + its own thread. `std.supervise.start` copied its spec's children into the + supervisor that way before freeing the spec: a factory that captured a + local crashed on its first call, and a child name built at run time + dangled in `supervise.which` and in the give-up reason. diff --git a/new_changelogs/20261028T225000Z-security-3382-key-copies-wiped.md b/new_changelogs/20261028T225000Z-security-3382-key-copies-wiped.md new file mode 100644 index 000000000..9988455d7 --- /dev/null +++ b/new_changelogs/20261028T225000Z-security-3382-key-copies-wiped.md @@ -0,0 +1,13 @@ +- **Loading a server's private key leaves no copy of it in freed memory + (#3382).** The pure TLS server wiped the key it kept, but the copies the + loading made on the way were freed as they were: the base64 decoder grew + its output from nothing, so each smaller copy of the key's DER went back + to the allocator; `parse_sec1` and `parse_pkcs8` freed the key's SEQUENCE; + the RSA import freed its SEQUENCE, the private exponent's INTEGER content + and the exponent's magnitude; and the key text was copied into strings, + which cannot be wiped. `pem.parse` now decodes the base64 where it lies in + the text into a buffer of its final size (new `pem.parse_at`, which the + key loader uses at the key's label), every intermediate buffer holding key + material is wiped as it is freed, and `rsa.key_from_pkcs1_der` builds its + key as `key_from_bytes` does, the private exponent copied byte by byte and + never through `std.bignum`: the `d` bignum of such a key is null. diff --git a/new_changelogs/20261028T230000Z-security-3376-bytes-wipe.md b/new_changelogs/20261028T230000Z-security-3376-bytes-wipe.md new file mode 100644 index 000000000..6d3e8bd7f --- /dev/null +++ b/new_changelogs/20261028T230000Z-security-3376-bytes-wipe.md @@ -0,0 +1,9 @@ +- **`bytes.wipe` and `bytes.wipe_free`: zeroing a secret that the compiler + cannot drop.** A buffer that held key material, a nonce or the text of a + private key was zeroed with a loop of `bytes.set` calls before it was + freed, a dead store the C compiler may remove once it inlines the calls, + and only up to the buffer's length. `bytes.wipe(b)` zeroes the whole + capacity through a call the compiler cannot see through (OpenSSL's + `OPENSSL_cleanse` technique), and keeps the buffer; `bytes.wipe_free(b)` + wipes, then frees. The cryptography modules' private wipe helpers and the + HTTP server's copies of the TLS key text use it (#3376). diff --git a/new_changelogs/20261028T231000Z-security-3376-verify-before-send.md b/new_changelogs/20261028T231000Z-security-3376-verify-before-send.md new file mode 100644 index 000000000..ea530d6ed --- /dev/null +++ b/new_changelogs/20261028T231000Z-security-3376-verify-before-send.md @@ -0,0 +1,14 @@ +- **The pure TLS server checks every signature before it sends it.** A + CertificateVerify signature computed wrongly, by a fault in the + arithmetic or a bit flipped in memory (a glitch, Rowhammer), can disclose + the private key: one faulty RSA-CRT signature factors the modulus, and + two signatures of one message under a deterministic nonce (RFC 6979 + ECDSA, Ed25519) give the key. Each signature is now verified against the + certificate's key, the one the client checks, before it is sent; one + that does not verify is wiped and never sent, and the handshake ends with + `internal_error`. On by default, for every scheme the server signs + (ECDSA P-256, P-384, P-521, rsa_pss_rsae_sha256/384/512, Ed25519). The + cost is one verification per handshake: about as much as the signature + for ECDSA and Ed25519, about a tenth of it for RSA. New in + `std.cryptography.tls13_cert`: `leaf_signature_holds` and + `verify_cert_verify_rsa_pss_scheme` (#3376). diff --git a/new_changelogs/20261028T232000Z-security-3376-hedged-nonces.md b/new_changelogs/20261028T232000Z-security-3376-hedged-nonces.md new file mode 100644 index 000000000..44aeb0420 --- /dev/null +++ b/new_changelogs/20261028T232000Z-security-3376-hedged-nonces.md @@ -0,0 +1,15 @@ +- **Hedged signing nonces, and the mTLS client checks its signature too.** + The TLS CertificateVerify signatures used deterministic nonces (RFC 6979 + for ECDSA, RFC 8032 for Ed25519), so two signatures of one message shared + one, and a fault in either gave the key away. Each signature now mixes 32 + fresh random bytes into the nonce: `ecc.ecdsa_sign_hedged` and the + curves' `ecdsa_sign_hedged` (RFC 6979 section 3.6, the extra input k'), + and `ed25519.sign_hedged`. Whatever those bytes hold, the nonce is no + weaker than the deterministic one, and a verifier cannot tell the + difference. `ecdsa_sign` and `ed25519.sign` stay deterministic and keep + their RFC vectors. The mTLS client (`tls13_client.connect_mtls`) now + checks its CertificateVerify against its certificate's key before + sending it, as the server does: one that does not verify ends the + handshake with `internal_error`, and no Finished follows. Before, it sent + the signature, and learnt of the server's `decrypt_error` only on its + first read (#3376). diff --git a/new_changelogs/20261028T233000Z-security-3376-shield.md b/new_changelogs/20261028T233000Z-security-3376-shield.md new file mode 100644 index 000000000..47eab0f7d --- /dev/null +++ b/new_changelogs/20261028T233000Z-security-3376-shield.md @@ -0,0 +1,10 @@ +- **`std.cryptography.shield`: a long-lived secret kept encrypted in + memory.** `shield.wrap(secret)` seals a private key with ChaCha20-Poly1305 + under SHA-512 of a fresh 16 KiB random pre-key and wipes the plaintext; + `shield.unwrap(handle)` gives it back for one use, and refuses it when a + bit of the sealed key or the pre-key changed in memory (the tag); and + `shield.free` wipes both. A memory-reading attack (RAMBleed, a + Spectre-class read, a core dump) then needs the whole 16 KiB pre-key + without an error, as for OpenSSH's shielded keys since 8.2. An unwrap + costs about 0.1 ms. ChaCha20-Poly1305 now also wipes its keystream and + one-time Poly1305 key before freeing them (#3376). diff --git a/new_changelogs/20261028T234000Z-security-3376-server-key-shielded.md b/new_changelogs/20261028T234000Z-security-3376-server-key-shielded.md new file mode 100644 index 000000000..a13a755f0 --- /dev/null +++ b/new_changelogs/20261028T234000Z-security-3376-server-key-shielded.md @@ -0,0 +1,9 @@ +- **The pure TLS server keeps its private key shielded.** The key a server + loads once and keeps for its life (#3362) is now held by + `std.cryptography.shield`, sealed behind a 16 KiB random pre-key, and in + the clear only for each signature; a key whose sealed form changed in + memory is refused, and the handshake ends with `internal_error`. An RSA + key is shielded as its PKCS#1 DER and rebuilt for each signature. For + code that read `tls13_server.ServerKey`'s fields: `secret` and `rsa` are + gone, and `shielded` holds the shield handle; `load_private_key`, + `free_private_key` and `accept` are unchanged (#3376). diff --git a/new_changelogs/20261028T234500Z-fixed-3390-supervise-restart-once.md b/new_changelogs/20261028T234500Z-fixed-3390-supervise-restart-once.md new file mode 100644 index 000000000..fec7ebf8f --- /dev/null +++ b/new_changelogs/20261028T234500Z-fixed-3390-supervise-restart-once.md @@ -0,0 +1,10 @@ +- **A supervisor restarts each child once per restart, in start order + (#3390).** Under `one_for_all` and `rest_for_one`, a death while a + restart still waited for a killed sibling's `Down` queued every child of + its restart again, the waiting ones included. The duplicate came round + after the child was already restarted and gave the supervisor up ("child + c cannot be restarted"), the children restarted out of start order (c + before a), and the 32-slot ring could overflow and overwrite entries. The + queue is now one pending mark per child: a child is marked at most once, + whichever deaths ask for it, the first marked in start order is restarted + next, and nothing can overflow. diff --git a/new_changelogs/20261028T235000Z-fixed-3387-ask-message-slot.md b/new_changelogs/20261028T235000Z-fixed-3387-ask-message-slot.md new file mode 100644 index 000000000..c7661d3ac --- /dev/null +++ b/new_changelogs/20261028T235000Z-fixed-3387-ask-message-slot.md @@ -0,0 +1,12 @@ +- **An ask's reply slot rides in its own message only.** The ask handed its + slot to the send through a thread-local. The send can step its target + inline, and every message with fields that handler sent read the slot + too: its receiver replied into it or failed it, the slot was let go + twice, and the asker read freed memory (a crash on every run of a handler + that asks an idle actor of its own core which sends before it replies, + #3395). Inside a library call, an ask whose message copy found no memory + unwound from inside the send and left the slot set for every later send + on the thread, and its core marked as asking, so no actor moved onto it + again (#3387). The ask now builds its own message; with no memory for it + the ask fails at once with "out of memory", nothing sent and nothing left + set, and the call ends with `AETHER_OUT_OF_MEMORY`. diff --git a/new_changelogs/20261028T235500Z-fixed-3388-failed-call-zero-value.md b/new_changelogs/20261028T235500Z-fixed-3388-failed-call-zero-value.md new file mode 100644 index 000000000..6f7e95237 --- /dev/null +++ b/new_changelogs/20261028T235500Z-fixed-3388-failed-call-zero-value.md @@ -0,0 +1,11 @@ +- **A failed call returns its zero value on every path.** A library call + that ran to its return with a stop noted after its last safe point (an + allocation it survived, a JSON parse that stopped, the deadline's flag) + ended with that outcome but returned its real result ("hello7" with + `AETHER_OUT_OF_MEMORY`), which a host following `libaether.h` (release + only what an `AETHER_OK` call returned) leaked. The stub now returns the + zero value and releases what the function returned: a string, or the + strings and closures of a struct or closure by value. An Aether importer + of a library with its own runtime gives such a string back to the + library before it raises the failure, also for a library built before + this change (#3388). diff --git a/new_changelogs/20261029T000000Z-fixed-3389-nested-step-claim.md b/new_changelogs/20261029T000000Z-fixed-3389-nested-step-claim.md new file mode 100644 index 000000000..077d7dc82 --- /dev/null +++ b/new_changelogs/20261029T000000Z-fixed-3389-nested-step-claim.md @@ -0,0 +1,12 @@ +- **A step nested in a supervisor's restart leaves the restart alone.** A + send from a child's factory to an idle actor of the same core steps that + actor inline, and the nested step took the supervisor's restart claim for + its own: its panic undid the claim, so the supervisor gave up ("the + restart of w failed") with an orphaned Worker made; its spawn of the + child's type took the child's block, so the child's ref reached a Worker + its factory never configured; and any spawn of it inside a factory + (supervise.start in a handler) was taken for the child. Every step now + puts the thread's claim, reborn block and spawn brackets aside at entry + and gives them back at exit, and its panic undoes only what it made; the + in-step flags the signal handler reads come back too, where an inner step + had left them cleared for the rest of the outer handler (#3389). diff --git a/new_changelogs/20261029T000500Z-fixed-3393-nested-step-sandbox.md b/new_changelogs/20261029T000500Z-fixed-3393-nested-step-sandbox.md new file mode 100644 index 000000000..745cf3a9d --- /dev/null +++ b/new_changelogs/20261029T000500Z-fixed-3393-nested-step-sandbox.md @@ -0,0 +1,10 @@ +- **A receiver stepped inside an enforce block runs without its grants.** A + message an enforce block sends is handled with no grants, as an actor's + thread handles it, but a send to an idle actor of the same core steps it + inline, on the block's thread, and it ran with the block's levels: a + deny-all block had the receiver's reads refused, or not, depending on + whether the step happened inline. A step now puts the thread's levels + aside (in every runtime copy that shares the count) and gives them back + when it ends, so the receiver is checked the same whichever thread steps + it, and the block's own code after the send is held to the block again + (#3393). diff --git a/new_changelogs/20261029T001000Z-fixed-3392-nested-step-execs.md b/new_changelogs/20261029T001000Z-fixed-3392-nested-step-execs.md new file mode 100644 index 000000000..a45cc2cd5 --- /dev/null +++ b/new_changelogs/20261029T001000Z-fixed-3392-nested-step-execs.md @@ -0,0 +1,12 @@ +- **An actor stepped inside a library call runs outside it.** On the + cooperative runtime (wasm, ESP32) an export's ask polls the actors, so the + asked actor runs on the call's thread, inside the call; on threads a + send's inline step can run one inside a call too. The call's deadline was + then raised in the actor's loop and caught by the actor's barrier: the + actor died ("the call's deadline passed") and the interruption stayed + marked, so the next call's `try` could not catch an ordinary panic and the + call ended with the stale outcome (#3392). The actor also read the caller + info the host had set for the call (#3396). A step now puts the thread's + calls aside, with their deadline, cancel, noted stop, interruption and + caller info, and gives them back when it ends: the actor runs as on a + core's thread, and the call is cut at its own next safe point after. diff --git a/new_changelogs/20261029T001500Z-fixed-3391-coop-no-reentry.md b/new_changelogs/20261029T001500Z-fixed-3391-coop-no-reentry.md new file mode 100644 index 000000000..f18eabac6 --- /dev/null +++ b/new_changelogs/20261029T001500Z-fixed-3391-coop-no-reentry.md @@ -0,0 +1,8 @@ +- **The cooperative scheduler never steps an actor inside its own + handler.** An ask polls every actor until its reply, and the poll stepped + the asking actor too: its next message was handled inside the handler + that asked, its state changing under it, and a `stop()` or a panic there + finalized the actor (its Down sent, its queue dropped) while the first + handler still ran. An actor whose step is on the stack is now left alone, + as threads leave an actor stepping: its next message waits for the + handler to return (#3391). diff --git a/new_changelogs/20261029T001500Z-fixed-3394-link-chain-iterative.md b/new_changelogs/20261029T001500Z-fixed-3394-link-chain-iterative.md new file mode 100644 index 000000000..57dae7f39 --- /dev/null +++ b/new_changelogs/20261029T001500Z-fixed-3394-link-chain-iterative.md @@ -0,0 +1,9 @@ +- **A chain of linked actors ends in the same stack depth however long it + is (#3394).** Where a death is finalized as soon as it is seen (the + cooperative scheduler, and the threaded one once it is stopped), the end + a link passed on ran inside the previous actor's finalize, so a chain of + linked actors ended one inside the other, a few frames per actor: killing + the head of 8000 linked actors overflowed a 256 KiB stack (wasm, an + ESP32 task or a host's thread have no more). An end a link passes on + while the thread is still making another now waits in the thread's queue + and is made after it; 100000 linked actors end on a 64 KiB stack. diff --git a/new_changelogs/20261029T002000Z-added-2954-execution-accounts.md b/new_changelogs/20261029T002000Z-added-2954-execution-accounts.md new file mode 100644 index 000000000..63450fbc0 --- /dev/null +++ b/new_changelogs/20261029T002000Z-added-2954-execution-accounts.md @@ -0,0 +1,17 @@ +- **Execution accounts: a budget that belongs to a script, not to a call + or a thread.** A host that runs many scripts on one thread, each + yielding and resumed later, gives each an account + (`aether_exec_account_new`, `aether_exec_use`; `host.account_new` and + friends in std.host): every cap-aware allocation made while it is in use + is charged to it and its parent chain and refused past its allowance + (`AETHER_MEMORY_CAP`), a block credits the account that allocated it + wherever it is freed, and its deadline counts wall time from its making + (`AETHER_DEADLINE_ELAPSED`) or only the time spent in its calls + (`AETHER_DEADLINE_ACTIVE`). A parent only narrows; an account is in use + on one thread at a time (`aether_exec_use` returns -1 for one in use on + another); an execution puts back the account it began with; an actor + stepped inside code that has one in use is not charged to it. Libraries + with a runtime of their own share the accounts of the first loaded copy + (one lock, one table), and a copy of another layout refuses them, saying + so once. While no account exists an allocation still tests one word + (#2954). diff --git a/new_changelogs/20261029T003000Z-fixed-3403-barrier-drop-contained.md b/new_changelogs/20261029T003000Z-fixed-3403-barrier-drop-contained.md new file mode 100644 index 000000000..5307a88f4 --- /dev/null +++ b/new_changelogs/20261029T003000Z-fixed-3403-barrier-drop-contained.md @@ -0,0 +1,11 @@ +- **A drop that panics after a handler panicked stays in the step.** When a + handler panics, its step drops the message it ran on and fires the death + hook, both after the step's frame is gone. A field's drop function that + panicked there jumped past the rest of the step: on a core's own thread + the program ended; stepped inline inside another handler the panic + landed there, with the inner step's context still put aside, so the step + lock stayed held (the next ask hung) and an enforce block whose `try` + caught it ran on with its levels put aside, unenforced. The drop and the + hook now run in containment frames of their own (`aether_contain`): a + panic in one is reported through the death hook, and the step gives its + context back whatever happens (#3403). diff --git a/new_changelogs/20261029T003500Z-fixed-3404-contained-interruption.md b/new_changelogs/20261029T003500Z-fixed-3404-contained-interruption.md new file mode 100644 index 000000000..7daa316e5 --- /dev/null +++ b/new_changelogs/20261029T003500Z-fixed-3404-contained-interruption.md @@ -0,0 +1,10 @@ +- **An interruption inside an actor's end goes on to its call.** The frame + that runs an ending actor's message drops (#3401) caught every panic, + the call's interruption included: when a call ended an actor there and + then (a kill on the cooperative runtime) and a drop ran a loop past the + call's deadline, the interruption was reported as the drop's panic and + left marked, so the call's next ordinary panic, in a later call's `try`, + was raised again as the interruption. A containment frame (`aether_contain`, + which the step barrier's drop uses too) now takes the interruption off + its way and notes it as the call's stop: the call ends with it at its + next safe point, or when it returns (#3404). diff --git a/new_changelogs/20261029T004000Z-fixed-3406-owned-place-unwind.md b/new_changelogs/20261029T004000Z-fixed-3406-owned-place-unwind.md new file mode 100644 index 000000000..0ef4ecc02 --- /dev/null +++ b/new_changelogs/20261029T004000Z-fixed-3406-owned-place-unwind.md @@ -0,0 +1,7 @@ +- **A panic no longer leaks an owned pointer it skips the release of.** A + place of an owned pointer type (`type T = owned ptr drop f`), a function's + local or an arm's binding of an owned message field, was released at its + exits only: a panic skipped them, and the buffer leaked with its drop never + run (an arm that bound the field and panicked leaked it every time). Every + such place is now in the unwind journal while it lives, by one rule, and a + panic releases it as its exit would (#3406). diff --git a/new_changelogs/20261029T020000Z-fixed-3398-pointer-store-publishes.md b/new_changelogs/20261029T020000Z-fixed-3398-pointer-store-publishes.md new file mode 100644 index 000000000..f7a0715c1 --- /dev/null +++ b/new_changelogs/20261029T020000Z-fixed-3398-pointer-store-publishes.md @@ -0,0 +1,12 @@ +- **Every store through a pointer, a slice or an actor's reference + publishes what it stores (#3398).** A string field reached through a + pointer was published (#2919), so two actors that reached it counted + its string atomically; a struct (`p.doc = d`), an element of an array + field (`p.docs[i] = d`, `p.names[i] = s`), a whole array field + (`p.names = arr`), an optional in a struct (`p.inner.tag = s`), a + slice's element (`xs[i] = d`) and the same parts of another actor's state + (`k.kept.doc = d`) took their strings with plain counts, which two + actors then raced on. One rule now decides it for every store emitter. + An element store also evaluates the element's place once: a string or + closure element's index used to be evaluated twice. The ownership grid + has a place for a field of a heap box reached through a pointer (P4H). diff --git a/new_changelogs/20261029T021500Z-fixed-3399-kill-before-start.md b/new_changelogs/20261029T021500Z-fixed-3399-kill-before-start.md new file mode 100644 index 000000000..c9827ea1f --- /dev/null +++ b/new_changelogs/20261029T021500Z-fixed-3399-kill-before-start.md @@ -0,0 +1,10 @@ +- **An actor ended before `scheduler_start` is finalized at once + (#3399).** `scheduler_actor_end` took an initialized scheduler for a + running one and posted the finalize to the actor's core, but + `scheduler_init` marks the scheduler initialized before + `scheduler_start` makes the core threads. A C host that killed an actor + between the two waited for a start to finalize it, and one that never + started the cores never did: no `Down` went out, an actor linked to it + lived on, and `scheduler_wait` and `scheduler_shutdown` returned without + it. The request is posted only once the threads are started; before, + the death is finalized on the killing thread, as after a stop. diff --git a/new_changelogs/20261029T024500Z-fixed-3400-link-drain-panic.md b/new_changelogs/20261029T024500Z-fixed-3400-link-drain-panic.md new file mode 100644 index 000000000..aaa50e48e --- /dev/null +++ b/new_changelogs/20261029T024500Z-fixed-3400-link-drain-panic.md @@ -0,0 +1,11 @@ +- **A panic out of a link's end no longer stops the thread's later links + (#3400, a follow-up to #3394).** Ending an actor drops the messages + that wait for it, and a dropped message's release runs the drop + function of an owned field (#3142), which is the program's code and may + panic. Where a death is finalized as soon as it is seen (the cooperative + scheduler, the threaded one once stopped), such a panic jumped out of + the queue that makes the ends links pass on and left the thread marked + as making them: every later end a link passed on there was queued and + never made. The queue is journaled with the unwind journal now, so a + panic that leaves it puts it back empty; the ends it had not made yet + are given up, as an unwinding recursion gave them up. diff --git a/new_changelogs/20261029T032000Z-fixed-3407-account-free-in-use.md b/new_changelogs/20261029T032000Z-fixed-3407-account-free-in-use.md new file mode 100644 index 000000000..0ea9c3777 --- /dev/null +++ b/new_changelogs/20261029T032000Z-fixed-3407-account-free-in-use.md @@ -0,0 +1,12 @@ +- **Freeing an execution account that is in use no longer leaves the + runtime writing into freed memory.** `aether_exec_account_free` (and + `host.account_free`) freed the account while the thread's account in use, + the account's own thread claim and every execution that began under it + still named it: `host.account_in_use()` went on returning it, the next + cap-aware allocation charged the freed block, and the execution's return + charged its time to it and put it back in use. Now freeing an account in + use on the calling thread ends its use; one in use on another thread is + charged nothing more there, and that thread lets go of it at its next + allocation, call or `aether_exec_use`; an execution that began under it + holds it until it returns and then puts no account back. The block goes + when the last of them lets go (#3407). diff --git a/new_changelogs/20261029T033000Z-fixed-3408-account-free-credits-parent.md b/new_changelogs/20261029T033000Z-fixed-3408-account-free-credits-parent.md new file mode 100644 index 000000000..58dd62bd7 --- /dev/null +++ b/new_changelogs/20261029T033000Z-fixed-3408-account-free-credits-parent.md @@ -0,0 +1,10 @@ +- **Freeing an execution account with live blocks no longer leaves its + parent charged for them.** The freed account's blocks were detached but + its ancestors kept their charge for them, even after the blocks + themselves were freed, so a plugin account that freed its scripts' + accounts while the host still held their data lost allowance each time + and tripped `TRIPPED_MEMORY` in the end. Freeing a parent under live + children left the same charge behind on its own parent. Now freeing an + account credits its ancestors with what its own live blocks were + charged, and its children go under its parent, which their blocks were + charged to all along, and keep narrowing under it (#3408). diff --git a/new_changelogs/20261029T034000Z-fixed-3409-active-time-once.md b/new_changelogs/20261029T034000Z-fixed-3409-active-time-once.md new file mode 100644 index 000000000..70297495b --- /dev/null +++ b/new_changelogs/20261029T034000Z-fixed-3409-active-time-once.md @@ -0,0 +1,12 @@ +- **An ACTIVE execution account is charged once for nested calls.** Every + call under an account charged its whole duration to the account and its + parents, so a call a host service made inside another call under the + same account (or under a child of it) was charged twice: an account with + a 300 ms ACTIVE budget tripped after 200 ms spent in such a nested call. + Now a thread times each ACTIVE account from the start of the outermost + call open under it to that call's end, and a call nested inside adds no + time of its own; the deadline a nested call gets counts the time open so + far. The thread's list of the accounts it times has no bound: a call + whose account cannot be timed for want of memory ends at once with + `AETHER_OUT_OF_MEMORY` (the allocation test hook covers that growth), so + a budget is never left unenforced (#3409). diff --git a/new_changelogs/20261029T035000Z-fixed-3410-accounts-lock-cost.md b/new_changelogs/20261029T035000Z-fixed-3410-accounts-lock-cost.md new file mode 100644 index 000000000..7778c3f1f --- /dev/null +++ b/new_changelogs/20261029T035000Z-fixed-3410-accounts-lock-cost.md @@ -0,0 +1,12 @@ +- **Execution accounts no longer cost threads that use none, and freeing + one is linear.** While any account existed, every cap-aware allocation + and free on every thread (actor cores, HTTP workers) took the accounts' + lock, a test-and-set loop with no pause, and probed their table; and + freeing an account rescanned the table from the start after each of its + blocks, under that lock (40,000 blocks took 1.1 s, with every allocation + in the process waiting). Now a thread with no account in use asks a + filter of the addresses the accounts recorded and takes no lock unless + its block may be one of them; freeing an account is one pass over the + table (40,000 blocks: 0.5 ms); and a waiter on the lock pauses, then + yields, instead of spinning hot. emit-lib.md gives the cost while an + account exists (#3410). diff --git a/new_changelogs/20261029T040000Z-fixed-3401-finalize-drop-panic.md b/new_changelogs/20261029T040000Z-fixed-3401-finalize-drop-panic.md new file mode 100644 index 000000000..f4c91d7fa --- /dev/null +++ b/new_changelogs/20261029T040000Z-fixed-3401-finalize-drop-panic.md @@ -0,0 +1,11 @@ +- **A panic in a dropped message's release no longer stops an actor's end + (#3401).** An actor's end drops the messages that wait for it, and a + dropped message's release runs the drop function of an owned field + (#3142), which is the program's code. Where a death is finalized as soon + as it is seen (the cooperative scheduler, a stopped threaded one), a + panic in it jumped out of the finalize: the messages after it were not + released, and the actor's watchers and links were never told. Each such + release runs in a frame of its own now, on every path that finalizes a + death; a panic in it is reported through the death hook with the actor's + id and the panic's reason, as the step barrier reports a panic in the + release after a handler, and the drain and the notice go on. diff --git a/new_changelogs/20261029T040000Z-fixed-3411-unloaded-copy-unregisters.md b/new_changelogs/20261029T040000Z-fixed-3411-unloaded-copy-unregisters.md new file mode 100644 index 000000000..db472c91f --- /dev/null +++ b/new_changelogs/20261029T040000Z-fixed-3411-unloaded-copy-unregisters.md @@ -0,0 +1,10 @@ +- **An unloaded library's runtime copy is no longer written into by the + execution accounts.** Every runtime copy registered its allocation + fast-path word with the accounts it shares at its first call and never + left: once a plugin library with a runtime of its own was unloaded, + making the first account (or freeing the last) wrote into its unmapped + image. A copy now leaves the accounts when it is unloaded. The list of + copies also grows as needed, where a 65th copy was refused and then + never told that an account was in use, so its allocations went + uncharged; a copy that cannot join the list asks the accounts on every + allocation instead (#3411). diff --git a/new_changelogs/20261029T041000Z-fixed-3412-unknown-parent-refused.md b/new_changelogs/20261029T041000Z-fixed-3412-unknown-parent-refused.md new file mode 100644 index 000000000..dc0971316 --- /dev/null +++ b/new_changelogs/20261029T041000Z-fixed-3412-unknown-parent-refused.md @@ -0,0 +1,6 @@ +- **An execution account made under a parent that is not live is refused.** + `aether_exec_account_new` (and `host.account_new`) dropped a parent it + did not hold, one the host had freed or one another runtime copy made + without sharing the accounts, and made the account with no parent at + all: unbounded by the limits meant to narrow it. It now returns NULL + for such a parent, so a child is never wider than its parent (#3412). diff --git a/new_changelogs/20261029T045000Z-fixed-make-install-release-link.md b/new_changelogs/20261029T045000Z-fixed-make-install-release-link.md new file mode 100644 index 000000000..8b5264c33 --- /dev/null +++ b/new_changelogs/20261029T045000Z-fixed-make-install-release-link.md @@ -0,0 +1,6 @@ +- **`make install` links again.** The release compiler it installs was + linked from a list of runtime files of its own, which lacked + `aether_unwind.c` (and four more the development compiler links) once the + cap-aware allocators began reading the execution frames, so its link + failed on `aether_frames_here`. It now links the same runtime pieces as + the development compiler, one list for both. diff --git a/new_changelogs/20261029T050000Z-added-2957-abi-policy.md b/new_changelogs/20261029T050000Z-added-2957-abi-policy.md new file mode 100644 index 000000000..a41907884 --- /dev/null +++ b/new_changelogs/20261029T050000Z-added-2957-abi-policy.md @@ -0,0 +1,19 @@ +- **What a shipped library promises across Aether versions, and where an + unsupported pair is refused** (docs/abi-policy.md). The host ABI is + versioned (`AETHER_HOST_ABI`, level 1; a library with none recorded is + level 0) and only grows within a level. Catalogs are schema 1.11: they + record the library's `host_abi`, the runtime ABI it was compiled for + (`runtime_abi`, `AETHER_RUNTIME_ABI_ID`, which the shared runtime reports + through `aether_runtime_abi()`), and what its importer must do + (`requires`). The importer refuses a catalog of another major, one that + requires something it does not know (naming it, both versions and the + fix), and a shared-runtime library made for another runtime or recording + none; `ae lib-info` prints all of it. A library loaded against another + runtime than its own runs nothing: every export returns its zero value + with the new outcome `AETHER_INCOMPATIBLE` (6, `host.INCOMPATIBLE`) and + the reason, and `aether__check(&why)` asks first; its generated C + compiled against another Aether's headers does not compile. The sandbox + levels' rendezvous carries its size (version 2), and a copy that finds + another layout says so once. `AETHER_FLAG_LOCKFREE_POOLS` and + `aether_message_pool_stats` are marked for removal at host ABI level 2 + (#2957). diff --git a/new_changelogs/20261029T050000Z-security-3402-primitives-wipe-state.md b/new_changelogs/20261029T050000Z-security-3402-primitives-wipe-state.md new file mode 100644 index 000000000..229cd1759 --- /dev/null +++ b/new_changelogs/20261029T050000Z-security-3402-primitives-wipe-state.md @@ -0,0 +1,12 @@ +- **Hashes, MACs and ciphers wipe their own state.** Wiping a key was not + enough while the primitives that used it freed their state as it was: + after one Ed25519 signature its seed was in SHA-512's freed block, an + HMAC's key was in its freed padded key, and a shield wrap left the AEAD + key (a SHA-512 digest) in a freed SHA-2 context. `sha2`, `sha3`, `hmac`, + `hkdf`, `aes` and `chacha20poly1305` now free every buffer with + `bytes.wipe_free`, wipe their context structs before freeing them, and + the C AES wipes its round keys and stack copies with `aether_wipe` (a + `memset` there was a dead store). New: `mem.wipe(dst, n)`, the + raw-memory form of `bytes.wipe`. No cost shows at the timer's + resolution: SHA-256 of 1 MiB 8 ms, of 64 bytes 1 us, HMAC-SHA-256 of 64 + bytes 3 us, before and after (#3402). diff --git a/new_changelogs/20261029T051000Z-added-2957-abi-matrix.md b/new_changelogs/20261029T051000Z-added-2957-abi-matrix.md new file mode 100644 index 000000000..1f38bfe13 --- /dev/null +++ b/new_changelogs/20261029T051000Z-added-2957-abi-matrix.md @@ -0,0 +1,10 @@ +- **A cross-version matrix runs this Aether's libraries and hosts against + the release before it.** `tests/integration/abi_matrix` builds a library + with both versions and runs C hosts of either header, two libraries of + different versions in one process (an execution account suspended and + resumed beside the old copy), Aether importers both ways, and the shared + runtime pairs, with the unsupported ones refused. CI runs it on Linux + and macOS against the latest published release on every pull request, + and the release workflow on every platform before it publishes; + `AETHER_ABI_OLD_HOME` names an extracted release to run it by hand + (#2957). diff --git a/new_changelogs/20261029T060000Z-changed-step-one-word.md b/new_changelogs/20261029T060000Z-changed-step-one-word.md new file mode 100644 index 000000000..37dbd0fc0 --- /dev/null +++ b/new_changelogs/20261029T060000Z-changed-step-one-word.md @@ -0,0 +1,10 @@ +- **A plain actor step reads one word to know it has nothing to put + aside.** The step barrier looked at each part of the context it puts + aside (spawn claims, execution accounts, sandbox levels) behind a load of + its own, at both ends of every step, which cost send-heavy actors about 3% + (ping_pong 11.96 against 11.65 M msg/s). The parts' globals are now bits + of one word, `aether_caps_slow`'s step bits (the restart claims and spawn + captures are counted there too), read once at each end with one predicted + branch, and the parts are looked at out of line only when it is set: + ping_pong is at 11.87, thread_ring, fork_join, counting and skynet are + where they were before the step-context rule. diff --git a/new_changelogs/20261029T060000Z-fixed-3405-tls-keyupdate-parks.md b/new_changelogs/20261029T060000Z-fixed-3405-tls-keyupdate-parks.md new file mode 100644 index 000000000..a65f5bb0e --- /dev/null +++ b/new_changelogs/20261029T060000Z-fixed-3405-tls-keyupdate-parks.md @@ -0,0 +1,14 @@ +- **A TLS keep-alive connection that sends a KeyUpdate no longer holds a + worker.** A KeyUpdate (or another record with no application data) on an + idle keep-alive connection was taken as the next request: the pure TLS + backend's pending check counted any whole buffered record, and on both + backends a socket that turned readable sent the worker into a read that + handled the record and then waited for the whole idle timeout (30 s by + default). With every worker so held, other clients waited that long. Now + a TLS connection whose socket turns readable is asked, without waiting, + whether a read would find data: the pure backend handles the whole + records the socket holds, and OpenSSL peeks with the socket non-blocking. + One with none is parked, or parked again when the lot woke it, for what is + left of its idle time. The pure server also reports its socket's receive + timeout after the handshake as a timeout, keeping the connection, instead + of retrying it for about 5 s and then failing the connection (#3405). diff --git a/new_changelogs/20261029T061000Z-fixed-3419-step-word-sandbox-bit.md b/new_changelogs/20261029T061000Z-fixed-3419-step-word-sandbox-bit.md new file mode 100644 index 000000000..df8ae9a32 --- /dev/null +++ b/new_changelogs/20261029T061000Z-fixed-3419-step-word-sandbox-bit.md @@ -0,0 +1,10 @@ +- **A program that reads the environment or a file keeps its actor steps + plain.** The step barrier's sandbox bit was set by the first sandbox + check of any kind, a getenv or a file read with no enforce block + anywhere, so most programs paid the barrier's slow look at every step. + The bit is now set only when a sandbox level may be in force where the + copy's steps can see it: when the first thread enters a level counted by + this copy, or when the copy shares another copy's count. An actor of a + library with its own runtime, stepped inside an enforce block of another + copy, now runs without that block's levels even when the library had + made no sandbox check before (#3419). diff --git a/new_changelogs/20261029T070000Z-security-3414-crypto-modules-wipe.md b/new_changelogs/20261029T070000Z-security-3414-crypto-modules-wipe.md new file mode 100644 index 000000000..3affd9faa --- /dev/null +++ b/new_changelogs/20261029T070000Z-security-3414-crypto-modules-wipe.md @@ -0,0 +1,12 @@ +- **Every cryptography module wipes what it frees.** #3402 made the hash, + MAC and cipher modules wipe their state; the key exchange and the key + schedule still freed secrets as they were: X25519's ladder its clamped + copy of the private scalar on every exchange, the TLS key schedule its + "derived" salts (one of which gives the master secret, so every + application traffic key), and `tls13_ks.free_secret` zeroed with a loop + the compiler may drop. The rule is now the whole tree's: every module + under `std/cryptography` frees every `bytes` buffer with + `bytes.wipe_free`, and `make check-crypto-wipe` (run by `make ci`) fails + on a plain `bytes.free` there. Context structs, Argon2's working matrix, + ML-KEM's coefficient arrays, BLAKE3's buffers and RSA's limb scratch are + wiped with `mem.wipe` before they are freed (#3414). diff --git a/new_changelogs/20261029T090000Z-fixed-3413-owned-drop-thunk.md b/new_changelogs/20261029T090000Z-fixed-3413-owned-drop-thunk.md new file mode 100644 index 000000000..3d5d614a8 --- /dev/null +++ b/new_changelogs/20261029T090000Z-fixed-3413-owned-drop-thunk.md @@ -0,0 +1,9 @@ +- **An owned type whose drop returns a value or has a typed C prototype + compiles again.** The checker takes any one-parameter `@frees ptr` + function as an owned type's drop, whatever it returns. The unwind journal + entry of an owned place (#3406) passed the drop itself where a `void` + function of a `void*` was expected, so a drop such as `rel(p: @frees ptr) + -> int`, or C's `fclose` (`int fclose(FILE*)`), failed to compile with + "incompatible pointer type", in a local and in an arm's binding alike. The + journal now holds a thunk per drop function, which calls the drop as the + scope's exit does (#3413). diff --git a/new_changelogs/20261029T091500Z-fixed-3415-contained-interruption-sampled.md b/new_changelogs/20261029T091500Z-fixed-3415-contained-interruption-sampled.md new file mode 100644 index 000000000..492f5a1c5 --- /dev/null +++ b/new_changelogs/20261029T091500Z-fixed-3415-contained-interruption-sampled.md @@ -0,0 +1,11 @@ +- **An ordinary panic during an interruption's unwind no longer ends the + interruption.** A containment frame (`aether_contain`, which runs an + ending actor's message drops) told the call's interruption from a panic by + whether an interruption was marked once it had caught one. When the drop + ran while the interruption was already unwinding the call (a `defer` the + unwind ran killed an actor, which the cooperative runtime finalizes there + and then) and panicked, the drop's panic was taken for the interruption, + which was taken off its way: a `try` the unwind reached ran its handler, + and a call with none ended `AETHER_PANICKED` instead of `AETHER_DEADLINE`. + The frame now puts an interruption on its way aside while its code runs, + and tells what it caught by what the code raised (#3415). diff --git a/new_changelogs/20261029T093000Z-fixed-3416-step-drops-own-context.md b/new_changelogs/20261029T093000Z-fixed-3416-step-drops-own-context.md new file mode 100644 index 000000000..2daa14f48 --- /dev/null +++ b/new_changelogs/20261029T093000Z-fixed-3416-step-drops-own-context.md @@ -0,0 +1,12 @@ +- **A receiver stepped inline runs its drops in its own context again.** + Since #3403 the step gave the enclosing context back before it dropped + the message a panicking handler ran on, and before it finalized an actor + its step ended, whose queued messages' drops are the receiver's code. An + actor stepped inline (a send or an ask of an idle actor of the same core, + a cooperative ask's poll) then ran those drops in the sender's context: a + drop inside a deny-all enforce block read PATH as refused, where on the + actor's own thread it reads it, a drop inside a library call was cut by + the call's deadline, and its allocations were charged to the call's + account. The drops, the finalize and the death hook now run in the step's + context, each in a containment frame, and the context is given back + after them (#3416). diff --git a/new_changelogs/20261029T110000Z-fixed-3417-end-hook-contained.md b/new_changelogs/20261029T110000Z-fixed-3417-end-hook-contained.md new file mode 100644 index 000000000..546d35b35 --- /dev/null +++ b/new_changelogs/20261029T110000Z-fixed-3417-end-hook-contained.md @@ -0,0 +1,9 @@ +- **An actor's end goes on past a death hook that panics.** The drop of a + message an ending actor leaves undelivered runs in a frame of its own + (#3401), and a panic in it is reported to the death hook, which ran bare + there. A hook that panicked then left the finalize halfway: the messages + it had drained were never counted, so `scheduler_shutdown` and + `wait_for_idle()` waited for them forever, its links and watchers were + never told, and the finalize, marked done, never ran again. The hook now + runs in a frame of its own, as the step barrier's does: its panic is + reported on stderr and the end goes on (#3417). diff --git a/new_changelogs/20261029T120000Z-fixed-3420-runtime-list-read-where-linked.md b/new_changelogs/20261029T120000Z-fixed-3420-runtime-list-read-where-linked.md new file mode 100644 index 000000000..08b0cece4 --- /dev/null +++ b/new_changelogs/20261029T120000Z-fixed-3420-runtime-list-read-where-linked.md @@ -0,0 +1,9 @@ +- **`ae build --target=wasm` and `ae check` no longer need the native + runtime list.** `ae` read MANIFEST, the list of runtime sources a build + without libaether.a compiles, as soon as it found the toolchain, for every + command, and exited when it could not. Only a native link reads it: a wasm + build compiles `runtime/coop_runtime_sources.txt`, and a check compiles no + runtime. So in a tree `make compiler ae` built, or an install without + libaether.a and MANIFEST, a wasm build stopped before emcc. The list is now + read where a native link needs it, with the same message when it cannot + be (#3420). diff --git a/new_changelogs/20261029T120100Z-fixed-3421-death-notice-entries-journaled.md b/new_changelogs/20261029T120100Z-fixed-3421-death-notice-entries-journaled.md new file mode 100644 index 000000000..a63bc5b2a --- /dev/null +++ b/new_changelogs/20261029T120100Z-fixed-3421-death-notice-entries-journaled.md @@ -0,0 +1,7 @@ +- **A death notice cut by a panic no longer leaks its entries.** The + notice of an actor's death takes its watch entries off the actor's list + and runs each one; a link's entry ends the linked actor, which can panic. + A panic that left the notice skipped the free of the entries it had taken. + They are now in the unwind journal from when the notice takes them until + it frees them, as the link drain's queue already was, so the unwind frees + them (#3421). diff --git a/new_changelogs/20261029T120200Z-fixed-3422-active-time-test-no-sleep-bound.md b/new_changelogs/20261029T120200Z-fixed-3422-active-time-test-no-sleep-bound.md new file mode 100644 index 000000000..c5b1e8c80 --- /dev/null +++ b/new_changelogs/20261029T120200Z-fixed-3422-active-time-test-no-sleep-bound.md @@ -0,0 +1,7 @@ +- **The nested ACTIVE-time test no longer fails on a slow macOS runner.** + `accounts_active_time_once_when_nested` (#3409) gave two sleeps 90 ms of + room under the account's budget, and on macOS CI they overran by more. + It now checks the time charged against the wall time the nested call + took, on the clock the runtime charges by, so a double charge still fails + it on any host and a long sleep cannot; the cut past the budget is + asserted with a margin an overrun only widens (#3422). diff --git a/new_changelogs/20261029T130000Z-fixed-3425-alloc-helpers-maybe-unused.md b/new_changelogs/20261029T130000Z-fixed-3425-alloc-helpers-maybe-unused.md new file mode 100644 index 000000000..d92f83e67 --- /dev/null +++ b/new_changelogs/20261029T130000Z-fixed-3425-alloc-helpers-maybe-unused.md @@ -0,0 +1,6 @@ +- **Generated C compiles with `clang -Wall -Werror` again when it allocates + nothing.** The two allocation helpers every program's C defines + (`_aether_alloc` and `_aether_alloc_or_null`, #3377) were plain + `static inline` functions, and clang warns of an unused one in a `.c` file + (gcc does not). They now carry `AETHER_MAYBE_UNUSED`, as the prologue's + other helpers do, defined before them (#3425). diff --git a/new_changelogs/20261029T130100Z-fixed-3426-pure-tls-links-no-http.md b/new_changelogs/20261029T130100Z-fixed-3426-pure-tls-links-no-http.md new file mode 100644 index 000000000..781b7ebde --- /dev/null +++ b/new_changelogs/20261029T130100Z-fixed-3426-pure-tls-links-no-http.md @@ -0,0 +1,11 @@ +- **A program using the pure TLS stack without `std.http` links nothing of + the HTTP server or client.** `std.cryptography.tls13_server` and + `tls13_client` told `std.http` why a key or a handshake was refused + through functions of the HTTP server and client, and the client read the + Windows trust store through two more, so every such program pulled both + out of libaether.a, and with the server the HTTP/2 code: on macOS, where + Homebrew's nghttp2 makes that the real one, it did not link. The reason + now goes through the callback registry (`aether_callback_set_reason`, + `aether_callback_reason`, per thread), and the trust store has a file of + its own, `std/net/aether_tls_trust.c`, which both HTTPS clients read + (#3426). diff --git a/new_changelogs/20261029T130200Z-fixed-3427-ioctl-in-networking-build.md b/new_changelogs/20261029T130200Z-fixed-3427-ioctl-in-networking-build.md new file mode 100644 index 000000000..65dcfc5aa --- /dev/null +++ b/new_changelogs/20261029T130200Z-fixed-3427-ioctl-in-networking-build.md @@ -0,0 +1,5 @@ +- **The embedded build compiles the HTTP server's stubs again.** + `std/net/aether_http_server.c` included ``, for the FIONREAD + check of #3405, where every POSIX build reads it, and a bare-metal target + has none. It is now included with the file's other socket headers, in the + part a build with networking compiles (#3427). diff --git a/new_changelogs/20261029T130300Z-fixed-3429-tls-tests-every-openssl.md b/new_changelogs/20261029T130300Z-fixed-3429-tls-tests-every-openssl.md new file mode 100644 index 000000000..8f3040689 --- /dev/null +++ b/new_changelogs/20261029T130300Z-fixed-3429-tls-tests-every-openssl.md @@ -0,0 +1,7 @@ +- **The TLS tests pass with every OpenSSL CI runs.** The signature check + read only the scheme names OpenSSL 3.2 and later print, and Ubuntu's 3.0 + names a signature by its type and digest; the resumption test started + `s_server -www -early_data`, which 3.0 refuses; the key test made its + PKCS#1 fixture with `-traditional`, which macOS's LibreSSL does not know + and does not need. Each now reads what every one of them says, and a + 3.0 host reports its 0-RTT-against-OpenSSL cases as skipped (#3429). diff --git a/new_changelogs/20261029T130400Z-fixed-3430-regression-tests-free-what-they-make.md b/new_changelogs/20261029T130400Z-fixed-3430-regression-tests-free-what-they-make.md new file mode 100644 index 000000000..a8b201139 --- /dev/null +++ b/new_changelogs/20261029T130400Z-fixed-3430-regression-tests-free-what-they-make.md @@ -0,0 +1,5 @@ +- **Regression tests free what they allocate.** Three tests never gave their + `ref` cells to `ref_free`, one never released the closure boxes it stored + in raw `ptr` fields (`release_closure`), and one never freed the INTEGER + it read (`bignum.free`). The macOS leaks gate passed some of them only + while a stale pointer still reached the blocks (#3430). diff --git a/new_changelogs/20261029T140000Z-fixed-3435-fatal-errors-flush-stdout.md b/new_changelogs/20261029T140000Z-fixed-3435-fatal-errors-flush-stdout.md new file mode 100644 index 000000000..d14c65def --- /dev/null +++ b/new_changelogs/20261029T140000Z-fixed-3435-fatal-errors-flush-stdout.md @@ -0,0 +1,10 @@ +- **A fatal runtime error no longer loses what the program printed last.** + A panic outside any try, a failed runtime check, a fatal `std.log` + record, or an out-of-memory the runtime cannot report printed its message + and called `abort()`, and stdout, fully buffered when it is a file or a + pipe, was never flushed: glibc's `abort()` flushes no stdio stream, so the + program's last lines were lost on Linux. Each such path now flushes stdout + before its message. It waits for another thread that holds stdout a + little, as one in the middle of a println does, and gives up after a + quarter of a second, as one blocked writing to a pipe nobody reads would + hold it for ever (#3435). diff --git a/new_changelogs/20261029T140100Z-fixed-3437-copy-tests-every-binding.md b/new_changelogs/20261029T140100Z-fixed-3437-copy-tests-every-binding.md new file mode 100644 index 000000000..cff34c583 --- /dev/null +++ b/new_changelogs/20261029T140100Z-fixed-3437-copy-tests-every-binding.md @@ -0,0 +1,8 @@ +- **The runtime-copy tests check their claims on every platform.** Three + tests assumed, as Windows has it, that a loaded library has a runtime of + its own and that a program's copy is never found: on ELF a program + linked with `-rdynamic` lends its runtime to a library, and on macOS an + executable exports its globals. The failed-result importer now measures + without holding the raised reason, and the foreign-layout cases of the + accounts and the sandbox run their copies in libraries loaded after the + foreign rendezvous, from hosts with no runtime of their own (#3437). diff --git a/new_changelogs/20261029T140200Z-fixed-3438-copy-joins-sandbox-count.md b/new_changelogs/20261029T140200Z-fixed-3438-copy-joins-sandbox-count.md new file mode 100644 index 000000000..71a1bee4b --- /dev/null +++ b/new_changelogs/20261029T140200Z-fixed-3438-copy-joins-sandbox-count.md @@ -0,0 +1,12 @@ +- **A runtime copy joins another copy's sandbox levels before its first + step.** A step reads only its own copy's word, and a copy that had looked + nothing up when another copy's count held levels stepped an actor plainly + inside them; the actor's first check then found levels it could not read + and was refused. `scheduler_init` now joins another copy's rendezvous of + this layout, through a pointer the sandbox installs, so the scheduler + still links nothing of it. A copy whose code runs another copy's panic (a + library's, under ELF interposition) has its sandbox served by that panic + and its steps: each copy adds its hooks to a list the panic and the step + barrier call, where one slot, set by the copy that looked last, served + only that one, and a panic caught inside another copy's block left its + level in force (#3438). diff --git a/new_changelogs/20261029T140300Z-fixed-3439-gateway-scripts-load.md b/new_changelogs/20261029T140300Z-fixed-3439-gateway-scripts-load.md new file mode 100644 index 000000000..7ab86aed1 --- /dev/null +++ b/new_changelogs/20261029T140300Z-fixed-3439-gateway-scripts-load.md @@ -0,0 +1,9 @@ +- **Gateway script libraries load again on Linux and macOS.** Every + `--emit=lib` library's C carried a table of the host API (deadline, memory + cap, caller, accounts), which made a script built to bind to its host's + runtime reference objects the host never linked: "undefined symbol: + aether_set_memory_cap". The runtime keeps the table itself, in + `aether_exec.c`, which every library with a runtime of its own links; a + program that loads libraries (`std.dl`) keeps what every Aether library + binds to; and the cooperative runtime list has `libaether_caps.c`, which a + wasm library's host API needs (#3439). diff --git a/new_changelogs/20261029T140400Z-fixed-3442-copies-agree-on-owner.md b/new_changelogs/20261029T140400Z-fixed-3442-copies-agree-on-owner.md new file mode 100644 index 000000000..394052e99 --- /dev/null +++ b/new_changelogs/20261029T140400Z-fixed-3442-copies-agree-on-owner.md @@ -0,0 +1,8 @@ +- **Runtime copies agree on which one owns the sandbox levels and the + accounts, whatever order they looked in.** The lookup of the first loaded + image that exports the rendezvous took a definition through the program's + handle, which searches every global library whenever it was loaded, or + through a library's handle, which searches its dependencies, so two + copies could pin different owners and not see each other's levels. A + definition now counts only inside the image being visited, and the list + of loaded objects grows past 512 (#3442). diff --git a/new_changelogs/20261029T140500Z-fixed-3443-wait-for-idle-waits-for-arms.md b/new_changelogs/20261029T140500Z-fixed-3443-wait-for-idle-waits-for-arms.md new file mode 100644 index 000000000..6788ed7d1 --- /dev/null +++ b/new_changelogs/20261029T140500Z-fixed-3443-wait-for-idle-waits-for-arms.md @@ -0,0 +1,7 @@ +- **`wait_for_idle()` waits for a receive timeout's arm to end.** The step + that fires a countdown disarms it before the arm runs, and + `wait_for_idle()` saw a timeout only while it was armed, so it returned + while the arm still ran, and a program read the arm's state before the arm + wrote it. A step that fires a countdown is now counted until its arm + returns, and one that ends inside the wait's quiet window starts the window + again (#3443). diff --git a/new_changelogs/20261029T140600Z-fixed-3450-free-of-a-slice-under-extern-free.md b/new_changelogs/20261029T140600Z-fixed-3450-free-of-a-slice-under-extern-free.md new file mode 100644 index 000000000..dc5b25eb6 --- /dev/null +++ b/new_changelogs/20261029T140600Z-fixed-3450-free-of-a-slice-under-extern-free.md @@ -0,0 +1,11 @@ +- **`free(xs)` of a slice is the builtin release whatever extern `free` a + program declares.** A program with `extern free(p: ptr)` turned every + `free` of a slice, in its own code and in the modules it imports, into C's + free of the slice's pointer, while slice ownership still released the + buffer at scope exit: a double free (ML-KEM's matrix, in a TLS test that + declared it), and an element's references never given back. A slice + given to an extern `free` that takes a pointer is now released by the + builtin, as a module's own `free` already left it, so the same + ownership checks apply: freeing a slice parameter the function only + borrows, or a view of another slice, is a compile error there too (#3116, + #3450). diff --git a/new_changelogs/20261029T150000Z-fixed-3455-libraries-load-without-a-runtime.md b/new_changelogs/20261029T150000Z-fixed-3455-libraries-load-without-a-runtime.md new file mode 100644 index 000000000..ab423ab67 --- /dev/null +++ b/new_changelogs/20261029T150000Z-fixed-3455-libraries-load-without-a-runtime.md @@ -0,0 +1,8 @@ +- **A library whose runtime is left to its host loads where there is none + yet.** The allocation helpers of a `--emit=lib` library read the runtime's + test-hook flag, and its loop heads the safe point's, both data, which the + dynamic loader binds when the library is loaded: a library linked with a + raw `cc -shared` did not load at all without a runtime, so `ae lib-info` + could not read the catalog of one whose closures allocate. On ELF and + Mach-O those references are now weak, as the callback registry's was, and + an absent flag reads as unarmed (#3455). diff --git a/new_changelogs/20261029T150100Z-fixed-3457-tcp-write-closed-peer.md b/new_changelogs/20261029T150100Z-fixed-3457-tcp-write-closed-peer.md new file mode 100644 index 000000000..83c19d8c3 --- /dev/null +++ b/new_changelogs/20261029T150100Z-fixed-3457-tcp-write-closed-peer.md @@ -0,0 +1,8 @@ +- **A `std.tcp` write to a connection the peer closed fails instead of + ending the process.** Its sends passed no flags, on sockets without + `SO_NOSIGPIPE`, so the write raised SIGPIPE and the program ended with no + output; on macOS the pure TLS client and server died that way when the + other side closed first. Sends now name `MSG_NOSIGNAL` where the platform + has it and sockets carry `SO_NOSIGPIPE` where it has that, as the HTTP + server's always did, and the write fails with EPIPE. `net.fd_write` on + a socket fails the same way (#3457). diff --git a/new_changelogs/20261029T150200Z-fixed-3459-tls-tests-ownership-and-servers.md b/new_changelogs/20261029T150200Z-fixed-3459-tls-tests-ownership-and-servers.md new file mode 100644 index 000000000..2854fdf78 --- /dev/null +++ b/new_changelogs/20261029T150200Z-fixed-3459-tls-tests-ownership-and-servers.md @@ -0,0 +1,7 @@ +- **Two TLS tests no longer fail on Linux and macOS.** The pure server's + descriptor-ownership probe still used the accept that took a certificate + and key path, which #3362 replaced with creds loaded once; Windows skips + it, so it had not been built. The key test left every server it started + running, and the slowest to start missed its window on a two-core runner. + The probe uses the creds API, and the key test runs one server at a time + (#3459). diff --git a/new_changelogs/20261029T190000Z-security-3445-runtime-copies-find-each-other-by-record.md b/new_changelogs/20261029T190000Z-security-3445-runtime-copies-find-each-other-by-record.md new file mode 100644 index 000000000..928b27240 --- /dev/null +++ b/new_changelogs/20261029T190000Z-security-3445-runtime-copies-find-each-other-by-record.md @@ -0,0 +1,16 @@ +- **A library's code inside a program's deny-all block is held to it, however + the program was linked (#3445).** Runtime copies share the sandbox levels + and the execution accounts through the first loaded image that has them, + and an image said so only by its exports. A Windows executable exports + nothing, nor does one linked without `-rdynamic` on Linux (a C host linked + with `ae cflags --libs`), so a program whose copy looked first, before it + loaded a library with a runtime of its own, kept levels and accounts the + library's copy never found: the library's code that the program called + inside a deny-all block was allowed everything, and an account the + program had put in use was charged nothing the library allocated. Each + copy now carries a record in its image that every copy reads in load + order, the program first: on ELF a read-only note naming the copy's + rendezvous by an offset fixed at link time, on PE and Mach-O a section of + its own. An image is read while it is held loaded, and a record counts + only when what it names lies inside its image. The record never changes, + so copies of later releases read it too. diff --git a/new_changelogs/20261029T200000Z-fixed-3463-wait-for-idle-inside-an-actor.md b/new_changelogs/20261029T200000Z-fixed-3463-wait-for-idle-inside-an-actor.md new file mode 100644 index 000000000..9725bd7da --- /dev/null +++ b/new_changelogs/20261029T200000Z-fixed-3463-wait-for-idle-inside-an-actor.md @@ -0,0 +1,14 @@ +- **`wait_for_idle()` inside an actor panics instead of never returning.** + It waits for every actor, the calling one too, whose step cannot end + while it waits: in a message handler it hung forever (the handler's own + message stays counted until the step returns), in a receive timeout's arm + it waited until its bound, and in a drop or a death hook a core ran after + the step, forever. Called inside an actor's step, on either scheduler, + or on the threaded scheduler on any thread that runs actors, it now + panics as the actor's code: the actor ends with a reason that says to + call it outside the actors (main, or a host's thread), its watchers are + told, and the program goes on (a drop run outside any frame, as for a + message to a dead actor, ends the process with the reason). On the + cooperative scheduler, where the wait ran the other actors nested and + returned, it panics in a step too, and a drop or a death hook after a + step still waits there (#3463). diff --git a/new_changelogs/20261029T210000Z-fixed-3464-wait-for-idle-messages-in-flight.md b/new_changelogs/20261029T210000Z-fixed-3464-wait-for-idle-messages-in-flight.md new file mode 100644 index 000000000..94c20fdaa --- /dev/null +++ b/new_changelogs/20261029T210000Z-fixed-3464-wait-for-idle-messages-in-flight.md @@ -0,0 +1,14 @@ +- **`wait_for_idle()` no longer returns with messages in flight.** Its count + of the messages still pending took sent less handled, and three things + broke it. Each core's two counts were read in turn, so a message sent on a + core already read and handled on one read after cancelled the reply it + sent. The handled count went up by one for each step a core made, so a + step that took no message (an actor killed mid-drain, or one another core + had just taken) counted a message never handled, and the handled count + passed the sent count for good, hiding as many pending messages from every + later wait. And two batch paths counted their sends after the receiver + could take them. Every core's handled count is now read before every sent + count, a step counts only a message it took, and every send counts before + its message can be taken: two actors trading messages on two cores (a + stream's chunks and credits) no longer see the wait return mid-exchange + (#3464). diff --git a/new_changelogs/20261030T010000Z-fixed-3465-tls-alert-lost-to-reset.md b/new_changelogs/20261030T010000Z-fixed-3465-tls-alert-lost-to-reset.md new file mode 100644 index 000000000..b44819815 --- /dev/null +++ b/new_changelogs/20261030T010000Z-fixed-3465-tls-alert-lost-to-reset.md @@ -0,0 +1,9 @@ +- **The pure TLS stack's fatal alert is no longer lost to a TCP reset when + a connection fails after the handshake.** The close after a failure + closed the socket with what the peer had sent after (the rest of a record + too long to read) still unread, which ends with a reset, and a peer that + got the reset before it read could discard the alert: it saw a bare + close. The client and the server now drain the socket before that close, + as a failed handshake's close on the client already did (#3368), with + `tcp.close_drained(sock)` in std.tcp; the server's library entry for a + failed handshake drains too (#3465). diff --git a/runtime/actors/aether_actor_thread.c b/runtime/actors/aether_actor_thread.c index 3348f7d1c..4c1c46aa8 100644 --- a/runtime/actors/aether_actor_thread.c +++ b/runtime/actors/aether_actor_thread.c @@ -9,6 +9,7 @@ #include extern AETHER_TLS int current_core_id; +extern AETHER_TLS int aether_in_actor_thread; /* multicore_scheduler.c */ // Idle iterations before the thread parks (#2592): as many as a core spins // before it parks (#1517), so a message that comes soon after the last one @@ -41,6 +42,8 @@ void* aether_actor_thread(void* arg) { // whose single producer is the core's thread, and messages were lost or // delivered twice. current_core_id = -1; + /* A thread that runs an actor: scheduler_wait() refuses here (#3463). */ + aether_in_actor_thread = 1; // The step reads actors from the tables (the ones its handler sends to), // so this thread publishes an epoch as a reader (#2517): from here until @@ -67,15 +70,18 @@ void* aether_actor_thread(void* arg) { // As a core steps an actor: an ask its handler leaves // unanswered fails then, and a panic ends the actor, not // the process. - scheduler_step_actor(actor); // A step takes one message, as on a core. The send was // counted when it was made, so without the credit // scheduler_wait (and with it scheduler_shutdown) waits - // forever on a message an actor thread handled. + // forever on a message an actor thread handled; and only a + // step that took one (not a dead actor's) is credited, or the + // handled count passes the sent count (#3464). Release, as + // the wait's reading of it is acquire. + int took = scheduler_step_actor(actor); int core = actor->assigned_core; - if (core >= 0 && core < num_cores) { + if (took && core >= 0 && core < num_cores) { atomic_fetch_add_explicit(&schedulers[core].messages_processed, 1, - memory_order_relaxed); + memory_order_release); } } continue; @@ -102,7 +108,9 @@ void* aether_actor_thread(void* arg) { if (actor->timeout_ns > 0 && actor->last_activity_ns > 0 && actor->step && aether_now_ns() - actor->last_activity_ns >= actor->timeout_ns) { idle = 0; + scheduler_countdown_enter(); // until its arm returns (#3443) scheduler_step_actor(actor); + scheduler_countdown_leave(); continue; } @@ -131,6 +139,11 @@ void* aether_actor_thread(void* arg) { } } + // Ended (a kill, a linked death; a panic's is finalized in its step): + // the death is finalized here, on the thread that owns the mailbox and + // takes the inbox, while it is still a reader (#2974, #3298). + scheduler_actor_thread_finalize(actor); + // Done reading: a released actor this thread stepped can be reclaimed. scheduler_reader_offline(); // And this actor, if it was released (or when it is, later). diff --git a/runtime/actors/aether_frames.h b/runtime/actors/aether_frames.h new file mode 100644 index 000000000..458ef0cd0 --- /dev/null +++ b/runtime/actors/aether_frames.h @@ -0,0 +1,117 @@ +/* aether_frames.h: the per-thread frames a panic unwinds to, shared by the + * runtime files that keep them (aether_panic.c, aether_unwind.c, + * aether_exec.c). Runtime-internal: generated code never names this + * thread-local (a Windows DLL cannot import one); it reaches the frames only + * through the functions in aether_panic.h and aether_exec.h. + * + * One block per thread, so a try frame or an execution frame costs one + * thread-local lookup, not one per file: the frames, the executions among + * them (#2955, #2956), and the depth the allocation journal tags entries + * with, which is the frame depth itself. */ + +#ifndef AETHER_FRAMES_H +#define AETHER_FRAMES_H + +#include +#include "aether_panic.h" + +/* What an execution keeps to give back when it returns (aether_exec.c). */ +typedef struct { + int64_t deadline_at_ns; /* the deadline in force when it was entered */ + int deadline_tripped; /* (a nested execution's only) */ + int64_t next_at_ns; /* the deadline a host service it called set */ + int next_tripped; /* for the executions entered from there */ + int next_set; + int sandbox_depth; /* this thread's sandbox levels at entry */ + int has_view; /* aether_host.c kept its caller info */ + int stop; /* why a C-backed std loop stopped early */ + uint64_t serial; /* this thread's count of executions, its token */ + struct AetherExecAccount* account; /* the account in use when it began (#2954) */ + int account_timed; /* the ACTIVE accounts it began timing (#3409) */ + int account_deadline; /* it took its account's deadline */ +} AetherExecRecord; + +/* Where another thread asks for an execution of this one to stop + * (aether_exec_cancel): the serial of the execution to cancel, 0 for none. + * Made the first time a token is asked for on a thread and kept for the + * process, so a token never points at freed memory. */ +typedef struct AetherCancelCell { + _Atomic uint64_t pending; + struct AetherCancelCell* next; +} AetherCancelCell; + +#define AETHER_OUTCOME_TEXT_MAX 1024 + +struct AetherUnwindEntry; /* aether_unwind.c's */ + +typedef struct { + AetherJmpFrame frames[AETHER_PANIC_MAX_DEPTH]; + int depth; /* live frames; the innermost is frames[depth-1] */ + AetherExecRecord exec[AETHER_PANIC_MAX_DEPTH]; + int exec_depth; + /* The executions below are the code's an actor step runs inside + * (#3392): put aside for the step, which runs outside every one of them + * (aether_step_put_aside_execs below). Only those above it run here. */ + int exec_floor; + int outcome; /* the last execution's, aether_last_outcome() */ + char outcome_text[AETHER_OUTCOME_TEXT_MAX]; + int interrupting; /* the outcome an interruption on its way carries */ + uint64_t serial; /* executions entered on this thread */ + AetherCancelCell* cell; + struct AetherExecAccount* account; /* in use on this thread (aether_exec_use, #2954) */ + /* The allocation journal (aether_unwind.c): its entries are tagged with + * the frame depth they were made at. */ + struct AetherUnwindEntry* journal; + int journal_count; + int journal_capacity; +} AetherFrames; + +/* This thread's block. The block is a `static` thread-local of + * aether_unwind.c, the one file that owns it (with the try frames' push and + * pop and the journal, the paths every step and every try takes); every + * other file reaches it through this function and never names the + * thread-local. + * + * The rule for the runtime's thread-locals: an extern thread-local is a + * scalar written whole; an aggregate stays static to its file, reached by + * accessor. Generated code goes further and names none (a Windows DLL cannot + * import a thread-local). An extern aggregate was miscompiled by GCC 16.2 + * for x86_64-w64-mingw32 at -O2: two adjacent stores into it, merged into + * one, were addressed through the variable's non-thread address and landed + * in the thread-local template, not the calling thread's block. A scalar + * read or written whole (an int, a pointer: current_core_id, + * g_current_step_actor) has no adjacent store to merge with. The send buffer + * (aether_send_buffer.c) follows the same rule. */ +AetherFrames* aether_frames_here(void); + +/* What an actor step puts aside of the executions it runs inside (#3392, + * #3396; the rule is aether_step_barrier.h's): the floor below which the + * executions are not the step's, an interruption on its way through them, + * and, through aether_exec.c, the call's deadline and its caller info. */ +typedef struct { + int exec_floor; + int interrupting; + int execs_part; /* aether_exec.c took its part (the hook was set) */ + int64_t deadline_at_ns; + int deadline_tripped; + void* caller; /* aether_host.c's: the setting, put away */ +} AetherExecAside; + +/* Set by aether_exec.c at its first execution: puts aside (hide 1) or gives + * back (hide 0) the deadline and the caller info, which live in files only + * a library call links. */ +extern void (*_Atomic aether_step_execs_hook)(AetherExecAside* s, int hide); + +/* An actor step begins and ends (aether_step_barrier.h): the executions + * running on this thread are put aside, AETHER_STEP_HID_EXECS set in *hid + * when there were any (or an interruption on its way), and given back. The + * step's try frame is pushed with them, in one look at this thread's block. + * The step's other parts have the other bits of *hid. */ +#define AETHER_STEP_HID_EXECS 1 +#define AETHER_STEP_HID_SPAWN 2 +#define AETHER_STEP_HID_ACCOUNT 4 +#define AETHER_STEP_HID_SANDBOX 8 +AetherJmpFrame* aether_step_push(AetherExecAside* s, int* hid); +void aether_step_give_back_execs(const AetherExecAside* s); + +#endif /* AETHER_FRAMES_H */ diff --git a/runtime/actors/aether_message_pool.h b/runtime/actors/aether_message_pool.h deleted file mode 100644 index 0dd480880..000000000 --- a/runtime/actors/aether_message_pool.h +++ /dev/null @@ -1,117 +0,0 @@ -#ifndef AETHER_MESSAGE_POOL_H -#define AETHER_MESSAGE_POOL_H - -#include "actor_state_machine.h" -#include "../config/aether_optimization_config.h" -#include - -/** - * Message Pool - Zero-Copy Message Passing via Pointer Indirection - * - * Instead of copying message structs (40 bytes each), we: - * 1. Allocate messages from thread-local pool - * 2. Pass message *pointers* through mailboxes (8 bytes) - * 3. Receiver claims ownership and returns to pool - * - * Pool size is configured via: - * - AETHER_PROFILE env var (micro=256, small=1024, medium=4096, large=16384) - * - AETHER_MSG_POOL_SIZE env var (explicit override) - * - * Benefits: - * - 5x less data copied (8 bytes vs 40 bytes) - * - Better cache utilization - * - Enables true zero-copy for all messages - * - Memory scales with workload (micro profile uses 90% less RAM) - */ - -#define MSG_POOL_MAX_SIZE 16384 // Maximum possible pool size -#define MSG_POOL_BATCH 32 - -typedef struct { - Message* messages; // Dynamically allocated based on profile - int* free_list; // Dynamically allocated - int capacity; // Actual pool size - atomic_int head; - atomic_int count; -} MessagePool; - -// Thread-local message pool -extern AETHER_TLS MessagePool* g_msg_pool; - -// Initialize thread-local message pool -static inline void message_pool_init_thread(void) { - if (g_msg_pool) return; - - // Get pool size from config (set by aether_init_from_env) - int pool_size = g_aether_config.msg_pool_size; - if (pool_size <= 0 || pool_size > MSG_POOL_MAX_SIZE) { - pool_size = AETHER_PROFILE_MEDIUM_MSG_POOL; - } - - g_msg_pool = malloc(sizeof(MessagePool)); - g_msg_pool->messages = malloc(pool_size * sizeof(Message)); - g_msg_pool->free_list = malloc(pool_size * sizeof(int)); - g_msg_pool->capacity = pool_size; - atomic_store(&g_msg_pool->head, 0); - atomic_store(&g_msg_pool->count, pool_size); - - // Initialize free list - for (int i = 0; i < pool_size; i++) { - g_msg_pool->free_list[i] = i; - } -} - -// Allocate message from pool -static inline Message* message_pool_alloc(void) { - if (!g_msg_pool) message_pool_init_thread(); - - int count = atomic_load_explicit(&g_msg_pool->count, memory_order_relaxed); - if (count == 0) { - // Pool exhausted - allocate from heap - return malloc(sizeof(Message)); - } - - // Pop from free list - int cap = g_msg_pool->capacity; - int head = atomic_fetch_add(&g_msg_pool->head, 1) % cap; - atomic_fetch_sub(&g_msg_pool->count, 1); - - int idx = g_msg_pool->free_list[head]; - return &g_msg_pool->messages[idx]; -} - -// Return message to pool -static inline void message_pool_free(Message* msg) { - if (!g_msg_pool) return; - - int cap = g_msg_pool->capacity; - - // Check if message is from pool - if (msg < &g_msg_pool->messages[0] || - msg >= &g_msg_pool->messages[cap]) { - // Not from pool - free normally - free(msg); - return; - } - - // Return to free list - int idx = msg - &g_msg_pool->messages[0]; - int tail = (atomic_load(&g_msg_pool->head) + atomic_load(&g_msg_pool->count)) % cap; - g_msg_pool->free_list[tail] = idx; - atomic_fetch_add(&g_msg_pool->count, 1); -} - -// Create message from pool (convenience) -static inline Message* message_pool_create(int type, int sender_id, int payload) { - Message* msg = message_pool_alloc(); - msg->type = type; - msg->sender_id = sender_id; - msg->payload_int = payload; - msg->payload_ptr = NULL; - msg->zerocopy.data = NULL; - msg->zerocopy.size = 0; - msg->zerocopy.owned = 0; - return msg; -} - -#endif // AETHER_MESSAGE_POOL_H diff --git a/runtime/actors/aether_message_registry.c b/runtime/actors/aether_message_registry.c index 3e9adfceb..92baaf979 100644 --- a/runtime/actors/aether_message_registry.c +++ b/runtime/actors/aether_message_registry.c @@ -8,6 +8,9 @@ // registry assigns this ID so the generated dispatch table routes // scheduler-delivered I/O readiness notifications to the user's handler. #define AETHER_RESERVED_ID_IO_READY 255 +// The death notice, `message Down { ref: actor_ref, reason: string }`, +// which the compiler declares (#2974). Must match AETHER_MSG_DOWN there. +#define AETHER_RESERVED_ID_DOWN 254 MessageRegistry* create_message_registry(void) { MessageRegistry* registry = (MessageRegistry*)malloc(sizeof(MessageRegistry)); @@ -54,11 +57,14 @@ int register_message_type(MessageRegistry* registry, const char* name, MessageFi // through the user's receive handler. if (strcmp(name, "IoReady") == 0) { def->message_id = AETHER_RESERVED_ID_IO_READY; + } else if (strcmp(name, "Down") == 0) { + def->message_id = AETHER_RESERVED_ID_DOWN; } else { - // Skip any reserved slot so user messages never collide with - // system IDs. With only one reserved slot (255), user IDs top out - // at 254, which is far beyond any realistic program. - if (registry->next_id == AETHER_RESERVED_ID_IO_READY) { + // Skip the reserved slots so user messages never collide with + // system IDs: user IDs top out at 253, which is far beyond any + // realistic program. + while (registry->next_id == AETHER_RESERVED_ID_IO_READY || + registry->next_id == AETHER_RESERVED_ID_DOWN) { registry->next_id++; } def->message_id = registry->next_id++; diff --git a/runtime/actors/aether_panic.c b/runtime/actors/aether_panic.c index 48c0e9f0b..654a511da 100644 --- a/runtime/actors/aether_panic.c +++ b/runtime/actors/aether_panic.c @@ -1,22 +1,26 @@ #include "aether_panic.h" +#include "../utils/aether_fatal.h" /* aether_fatal_flush (#3435) */ /* Header-only use: is_aether_string and the AetherString layout. Nothing * from std/string is linked, so a runtime-only link (the WASI panic lib) * still resolves. */ #include "../../std/string/aether_string.h" +#include "../aether_exec.h" +#include "aether_frames.h" #include #include #include #include #include +#include // Stack-trace capture (issue #347). Three platform paths: // // - glibc / macOS: backtrace() + backtrace_symbols() from // . Already in libc, no extra link. // - Windows / MinGW: CaptureStackBackTrace from kernel32 plus -// SymInitialize + SymFromAddr from dbghelp.dll. -// The Makefile + ae.c link `-ldbghelp`. +// SymInitialize + SymFromAddr from dbghelp.dll, +// loaded when a trace is printed (no link flag). // - Everything else (musl, wasm, freestanding): no-op stubs. // // Each path captures into the same TLS slot at the call site @@ -45,19 +49,24 @@ // Per-thread jmp frame stack // --------------------------------------------------------------------------- -typedef struct { - AetherJmpFrame frames[AETHER_PANIC_MAX_DEPTH]; - int depth; // number of frames currently live; innermost is frames[depth-1] -} AetherJmpStack; -/* AETHER_TLS_SHARED rather than AETHER_TLS: aether_panic.o is - * pulled into both the main `ae` executable AND `--emit=lib` .so - * outputs (codegen emits aether_panic_capture_stack + aether_panic - * at every user `panic("...")` call site). Initial-Exec TLS - * relocations don't work inside shared objects; Global-Dynamic - * works in both contexts with a tiny GOT-indirection overhead. - * See AETHER_TLS_SHARED in runtime/utils/aether_compiler.h. */ -static AETHER_TLS_SHARED AetherJmpStack tls_stack = { .depth = 0 }; +/* The frames live in the per-thread block aether_frames.h describes, + * with the executions and the allocation journal. */ + +/* To the frame a panic was raised for: an execution frame by the + * __builtin_setjmp buffer in its export stub, any other by its own. */ +#if defined(__GNUC__) || defined(__clang__) +__attribute__((noreturn)) +#endif +static void aether_jump_to(AetherJmpFrame* f) { +#if AETHER_EXEC_BUILTIN_SJLJ + if (f->exec_buf) __builtin_longjmp(f->exec_buf, 1); +#endif + AETHER_SIGLONGJMP(f->buf, 1); +#if !(defined(__GNUC__) || defined(__clang__)) + abort(); +#endif +} AETHER_TLS_SHARED int g_aether_in_actor_step = 0; AETHER_TLS_SHARED int g_aether_current_actor_id = -1; @@ -78,36 +87,57 @@ int aether_fnptr_claim(const void* r) { static AetherDeathHook death_hook = NULL; -AetherJmpFrame* aether_try_push(void) { - if (tls_stack.depth >= AETHER_PANIC_MAX_DEPTH) { - fprintf(stderr, "aether: try/catch nesting exceeded %d, aborting\n", - AETHER_PANIC_MAX_DEPTH); - abort(); +/* The copies of the sandbox the panic and the step barrier serve + * (aether_panic.h, #3438): each registers its unwind of the levels pushed + * inside a frame a panic leaves (#2955) and its part of an actor step's + * context (#3393) when it first looks for the runtime copies, before any + * thread can push one of its levels. Reached through here so that a program + * that panics but never enforces links no sandbox (aether_sandbox.o and the + * audit log it writes to), and the scheduler, which every actor program + * links, none either. With them, the threads with a sandbox level in force + * (the pointer aether_sandbox.h's check reads, set once the copies are + * found). These bind as the code that reads them does: under ELF + * interposition a library that runs the program's panic and step barrier + * registers its sandbox with the program's. */ +atomic_int* _Atomic aether_sandbox_in_force = NULL; +const AetherSandboxHooks* _Atomic aether_sandbox_hooks[AETHER_SANDBOX_HOOKS_MAX]; + +int aether_sandbox_hooks_add(const AetherSandboxHooks* h) { + if (!h) return -1; + for (int i = 0; i < AETHER_SANDBOX_HOOKS_MAX; i++) + if (atomic_load_explicit(&aether_sandbox_hooks[i], memory_order_acquire) == h) return 0; + for (int i = 0; i < AETHER_SANDBOX_HOOKS_MAX; i++) { + const AetherSandboxHooks* none = NULL; + if (atomic_compare_exchange_strong_explicit(&aether_sandbox_hooks[i], &none, h, memory_order_acq_rel, + memory_order_acquire) || + none == h) + return 0; } - AetherJmpFrame* f = &tls_stack.frames[tls_stack.depth++]; - f->reason = NULL; - f->reason_release = NULL; - aether_unwind_enter_frame(); - return f; + return -1; } -void aether_try_pop(void) { - if (tls_stack.depth <= 0) { - // Defensive: popping an empty stack means codegen/runtime mismatch. - fprintf(stderr, "aether: aether_try_pop on empty stack\n"); - abort(); +void aether_sandbox_hooks_remove(const AetherSandboxHooks* h) { + for (int i = 0; i < AETHER_SANDBOX_HOOKS_MAX; i++) { + const AetherSandboxHooks* mine = h; + atomic_compare_exchange_strong_explicit(&aether_sandbox_hooks[i], &mine, NULL, memory_order_acq_rel, + memory_order_relaxed); } - aether_unwind_exit_frame(); - tls_stack.depth--; } - -AetherJmpFrame* aether_current_frame(void) { - if (tls_stack.depth == 0) return NULL; - return &tls_stack.frames[tls_stack.depth - 1]; -} - -int aether_try_depth(void) { - return tls_stack.depth; +/* How this copy joins another copy's sandbox count before its first step + * (#3438): aether_sandbox.c's, set when that file is loaded, and called by + * scheduler_init. NULL in a copy without the sandbox, which has no level to + * see. This copy's own: a constructor sets it, which runs in every image + * whatever the interposition (aether_sandbox.c). */ +AETHER_COPY_LOCAL void (*_Atomic aether_sandbox_join)(void) = NULL; + +/* Every copy's levels pushed inside the frame the panic leaves: each copy + * keeps its own, and the try depth is the one every copy reads. */ +static void unwind_sandbox_levels(void) { + int frames = aether_try_depth(); + for (int i = 0; i < AETHER_SANDBOX_HOOKS_MAX; i++) { + const AetherSandboxHooks* h = atomic_load_explicit(&aether_sandbox_hooks[i], memory_order_acquire); + if (h && h->unwind) h->unwind(frames); + } } // --------------------------------------------------------------------------- @@ -296,8 +326,8 @@ static void aether_print_stack_trace_to_stderr(void) { #elif AETHER_STACK_TRACE_WIN32 // Windows path: CaptureStackBackTrace lives in kernel32 (always -// linked), SymFromAddr lives in dbghelp.dll (Makefile + ae.c add -// -ldbghelp). The shape mirrors the POSIX path — TLS-buffered +// linked), SymFromAddr lives in dbghelp.dll (loaded below when a +// trace is printed). The shape mirrors the POSIX path, TLS-buffered // frames captured at the call site, walked + pretty-printed in the // fallback printer — so the rest of the panic plumbing is identical. @@ -306,6 +336,34 @@ static AETHER_TLS_SHARED PVOID tls_panic_trace[AETHER_PANIC_TRACE_MAX]; static AETHER_TLS_SHARED USHORT tls_panic_trace_n = 0; static AETHER_TLS_SHARED int tls_panic_sym_initialised = 0; +/* dbghelp is loaded when a trace is printed, not linked: every export runs + * in a panic frame (#2956), so this object is in every library, and a C + * host linking one with libaether.a would otherwise need -ldbghelp. */ +typedef DWORD (WINAPI *aether_sym_get_options_fn)(void); +typedef DWORD (WINAPI *aether_sym_set_options_fn)(DWORD); +typedef BOOL (WINAPI *aether_sym_initialize_fn)(HANDLE, PCSTR, BOOL); +typedef BOOL (WINAPI *aether_sym_from_addr_fn)(HANDLE, DWORD64, PDWORD64, PSYMBOL_INFO); +static aether_sym_from_addr_fn aether_sym_from_addr = NULL; + +static void aether_load_dbghelp(HANDLE proc) { + HMODULE h = LoadLibraryA("dbghelp.dll"); + if (!h) return; + FARPROC get = GetProcAddress(h, "SymGetOptions"); + FARPROC set = GetProcAddress(h, "SymSetOptions"); + FARPROC init = GetProcAddress(h, "SymInitialize"); + FARPROC from = GetProcAddress(h, "SymFromAddr"); + if (!get || !set || !init || !from) return; + aether_sym_get_options_fn get_options; + aether_sym_set_options_fn set_options; + aether_sym_initialize_fn initialize; + memcpy(&get_options, &get, sizeof(get_options)); + memcpy(&set_options, &set, sizeof(set_options)); + memcpy(&initialize, &init, sizeof(initialize)); + set_options(get_options() | SYMOPT_UNDNAME | SYMOPT_DEFERRED_LOADS); + initialize(proc, NULL, TRUE); + memcpy(&aether_sym_from_addr, &from, sizeof(aether_sym_from_addr)); +} + void aether_panic_capture_stack(void) { // Skip 1 frame (this function itself); the next frame is the // codegen call site, which is the user's panic statement. @@ -350,8 +408,7 @@ static void aether_print_stack_trace_to_stderr(void) { // safe to call once per thread that actually needs symbols. // SYMOPT_LOAD_LINES could be added for line numbers; deferred // (PDB availability is implementation-defined under MinGW). - SymSetOptions(SymGetOptions() | SYMOPT_UNDNAME | SYMOPT_DEFERRED_LOADS); - SymInitialize(proc, NULL, TRUE); + aether_load_dbghelp(proc); tls_panic_sym_initialised = 1; } @@ -374,7 +431,7 @@ static void aether_print_stack_trace_to_stderr(void) { sym->SizeOfStruct = sizeof(SYMBOL_INFO); sym->MaxNameLen = NAME_MAX; DWORD64 disp = 0; - BOOL ok = SymFromAddr(proc, addr, &disp, sym); + BOOL ok = aether_sym_from_addr ? aether_sym_from_addr(proc, addr, &disp, sym) : FALSE; if (ok && sym->Name[0] != '\0') { // Skip our own panic-handler frames so the first thing @@ -438,7 +495,8 @@ static void aether_print_stack_trace_to_stderr(void) { * the runtime as the AetherString codegen made it -- the catch lowering * adopts that object as its binding -- so printing it as a C string would * print the header. A literal is a plain char* and is its own text. */ -static const char* panic_reason_text(const char* reason) { +const char* aether_panic_reason_text(const char* reason) { + if (!reason) return "unknown"; if (is_aether_string(reason)) return ((const AetherString*)reason)->data; return reason; } @@ -447,6 +505,41 @@ void aether_panic(const char* reason) { aether_panic_owned(reason, NULL); } +/* The reason an interruption of a library call carries (#2956, + * aether_exec.c), by its outcome. */ +const char* aether_exec_reason(int outcome) { + switch (outcome) { + case AETHER_DEADLINE: return "the call's deadline passed"; + case AETHER_CANCELLED: return "the call was cancelled"; + case AETHER_MEMORY_CAP: return "the call went past the memory cap"; + case AETHER_OUT_OF_MEMORY: return "the call ran out of memory"; + case AETHER_INCOMPATIBLE: return "the library was made for another runtime"; + default: return "panic"; + } +} + +/* An allocation the code cannot go on without failed (#2956). Inside an + * execution the call ends with AETHER_OUT_OF_MEMORY, an interruption a + * `try` does not catch, its reason a static string: nothing on the way + * allocates. Outside one it is the panic `what` it always was. */ +void aether_out_of_memory(const char* what) { + AetherFrames* F = aether_frames_here(); + if (F->exec_depth > F->exec_floor) { /* an execution of this code's (#3392) */ + F->interrupting = AETHER_OUT_OF_MEMORY; + aether_panic(aether_exec_reason(AETHER_OUT_OF_MEMORY)); + } + aether_panic(what); +} + +/* A `try` caught an interruption (aether_exec_interrupting()): it has done + * its cleanup, and the interruption goes on to the next frame out, which + * ends with the execution's. Here, with the panic it raises again, since + * every program with a `try` calls it. */ +void aether_exec_reraise(void) { + aether_try_pop(); + aether_panic(aether_exec_reason(aether_frames_here()->interrupting)); +} + void aether_panic_owned(const char* reason, void (*release)(const void*)) { if (!reason) { reason = "panic: (null)"; release = NULL; } @@ -454,10 +547,16 @@ void aether_panic_owned(const char* reason, void (*release)(const void*)) { if (f) { f->reason = reason; f->reason_release = release; + // The sandbox levels pushed inside the frame are left with it, in + // the runtime, whichever code catches (#2955): a catch in code that + // never enforces, or an actor's barrier, restored nothing, and the + // thread stayed held to grants freed with the block. Before the + // drain, which may free them. + unwind_sandbox_levels(); // Free the innermost frame's still-live journaled allocations // before the jump skips their deferred frees (aether_unwind.c). aether_unwind_drain_current(); - AETHER_SIGLONGJMP(f->buf, 1); + aether_jump_to(f); // unreachable } @@ -468,8 +567,13 @@ void aether_panic_owned(const char* reason, void (*release)(const void*)) { // frame reach the fallback. Suppress the trace via // AETHER_STACK_TRACE=0 if the noise gets in the way (e.g. tests // that diff stderr line-for-line). + // + // The program's own lines first (#3435): stdout is fully buffered when it + // is a file or a pipe, and abort() flushes no stdio stream on glibc, so + // what the program printed last was lost behind this message. + aether_fatal_flush(); fprintf(stderr, "aether: panic outside any try/catch or actor: %s\n", - panic_reason_text(reason)); + aether_panic_reason_text(reason)); const char* trace_env = getenv("AETHER_STACK_TRACE"); if (!trace_env || strcmp(trace_env, "0") != 0) { aether_print_stack_trace_to_stderr(); @@ -526,7 +630,8 @@ static void aether_sig_handler(int sig, siginfo_t* info, void* ucontext) { } f->reason = reason; f->reason_release = NULL; - AETHER_SIGLONGJMP(f->buf, 1); + unwind_sandbox_levels(); + aether_jump_to(f); } void aether_panic_install_signal_handlers(void) { @@ -573,5 +678,66 @@ void aether_set_on_actor_death(AetherDeathHook fn) { void aether_fire_death_hook(int actor_id, const char* reason) { AetherDeathHook h = death_hook; - if (h) h(actor_id, reason ? panic_reason_text(reason) : "unknown"); + if (h) h(actor_id, reason ? aether_panic_reason_text(reason) : "unknown"); +} + +/* aether_panic.h (#3403, #3404, #3415). What the frame caught is told by + * what its code raised, not by the state the thread is in after it: an + * interruption already on its way when the code began (its unwind runs + * defers and journal drains, and one that ends an actor runs the actor's + * drops here) is not the code's. It is put aside while the code runs and + * given back after it, so an ordinary panic in the code is reported as one + * and the interruption goes on as it was. Read after the catch, it made the + * code's ordinary panic the interruption: taken off its way, the unwind went + * on as an ordinary panic, which a `try` caught and the call's frame + * recorded as PANICKED. */ +int aether_contain(void (*fn)(void*), void* arg, const char** reason, + void (**reason_release)(const void*)) { + AetherFrames* F = aether_frames_here(); + int on_its_way = F->interrupting; + F->interrupting = 0; + AetherJmpFrame* f = aether_try_push(); + if (AETHER_SIGSETJMP(f->buf, 1) == 0) { + fn(arg); + aether_try_pop(); + F->interrupting = on_its_way; + return 0; + } + const char* r = f->reason; + void (*rel)(const void*) = f->reason_release; + aether_try_pop(); + if (F->interrupting) { + /* An interruption of the call this code runs inside, raised in it (a + * drop function's loop past the call's deadline, in a finalize the + * call runs): not this frame's to end, nor to report as a panic. + * The call ends at its own next safe point, or at its return. Left + * on its way, it turned the call's next ordinary panic into it. */ + if (rel) rel(r); + aether_exec_defer_interruption(); + F->interrupting = on_its_way; + return 2; + } + F->interrupting = on_its_way; + *reason = r; + *reason_release = rel; + return 1; +} + +typedef struct { int id; const char* reason; } DeathHookCall; + +static void death_hook_body(void* p) { + DeathHookCall* c = (DeathHookCall*)p; + aether_fire_death_hook(c->id, c->reason); +} + +void aether_fire_death_hook_contained(int actor_id, const char* reason) { + if (!death_hook) return; + DeathHookCall c = { actor_id, reason }; + const char* r = NULL; + void (*rel)(const void*) = NULL; + if (aether_contain(death_hook_body, &c, &r, &rel) == 1) { + fprintf(stderr, "aether: the death hook of actor %d panicked: %s\n", actor_id, + aether_panic_reason_text(r)); + if (rel) rel(r); + } } diff --git a/runtime/actors/aether_panic.h b/runtime/actors/aether_panic.h index 5a2338108..a8bf647f6 100644 --- a/runtime/actors/aether_panic.h +++ b/runtime/actors/aether_panic.h @@ -121,6 +121,22 @@ typedef jmp_buf aether_sigjmp_buf; #define AETHER_SIGLONGJMP(buf, val) _longjmp((buf), (val)) #endif +// An export's execution frame (aether_exec.h) saves its context with GCC's +// __builtin_setjmp, which keeps the frame pointer, stack pointer and resume +// address (the compiler saves the rest in the stub's own frame), where the +// C library's setjmp saves every callee-saved register and, on Win64, ten +// XMM registers: 0.57 ns against 2.85 ns on MinGW. A panic reaches it with +// __builtin_longjmp. x86 and x86-64 only: clang has no such builtins on +// AArch64 (macOS arm64), where the C library's setjmp is cheap anyway, and +// wasm has no unwinding; there the stub uses AETHER_SIGSETJMP on a frame's +// buf instead. +#if (defined(__GNUC__) || defined(__clang__)) && (defined(__x86_64__) || defined(__i386__)) && \ + !defined(__wasm__) && !defined(__EMSCRIPTEN__) + #define AETHER_EXEC_BUILTIN_SJLJ 1 +#else + #define AETHER_EXEC_BUILTIN_SJLJ 0 +#endif + // Maximum nesting depth of try/catch (and scheduler step barriers) per // thread. 32 covers any realistic handler; the limit lets the stack live // in TLS without dynamic allocation. @@ -135,6 +151,12 @@ typedef struct AetherJmpFrame { // aether_panic_owned(); the catch lowering adopts it as the binding's // heap flag, and the actor barrier releases it after the death hook. void (*reason_release)(const void*); + // An execution frame entered by aether_exec_enter_at (aether_exec.h): + // the __builtin_setjmp buffer in the export stub's own frame, which a + // panic reaches with __builtin_longjmp. NULL for every other frame, + // which a panic reaches through `buf`. Appended: the fields above keep + // their offsets. + void** exec_buf; } AetherJmpFrame; // Push a new frame onto the current thread's stack and return it. The @@ -171,6 +193,15 @@ __attribute__((noreturn)) #endif void aether_panic_owned(const char* reason, void (*release)(const void*)); +// An allocation the code cannot go on without failed (#2956): inside a +// library call's execution the call ends with AETHER_OUT_OF_MEMORY (a +// `try` does not catch it, and nothing on the way allocates); elsewhere it +// is aether_panic(what). Does not return. +#if defined(__GNUC__) || defined(__clang__) +__attribute__((noreturn)) +#endif +void aether_out_of_memory(const char* what); + // Install SIGSEGV/SIGFPE/SIGBUS handlers that convert native faults into // panics. No-op unless AETHER_CATCH_SIGNALS=1 is set in the environment. // Call once at process init. @@ -195,12 +226,35 @@ void aether_panic_capture_stack(void); typedef void (*AetherDeathHook)(int actor_id, const char* reason); void aether_set_on_actor_death(AetherDeathHook fn); +// The text of a caught panic's reason: a reason built at run time is an +// AetherString, a literal its own text (#2340). "unknown" for NULL. +const char* aether_panic_reason_text(const char* reason); + // Called by the scheduler after catching a panic. Fires the on_actor_death // hook if one is set. Separated from the push/pop primitives so scheduler // code can decide its own ordering (e.g. mark actor dead first, then fire // the hook). void aether_fire_death_hook(int actor_id, const char* reason); +// A containment frame (#3403, #3404): fn(arg) runs in a frame of its own, +// so a panic in it ends there and never jumps past the caller's own cleanup. +// 0: fn returned. 1: it panicked; *reason and *reason_release are the +// panic's, the caller's to report and release. 2: an interruption of the +// library call this code runs inside was raised in it (#3404): it is not the +// frame's to end, and ends the call at the call's next safe point or its +// return, as a stop noted for the call does; nothing to report. An +// interruption already on its way when fn begins (an unwind's defer that +// ends an actor runs its drops here) is not fn's: it is put aside while fn +// runs and given back after, so an ordinary panic in fn is 1 and the +// interruption goes on as it was (#3415). The step barrier runs what follows +// a panicking handler through it, and an ending actor's drops +// (aether_message_drop_at_end). +int aether_contain(void (*fn)(void*), void* arg, const char** reason, + void (**reason_release)(const void*)); +// The death hook, inside a containment frame: a hook that panics is +// reported on stderr and the caller goes on. +void aether_fire_death_hook_contained(int actor_id, const char* reason); + // Allocation journal for panic-unwind heap cleanup: see aether_unwind.h. #include "aether_unwind.h" @@ -224,6 +278,36 @@ void aether_fnptr_mark(const void* s); // Whether r is the marked pointer; clears the mark either way. int aether_fnptr_claim(const void* r); +/* How the panic and the step barrier reach a copy of the sandbox, which + * they do not link (#2955, #3393): its unwind of the levels pushed inside a + * frame a panic leaves, and how a step puts this thread's levels aside and + * gives them back, each on that copy's own levels. Every copy of the sandbox + * registers its hooks once, when it first looks for the runtime copies, and + * the panic and the step barrier serve every copy registered with them: + * their own, and under ELF interposition several (a library whose code runs + * the program's panic registers its own sandbox there, beside the program's + * or another library's). One slot of each, which the last copy to look set, + * served only that one: a panic caught inside another copy's block left its + * level in force (#3438). */ +typedef struct AetherSandboxHooks { + void (*unwind)(int frames); + int (*hide)(int saved[2]); + void (*show)(const int saved[2]); +} AetherSandboxHooks; + +#define AETHER_SANDBOX_HOOKS_MAX 64 +extern const AetherSandboxHooks* _Atomic aether_sandbox_hooks[AETHER_SANDBOX_HOOKS_MAX]; +// Registers `h`, once: 0, or -1 when every place is taken (a copy past +// AETHER_SANDBOX_HOOKS_MAX refuses every check inside its blocks until a +// place frees: refused more, never less). +int aether_sandbox_hooks_add(const AetherSandboxHooks* h); +// The copies whose levels one step puts aside: those that have some on the +// thread at once, few; one past it keeps its levels in force for the step +// (refused more, never less). +#define AETHER_STEP_SANDBOX_COPIES 8 +// A copy that is unloaded gives its place up. +void aether_sandbox_hooks_remove(const AetherSandboxHooks* h); + #ifdef __cplusplus } #endif diff --git a/runtime/actors/aether_send_buffer.c b/runtime/actors/aether_send_buffer.c index 293f045c3..0d7cf88cf 100644 --- a/runtime/actors/aether_send_buffer.c +++ b/runtime/actors/aether_send_buffer.c @@ -2,8 +2,12 @@ #include "../scheduler/multicore_scheduler.h" #include -// Thread-local send buffer -AETHER_TLS SendBuffer g_send_buffer = {NULL, {{0}}, 0, -1}; +// This thread's send buffer: named in this file only (aether_send_buffer.h). +static AETHER_TLS SendBuffer t_send_buffer = {NULL, {{0}}, 0, -1}; + +SendBuffer* send_buffer_here(void) { + return &t_send_buffer; +} // Flush accumulated messages to target actor. Every message goes, always // (#3292), by the scheduler's own send path: scheduler_send_many sends them @@ -15,15 +19,15 @@ AETHER_TLS SendBuffer g_send_buffer = {NULL, {{0}}, 0, -1}; // counted in flight; to count none of them as sent; and, from a thread that // is no core's, to write the shared channel without its lock. void send_buffer_flush(void) { - if (g_send_buffer.count == 0 || !g_send_buffer.target) { + if (t_send_buffer.count == 0 || !t_send_buffer.target) { return; } // Taken out of the buffer first: a send can step its target inline, and // a handler that buffers sends of its own finds the buffer empty. - ActorBase* actor = (ActorBase*)g_send_buffer.target; - int n = g_send_buffer.count; + ActorBase* actor = (ActorBase*)t_send_buffer.target; + int n = t_send_buffer.count; Message msgs[SEND_BUFFER_SIZE]; - memcpy(msgs, g_send_buffer.buffer, (size_t)n * sizeof(Message)); - g_send_buffer.count = 0; + memcpy(msgs, t_send_buffer.buffer, (size_t)n * sizeof(Message)); + t_send_buffer.count = 0; scheduler_send_many(actor, msgs, n); } diff --git a/runtime/actors/aether_send_buffer.h b/runtime/actors/aether_send_buffer.h index 010c76564..05d549529 100644 --- a/runtime/actors/aether_send_buffer.h +++ b/runtime/actors/aether_send_buffer.h @@ -9,10 +9,10 @@ struct ActorBase; /** * Sender-Side Message Batching - * + * * Reduces atomic operations by accumulating messages in thread-local buffers * before flushing to mailboxes in batches. - * + * * Performance impact: * - Before: N messages → N atomic operations on the target queue. * - After: N messages → roughly N/256 atomic operations (one per flushed batch). @@ -30,43 +30,54 @@ typedef struct { int core_id; // Core ID for cross-core detection } SendBuffer; -// Thread-local send buffer instance -extern AETHER_TLS SendBuffer g_send_buffer; +// The calling thread's buffer. It is a `static` thread-local of +// aether_send_buffer.c, and only that file names it: a thread-local of +// several fields stays in the file that defines it, and every other file +// reaches it through this function (runtime/actors/aether_frames.h says +// why). The functions below write the fields through the pointer it +// returns. They stay inline: a buffered send made a call into that file +// took 5.7 ns, the Message passed by value copied twice, against 1.57 ns +// for an inline store through the pointer (1.55 ns when the buffer was an +// extern thread-local; both with the loop aligned, since the placement of +// the caller's loop moves either by more than that). +SendBuffer* send_buffer_here(void); + +// Send what the buffer holds, all of it (#3292). +void send_buffer_flush(void); // Initialize send buffer for current thread static inline void send_buffer_init(int core_id) { - g_send_buffer.target = NULL; - g_send_buffer.count = 0; - g_send_buffer.core_id = core_id; + SendBuffer* b = send_buffer_here(); + b->target = NULL; + b->count = 0; + b->core_id = core_id; } -// Forward declare flush function (implemented in .c file) -void send_buffer_flush(void); - // Buffered send - accumulates messages before flushing. A flush sends every // message it holds (#3292), but a send in it can step a handler inline that // buffers sends of its own, for another target: those are flushed too, so // the message below is stored in a buffer with room, for its own target. static inline void send_buffered(struct ActorBase* actor, Message msg) { - while (g_send_buffer.count > 0 && - (g_send_buffer.target != actor || g_send_buffer.count >= SEND_BUFFER_SIZE)) { + SendBuffer* b = send_buffer_here(); + while (b->count > 0 && (b->target != actor || b->count >= SEND_BUFFER_SIZE)) { send_buffer_flush(); } - g_send_buffer.target = actor; - g_send_buffer.buffer[g_send_buffer.count++] = msg; + b->target = actor; + b->buffer[b->count++] = msg; } // Force flush of pending messages (call before blocking operations) static inline void send_buffer_force_flush(void) { - while (g_send_buffer.count > 0) { + SendBuffer* b = send_buffer_here(); + while (b->count > 0) { send_buffer_flush(); } - g_send_buffer.target = NULL; + b->target = NULL; } // Get buffer stats for monitoring static inline int send_buffer_pending(void) { - return g_send_buffer.count; + return send_buffer_here()->count; } #endif // AETHER_SEND_BUFFER_H diff --git a/runtime/actors/aether_send_message.c b/runtime/actors/aether_send_message.c index 0551e5a8e..10220b62e 100644 --- a/runtime/actors/aether_send_message.c +++ b/runtime/actors/aether_send_message.c @@ -1,10 +1,15 @@ #include "actor_state_machine.h" +#include "../utils/aether_fatal.h" /* aether_fatal_flush (#3435) */ #include "../scheduler/multicore_scheduler.h" #include "../config/aether_optimization_config.h" #include "../utils/aether_compiler.h" #include #include #include "../utils/aether_thread.h" +#include "../../std/string/aether_string.h" +#include "../aether_resource_caps.h" +#include "aether_frames.h" +#include "aether_panic.h" // Thread-local core ID for optimization extern AETHER_TLS int current_core_id; @@ -12,94 +17,35 @@ extern AETHER_TLS int current_core_id; // ActorBase is defined in multicore_scheduler.h // ============================================================================== -// TIER 1 OPTIMIZATION: Thread-Local Message Payload Pool +// Message payloads // ============================================================================== -// Instead of malloc/free for every message payload (one alloc + one free per -// send/receive cycle on the hot path), draw from a per-thread pool of small -// buffers. Eliminates the allocator as a bottleneck for small-message traffic. - -#define MSG_PAYLOAD_POOL_SIZE 256 // 256 buffers per thread -#define MSG_PAYLOAD_MAX_SIZE 256 // Max pooled message size (Ping/Pong ~16 bytes) - -typedef struct { - char buffer[MSG_PAYLOAD_MAX_SIZE]; - int in_use; // Thread-local: no atomics needed -} PooledPayload; - -typedef struct { - PooledPayload payloads[MSG_PAYLOAD_POOL_SIZE]; - int next_index; // Thread-local: no atomics needed - int initialized; -} PayloadPool; - -// Thread-local payload pool -static AETHER_TLS PayloadPool* g_payload_pool = NULL; - -// Global statistics (atomic, shared across all threads) -static _Atomic uint64_t g_pool_hits = 0; -static _Atomic uint64_t g_pool_misses = 0; -static _Atomic uint64_t g_too_large = 0; - -// Get pool statistics (for debugging/profiling) +// A typed message's payload is a heap copy (payload_message below), freed by +// whoever handles or drops the message. There is no payload pool (#3374): +// its acquire side was removed because an actor can move to another core +// after a message is sent, so a pooled payload would be released by a +// thread that does not own the pool. + +/* Deprecated (#3374): there is no pool, so there is nothing to count; kept + * for callers built against it, it reports zeros. */ void aether_message_pool_stats(uint64_t* hits, uint64_t* misses, uint64_t* large) { - *hits = atomic_load_explicit(&g_pool_hits, memory_order_relaxed); - *misses = atomic_load_explicit(&g_pool_misses, memory_order_relaxed); - *large = atomic_load_explicit(&g_too_large, memory_order_relaxed); + if (hits) *hits = 0; + if (misses) *misses = 0; + if (large) *large = 0; } -// Initialize thread-local payload pool -/* The acquire side of this pool was removed: it was unreachable, and the - * send path below documents why it must stay that way. An actor can be - * migrated to another core after a message is sent, so a TLS-pooled - * payload would be released by a thread that does not own that pool. - * payload_pool_release still runs and simply reports "not mine" for - * every heap payload. */ - - -// Return payload to pool -static inline int payload_pool_release(void* ptr) { - if (!g_payload_pool || !g_payload_pool->initialized) { - return 0; // Not from pool - } - - // Check if pointer is within pool memory - char* pool_start = (char*)g_payload_pool->payloads; - char* pool_end = pool_start + sizeof(g_payload_pool->payloads); - - if ((char*)ptr < pool_start || (char*)ptr >= pool_end) { - return 0; // Not from pool - } - - // Find which slot this is - size_t offset = (char*)ptr - pool_start; - size_t slot_index = offset / sizeof(PooledPayload); - - if (slot_index >= MSG_PAYLOAD_POOL_SIZE) { - return 0; // Invalid - } - - // Mark as free (thread-local: plain store) - g_payload_pool->payloads[slot_index].in_use = 0; - return 1; // Successfully returned to pool -} - -// Free message payload - try pool first, then fall back to free() void aether_free_message(void* msg_data) { - if (!msg_data) return; - - // Try to return to pool - if (payload_pool_release(msg_data)) { - return; // Returned to pool successfully - } - - // Was malloc'd (large message or pool exhausted), use regular free free(msg_data); } void aether_message_drop(ActorBase* target, Message* msg) { if (msg->payload_ptr) { - if (target && target->release_message) + if (msg->type == AETHER_MSG_DOWN) { + // The runtime wrote it (#2974): its reason is the runtime's to + // release, whether or not the program declares Down. + string_release(((AetherDownMsg*)msg->payload_ptr)->reason); + } else if (target && target->release_message) { target->release_message(msg->type, msg->payload_ptr); + } aether_free_message(msg->payload_ptr); msg->payload_ptr = NULL; } @@ -110,32 +56,137 @@ void aether_message_drop(ActorBase* target, Message* msg) { } } -// The heap copy of a typed message's payload, as the Message a send carries. -static Message payload_message(ActorBase* actor, void* message_data, size_t message_size) { - // Always use heap allocation for type-safe messages. - // TLS pools have thread affinity issues: a same-core actor may be migrated - // to another core after the message is sent, causing the receiver to call - // free() on pool memory (heap-use-after-free / free-on-non-malloc). - void* msg_copy = malloc(message_size); - if (!msg_copy) { - fprintf(stderr, "aether: malloc(%zu) failed for msg type %d to actor %d\n", - message_size, *(int*)message_data, actor->id); - abort(); +/* #3401: the drop of a message an ending actor leaves undelivered. Its + * release is the program's code for a field of an owned type (a drop + * function, #3142), which may panic, and the finalize that drains the + * actor ran it bare: a panic there jumped out of the finalize (to the + * nearest frame below, or ended the program without one), so the messages + * after it were not released and the actor's watchers and links were never + * told. The release runs in a containment frame of its own instead + * (aether_contain): a panic in it is caught there, the unwind journal + * giving back what the release held at its depth, and reported as the step + * barrier reports a panic in the release that follows a handler, through + * the death hook (itself contained, #3417) with + * the actor's id and the panic's reason, which is then released. The + * actor's end has its reason already and keeps it. What the release did not + * get to free (the payload, an owned zero-copy buffer) goes here, and the + * finalize goes on with the next message. A message whose release is not + * the program's (no release_message, a runtime Down) needs no frame. The + * step barrier drops the message a panicking handler ran on through here + * too (#3403). */ +typedef struct { ActorBase* target; Message* msg; } DropCall; + +static void drop_body(void* p) { + DropCall* c = (DropCall*)p; + aether_message_drop(c->target, c->msg); +} + +/* The drop of `msg` in a containment frame (#3403): 0 when it was dropped, + * 1 when its release panicked: what the release did not get to free (the + * payload, an owned zero-copy buffer) is freed here, and *reason and + * *reason_release are the panic's, the caller's to report and release. A + * drop cut by an interruption of the call it runs inside (#3404) leaves + * the same freed and reports nothing: the call ends with it. */ +int aether_message_drop_contained(ActorBase* target, Message* msg, const char** reason, + void (**reason_release)(const void*)) { + if (!target || !target->release_message || !msg->payload_ptr || msg->type == AETHER_MSG_DOWN) { + aether_message_drop(target, msg); + return 0; } - memcpy(msg_copy, message_data, message_size); + DropCall c = { target, msg }; + int how = aether_contain(drop_body, &c, reason, reason_release); + if (how == 0) return 0; + if (msg->payload_ptr) { + aether_free_message(msg->payload_ptr); + msg->payload_ptr = NULL; + } + if (msg->zerocopy.owned && msg->zerocopy.data) { + free(msg->zerocopy.data); + msg->zerocopy.data = NULL; + msg->zerocopy.owned = 0; + } + return how == 1; +} +void aether_message_drop_at_end(ActorBase* target, Message* msg) { + const char* reason = NULL; + void (*reason_release)(const void*) = NULL; + if (!aether_message_drop_contained(target, msg, &reason, &reason_release)) return; + /* In a frame of its own, as the step barrier's hook is (#3417): a hook + * that panics is reported on stderr and the finalize goes on. Fired + * bare, its panic left the finalize halfway (the messages it had drained + * never credited, so scheduler_shutdown waited for them forever, and the + * watchers never told), and the finalize, marked done, never ran again. + * Nothing a finalize runs leaves it now; a link's drain still journals + * its queue for a panic that would (#3400). */ + aether_fire_death_hook_contained(target->id, reason); + if (reason_release) reason_release(reason); +} + +// The heap copy of a typed message's payload, or NULL when there was no +// memory for it. +// Always use heap allocation for type-safe messages. +// TLS pools have thread affinity issues: a same-core actor may be migrated +// to another core after the message is sent, causing the receiver to call +// free() on pool memory (heap-use-after-free / free-on-non-malloc). +static void* payload_copy(void* message_data, size_t message_size) { + void* msg_copy = aether_alloc_test_refuses() ? NULL : malloc(message_size); + if (msg_copy) memcpy(msg_copy, message_data, message_size); + return msg_copy; +} + +static Message message_of(void* payload, int type, void* reply_slot) { Message msg; - msg.type = *(int*)message_data; + msg.type = type; msg.sender_id = 0; msg.payload_int = 0; - msg.payload_ptr = msg_copy; + msg.payload_ptr = payload; msg.zerocopy.data = NULL; msg.zerocopy.size = 0; msg.zerocopy.owned = 0; - msg._reply_slot = g_pending_reply_slot; + msg._reply_slot = reply_slot; return msg; } +// The heap copy of a typed message's payload, as the Message a send carries. +static Message payload_message(ActorBase* actor, void* message_data, size_t message_size) { + void* msg_copy = payload_copy(message_data, message_size); + if (!msg_copy) { + /* Inside a library call (#2956): nothing is queued yet, so the + * message's own values are released (they moved into it) and the + * call ends with AETHER_OUT_OF_MEMORY. Elsewhere the process stops, + * as it always did: the send has no way to fail. */ + AetherFrames* F = aether_frames_here(); + if (F->exec_depth > F->exec_floor) { + if (actor->release_message) actor->release_message(*(int*)message_data, message_data); + aether_out_of_memory("out of memory copying a message"); + } + aether_fatal_flush(); /* the program's lines first (#3435, aether_fatal.h) */ + fprintf(stderr, "aether: malloc(%zu) failed for msg type %d to actor %d\n", + message_size, *(int*)message_data, actor->id); + abort(); + } + return message_of(msg_copy, *(int*)message_data, NULL); +} + +// An ask's message (#3387): the payload copied, the asker's reply slot in it. +// An ask can fail, so a copy that finds no memory is its failure, not an +// unwind: 0, with what the message owns released (its values moved into it) +// and nothing sent. The slot is passed here rather than through a +// thread-local the send reads: the send can step its target inline, and +// every payload send that handler made read the asker's slot too. +int aether_ask_message(void* actor_ptr, void* message_data, size_t message_size, + void* reply_slot, Message* out) { + ActorBase* actor = (ActorBase*)actor_ptr; + void* msg_copy = payload_copy(message_data, message_size); + if (!msg_copy) { + if (actor && actor->release_message) actor->release_message(*(int*)message_data, message_data); + return 0; + } + *out = message_of(msg_copy, *(int*)message_data, reply_slot); + return 1; +} + // Send a typed message to an actor. The payload is copied to the heap and // handed to the scheduler, which delivers it whatever the actor count // (#3112): the handler runs later, on a scheduler thread, and the sender @@ -143,8 +194,12 @@ static Message payload_message(ActorBase* actor, void* message_data, size_t mess // aether_scheduler_poll. void aether_send_message(void* actor_ptr, void* message_data, size_t message_size) { ActorBase* actor = (ActorBase*)actor_ptr; - Message msg = payload_message(actor, message_data, message_size); + aether_send_built(actor, payload_message(actor, message_data, message_size)); +} +// A message built already (an ask's), to its actor: through this core when +// the actor is this core's, else through its own. +void aether_send_built(ActorBase* actor, Message msg) { #if AETHER_HAS_THREADS // Cache TLS once — avoids repeated tlv_get_addr calls on macOS const int my_core = current_core_id; @@ -159,6 +214,15 @@ void aether_send_message(void* actor_ptr, void* message_data, size_t message_siz #endif } +// try_send(ref, Msg {...}) of a payload message (#2974): sent as +// aether_send_message sends it, unless the actor is dead or its mailbox +// full, which it says ("" when sent). +const char* aether_try_send_message(void* actor_ptr, void* message_data, size_t message_size) { + ActorBase* actor = (ActorBase*)actor_ptr; + if (!actor) return "the actor is dead"; + return scheduler_try_send(actor, payload_message(actor, message_data, message_size)); +} + // A typed message sent inside a batched loop (#3117): it joins the batch, as // the loop's one-int messages do, so the loop's sends keep their order. void aether_send_message_batch(void* actor_ptr, void* message_data, size_t message_size) { diff --git a/runtime/actors/aether_send_message.h b/runtime/actors/aether_send_message.h index f63b1e360..00a874939 100644 --- a/runtime/actors/aether_send_message.h +++ b/runtime/actors/aether_send_message.h @@ -10,10 +10,11 @@ void aether_send_message(void* actor_ptr, void* message_data, size_t message_siz // (#3117). void aether_send_message_batch(void* actor_ptr, void* message_data, size_t message_size); -// Free message payload (tries thread-local pool first, then falls back to free()) +// Free a message payload (a heap copy: free()) void aether_free_message(void* msg_data); -// Get message pool statistics (for profiling) +// Deprecated (#3374): there is no message pool; reports zeros. Removed at +// host ABI level 2 (docs/abi-policy.md, "Deprecated names"). void aether_message_pool_stats(uint64_t* hits, uint64_t* misses, uint64_t* large); #endif diff --git a/runtime/actors/aether_step_barrier.h b/runtime/actors/aether_step_barrier.h new file mode 100644 index 000000000..f5dd796ea --- /dev/null +++ b/runtime/actors/aether_step_barrier.h @@ -0,0 +1,340 @@ +#ifndef AETHER_STEP_BARRIER_H +#define AETHER_STEP_BARRIER_H + +/* One step of an actor inside the panic barrier, shared by the multicore and + * the cooperative scheduler (#2974). + * + * If the handler calls aether_panic() or takes a caught signal, the barrier + * unwinds: the actor ends (its PANICKED bit, the panic's reason unless + * something ended it first), the message its handler was running on and the + * ask it held are dropped and failed, the scheduler finalizes its death + * (drains what waits for it and tells its watchers), and the C death hook + * fires. Otherwise, after the step: an ask the handler took and left + * unanswered fails, a stop() or a kill that came while it ran is finalized, + * and the scheduler's own after-step work runs. + * + * A step runs in its own context, whatever thread runs it and whatever + * runs below it there (#3389). A step can run inside another: a send steps + * an idle actor of the same core inline while the sender's handler is still + * running, and a cooperative ask polls until its reply. The rule, for every + * step and both of its ends: + * + * - at entry, the step puts aside the per-thread context of the code it + * runs inside, and starts with none of it; + * - at exit, normal or by panic, it gives that context back as it was; + * - its panic, and its end, undo only what the step itself made; + * - what follows the handler is the step's too, and runs in its context + * as on the actor's own thread: the drop of the message a panicking + * handler ran on, the finalize of an actor the step ended (its drops + * are the actor's code), the death hook. Each runs in a containment + * frame of its own (aether_contain), so a panic there ends nothing but + * itself and cannot leave with the context put aside (#3403), and the + * context is given back once they are done (#3416); + * - no step runs an actor whose step is already on the stack, so none + * finalizes an actor whose handler is still running: the multicore + * scheduler steps an actor only under its step lock, and the + * cooperative one leaves an actor stepping out of its polls (#3391). + * + * The context, part by part: + * - the ask in hand (g_current_reply_slot) and the actor stepping + * (g_current_step_actor): a handler that sends, or asks, before it + * replies still answers its own asker. Once the handler is done there + * is neither, as on a core's thread after a step: what follows is no + * handler's; + * - the in-step flags (g_aether_in_actor_step, g_aether_current_actor_id): + * the outer handler keeps its signal recovery after an inner step; + * - a supervisor's restart claim, the reborn block and the spawn brackets + * (aether_supervision.c): a nested step's spawn takes no claimed block + * and is no factory's child, and its panic undoes no enclosing claim; + * - the sandbox levels in force on the thread, in this runtime copy and + * every other it shares the count with (aether_sandbox.c, #3393): a + * receiver stepped inside an enforce block is checked as it is on a + * thread of its own, against the levels it pushes itself and no other, + * its drops after the handler included (#3416), and the block's levels + * are in force again when the send returns; + * - the library calls (executions) running on the thread, with what they + * carry (aether_unwind.c, aether_exec.c, aether_host.c, #3392, #3396): + * an interruption on its way through them, the call's deadline and + * cancel, the stop a failed allocation notes for the call, and the + * caller info the host set for it. An actor stepped inside a call (a + * send's inline step, a cooperative ask's poll in an export) is not the + * call: its loop heads are not cut by the call's deadline, a try in it + * catches its own panics, its failed allocations are its own, and it + * reads no caller info, as on a core's thread, in its handler and in + * its drops after it. The call is cut at its own next safe point once + * the step returns; + * - the execution account in use (aether_resource_caps.c, #2954): an + * actor stepped inside code that has one in use is charged nothing to + * it, its drops included, as on a thread of its own; the account stays + * the thread's meanwhile, so no other thread takes it, and one the step + * left in use is let go. + * + * A plain step pays for none of the parts: one word says whether some + * thread holds any of them, aether_caps_slow's step bits (the accounts + * exist; a sandbox level may be in force where this copy's steps can see + * it, #3419; the count of live restart claims and spawn captures, + * aether_resource_caps.h), read once at + * each end with one predicted branch, and only when it is set are the parts + * looked at, out of line, each behind what says this thread holds it (for + * the levels, the count of threads with one, through + * aether_sandbox_in_force). The executions are behind two fields of the + * thread's frames block, which the step's try frame is pushed on anyway + * (their depth against the floor, and an interruption on its way); the ask + * and the actor are two thread-local words, and the in-step flags are + * restored from what the step read when it set them. Neither the locals + * holding what was put aside nor the variables change between the + * sigsetjmp and a longjmp back to it, so all of it is intact on the panic + * path. + * + * The scheduler's parts are passed as functions it defines static: the + * compiler sees them at the call and inlines the whole. The caller has + * checked that the actor has a step and is not dead, and holds what it holds + * to step it (a core's step lock). The step lock is not released here, on + * either path (#2163): the caller took it and clears it. + * + * Where the target has no setjmp (wasi), AETHER_SIGSETJMP always takes the + * first arm and a panic aborts the program (aether_panic.h). */ + +#include + +#include "aether_panic.h" +#include "aether_frames.h" +#include "../scheduler/multicore_scheduler.h" +#include "../utils/aether_trace.h" +#include "../aether_resource_caps.h" + +extern AETHER_TLS void* g_current_reply_slot; +extern AETHER_TLS void* g_current_step_actor; + +/* The sandbox's part (aether_panic.c's, which aether_sandbox.c sets when it + * first looks for the runtime copies): the threads with a level in force, + * and each copy's hooks, by which this thread's levels are put aside and + * given back (aether_sandbox_hooks, aether_panic.h). */ +extern atomic_int* _Atomic aether_sandbox_in_force; +/* scheduler_init joins another copy's count through this (#3438): a step + * reads only its own copy's word, and a copy that had looked nothing up + * stepped plainly inside another copy's levels. */ +extern AETHER_COPY_LOCAL void (*_Atomic aether_sandbox_join)(void); + +/* What a step put aside, to give back when it ends. `hid` has a bit for each + * part put aside (AETHER_STEP_HID_*, aether_frames.h, where aether_step_push + * sets the executions'); the rest is read only under its bit. */ +_Static_assert((AETHER_STEP_HID_EXECS & (AETHER_STEP_HID_SPAWN | AETHER_STEP_HID_ACCOUNT | + AETHER_STEP_HID_SANDBOX)) == 0 && + (AETHER_STEP_HID_SPAWN & (AETHER_STEP_HID_ACCOUNT | AETHER_STEP_HID_SANDBOX)) == 0 && + (AETHER_STEP_HID_ACCOUNT & AETHER_STEP_HID_SANDBOX) == 0, + "the step's part bits are distinct"); +typedef struct { + void* reply_slot; + void* step_actor; + int in_step; + int actor_id; + int hid; + AetherSpawnAside spawn; + /* The copies of the sandbox that put levels aside, and what each kept + * (#3438): every copy registered with this barrier hides its own. */ + int sandbox_n; + const AetherSandboxHooks* sandbox_hooks[AETHER_STEP_SANDBOX_COPIES]; + int sandbox[AETHER_STEP_SANDBOX_COPIES][2]; + AetherExecAside execs; + struct AetherExecAccount* account; +} AetherStepAside; + +#if defined(__GNUC__) || defined(__clang__) +#define AETHER_STEP_COLD __attribute__((cold, noinline)) +#else +#define AETHER_STEP_COLD +#endif + +/* The parts some thread holds, as the caps word's step bits say: each put + * aside when this thread holds it. */ +static AETHER_STEP_COLD void aether_step_put_aside_parts(AetherStepAside* s, unsigned word) { + if ((word & AETHER_CAPS_SPAWN_MASK) && aether_spawn_put_aside(&s->spawn)) + s->hid |= AETHER_STEP_HID_SPAWN; + if (word & AETHER_CAPS_SLOW_ACCOUNTS) { + s->account = aether_account_step_aside(); + s->hid |= AETHER_STEP_HID_ACCOUNT; + } + if (word & AETHER_CAPS_SLOW_SANDBOX) { + atomic_int* in_force = atomic_load_explicit(&aether_sandbox_in_force, memory_order_acquire); + if (in_force != NULL && atomic_load_explicit(in_force, memory_order_relaxed) != 0) { + int n = 0; + for (int i = 0; i < AETHER_SANDBOX_HOOKS_MAX && n < AETHER_STEP_SANDBOX_COPIES; i++) { + const AetherSandboxHooks* h = atomic_load_explicit(&aether_sandbox_hooks[i], memory_order_acquire); + if (h && h->hide && h->show && h->hide(s->sandbox[n])) s->sandbox_hooks[n++] = h; + } + s->sandbox_n = n; + if (n) s->hid |= AETHER_STEP_HID_SANDBOX; + } + } +} + +static inline void aether_step_put_aside(AetherStepAside* s) { + s->reply_slot = g_current_reply_slot; + s->step_actor = g_current_step_actor; + g_current_reply_slot = NULL; + s->in_step = g_aether_in_actor_step; + s->actor_id = s->in_step ? g_aether_current_actor_id : -1; + s->hid = 0; + /* One word, one predicted branch: 0 on a plain step. */ + unsigned word = atomic_load_explicit(&aether_caps_slow, memory_order_relaxed) & AETHER_CAPS_STEP_BITS; + if (unlikely(word != 0)) aether_step_put_aside_parts(s, word); +} + +static AETHER_STEP_COLD void aether_step_give_back_parts(const AetherStepAside* s) { + if (s->hid & AETHER_STEP_HID_EXECS) aether_step_give_back_execs(&s->execs); + if (s->hid & AETHER_STEP_HID_SANDBOX) /* the last to hide gives back first */ + for (int i = s->sandbox_n - 1; i >= 0; i--) s->sandbox_hooks[i]->show(s->sandbox[i]); + /* Accounts made while the step ran, and one it left in use, count too. */ + if ((s->hid & AETHER_STEP_HID_ACCOUNT) || + (atomic_load_explicit(&aether_caps_slow, memory_order_relaxed) & AETHER_CAPS_SLOW_ACCOUNTS)) + aether_account_step_back((s->hid & AETHER_STEP_HID_ACCOUNT) ? s->account : NULL); + if (s->hid & AETHER_STEP_HID_SPAWN) aether_spawn_give_back(&s->spawn); +} + +static inline void aether_step_give_back(const AetherStepAside* s) { + /* What it put aside, and an account made while it ran: one word again. */ + if (unlikely((unsigned)s->hid | + (atomic_load_explicit(&aether_caps_slow, memory_order_relaxed) & AETHER_CAPS_SLOW_ACCOUNTS))) + aether_step_give_back_parts(s); + g_aether_in_actor_step = s->in_step; + g_aether_current_actor_id = s->actor_id; + g_current_reply_slot = s->reply_slot; + g_current_step_actor = s->step_actor; +} + +/* The finalize of an actor its step ended, in the step's context and in a + * containment frame of its own (#3416). Its drops are the actor's code, each + * contained already with the death hook a drop's panic is reported to + * (aether_message_drop_at_end, #3417), so nothing it runs leaves it; the + * frame stands for a panic a later change would let out, which is then + * reported on stderr and never leaves with the context put aside. */ +typedef struct { + ActorBase* actor; + void (*finalize)(ActorBase*); +} AetherStepFinalize; + +static inline void aether_step_finalize_body(void* p) { + AetherStepFinalize* c = (AetherStepFinalize*)p; + c->finalize(c->actor); +} + +static inline void aether_step_finalize(ActorBase* actor, void (*finalize)(ActorBase*)) { + AetherStepFinalize c = { actor, finalize }; + const char* r = NULL; + void (*rel)(const void*) = NULL; + if (aether_contain(aether_step_finalize_body, &c, &r, &rel) == 1) { + fprintf(stderr, "aether: the end of actor %d panicked: %s\n", actor->id, + aether_panic_reason_text(r)); + if (rel) rel(r); + } +} + +static inline void aether_step_barrier(ActorBase* actor, + void (*finalize)(ActorBase*), + void (*fail_slot)(void* slot, int why), + void (*after_step)(ActorBase*)) { + AetherStepAside aside; + aether_step_put_aside(&aside); + + /* The try frame, with the executions running on the thread put aside + * in the same look at its block (aether_unwind.c). */ + AetherJmpFrame* f = aether_step_push(&aside.execs, &aside.hid); + if (AETHER_SIGSETJMP(f->buf, 1) == 0) { + g_aether_in_actor_step = 1; + g_aether_current_actor_id = actor->id; + AETHER_TRACE_EVENT(AE_TRACE_STEP_BEGIN, actor->id, -1, -1); + actor->step(actor); + AETHER_TRACE_EVENT(AE_TRACE_STEP_END, actor->id, -1, -1); + g_aether_in_actor_step = 0; + g_aether_current_actor_id = -1; + aether_try_pop(); + /* The handler is done; what follows is still the step's, in its + * context, and gives the context back last (#3416). */ + void* unanswered = g_current_reply_slot; + g_current_reply_slot = NULL; + /* A handler that took an ask and returned without replying: no + * reply can come, since a reply answers the ask its own step took. + * Its asker learns that now, not at its timeout. */ + if (unanswered) fail_slot(unanswered, AETHER_ASK_NO_REPLY); + /* Ended while the handler ran (#2974): its stop(), or a kill or a + * linked actor's death from elsewhere, takes effect now that the + * handler has returned, so no Down is sent while it runs. Its drops + * are the actor's code, run as on its own thread, where no handler + * is running; the finalize is contained, so nothing leaves it with + * the context put aside. A release ends it by its own path. */ + int ended = atomic_load_explicit(&actor->dead, memory_order_acquire); + if (unlikely(ended != 0)) { + if (!(ended & AETHER_ACTOR_RELEASED)) { + g_current_step_actor = NULL; + aether_step_finalize(actor, finalize); + } + } else if (after_step) { + after_step(actor); + } + aether_step_give_back(&aside); + return; + } + + // Unwound via panic or signal. + const char* reason = f->reason; + void (*reason_release)(const void*) = f->reason_release; + g_aether_in_actor_step = 0; + g_aether_current_actor_id = -1; + aether_try_pop(); + + // A bit, not a store: a release may be setting its own bit meanwhile. + // The reason is the panic's unless something ended the actor first. + aether_actor_claim_end(actor, aether_panic_reason_text(reason), AETHER_ACTOR_PANICKED); + atomic_store_explicit(&actor->active, 0, memory_order_release); + + /* What the step made of the thread's own is taken, before the context + * it ran inside is given back: the message its handler was running on, + * the ask it was handling, and a restart in place it had begun, which is + * undone (#2974), with the spawn brackets it opened: its own, never an + * enclosing step's, which were put aside (#3389). */ + Message held = {0}; + if (actor->step_payload) { + held.type = actor->step_type; + held.payload_ptr = actor->step_payload; + actor->step_payload = NULL; + } + void* unanswered = g_current_reply_slot; + g_current_reply_slot = NULL; + aether_spawn_step_panicked(); + /* The step is over, and this frame is gone. What follows runs program + * code (drop functions, the host's death hook) that may panic: the + * actor's, so in the step's context, as on its own thread, where no + * handler is running (#3416); each piece in a containment frame of its + * own, so none ends anything but itself or leaves with the context put + * aside (#3403). The context is given back after them. */ + g_current_step_actor = NULL; + + /* The message (#3142): the release that follows the handler did not + * run, so what the message owns and its payload go here, as a dropped + * message's do; a drop function that panics is reported as the handler's + * panic is. */ + if (held.payload_ptr) { + const char* why = NULL; + void (*why_release)(const void*) = NULL; + if (aether_message_drop_contained(actor, &held, &why, &why_release)) { + aether_fire_death_hook_contained(actor->id, why); + if (why_release) why_release(why); + } + } + + /* The ask, if it had not replied (#3151): its asker learns now that no + * reply is coming, instead of at its timeout. */ + if (unanswered) fail_slot(unanswered, AETHER_ASK_DIED); + + // What waits for it is dropped and credited, and its watchers told. + aether_step_finalize(actor, finalize); + + aether_fire_death_hook_contained(actor->id, reason); + /* A heap-built panic message (aether_panic_owned) belongs to whoever + * caught it, here this barrier. The hook has seen it; release it. */ + if (reason_release) reason_release(reason); + aether_step_give_back(&aside); +} + +#endif /* AETHER_STEP_BARRIER_H */ diff --git a/runtime/actors/aether_supervision.c b/runtime/actors/aether_supervision.c new file mode 100644 index 000000000..48cf57597 --- /dev/null +++ b/runtime/actors/aether_supervision.c @@ -0,0 +1,759 @@ +// Death notices (#2974): monitors, links and the Down message, shared by +// the multicore and the cooperative scheduler. +// +// Every actor that is watched, or watches, has a watch list +// (`ActorBase.death_watch`, made on first use): who monitors it (WATCHER, with the +// monitor id), whom it monitors (MONITORS, the same id), and whom it is +// linked to (LINK). Its death is told once, by aether_watch_notify, which a +// scheduler calls when it finalizes the death on the actor's own thread, or +// a release from the releasing thread: the list is marked finished under its +// lock and its entries taken, each WATCHER is sent a Down, the actor lets go +// of the actors it monitors, and each actor linked to it takes the death: a +// Down if it traps exits, else the same end, unless the reason is "normal". +// A monitor or a link set after that finds the list finished and is answered +// at once, with the reason the death left. +// +// Locks: one watch list at a time, never two. A monitor adds its own +// MONITORS entry first, then its WATCHER entry in the target's list; a +// death or a demonitor that runs between the two finds one side and not the +// other, and each side is checked again where it is used: a Down whose +// MONITORS entry is gone is dropped by the watcher (aether_down_accept), and +// a WATCHER entry whose watcher has let go is removed by that watcher. + +#include "../scheduler/multicore_scheduler.h" +#include "../../std/string/aether_string.h" +#include "aether_panic.h" +#include "../aether_resource_caps.h" +#include +#include +#include + +enum { WATCH_WATCHER = 1, WATCH_MONITORS = 2, WATCH_LINK = 3 }; + +typedef struct { + int kind; + int id; // a monitor's id; 0 for a link + ActorBase* other; +} AetherWatchEntry; + +typedef struct AetherWatch { + atomic_flag lock; + int finished; // the death was told: no entry is added after + int count; + int cap; + AetherWatchEntry* e; + _Atomic(char*) reason; // why the actor ended (aether_actor_death_reason) +} AetherWatch; + +static atomic_int g_next_monitor_id = 1; + +static void watch_lock(AetherWatch* w) { + while (atomic_flag_test_and_set_explicit(&w->lock, memory_order_acquire)) AETHER_CPU_PAUSE(); +} + +static void watch_unlock(AetherWatch* w) { + atomic_flag_clear_explicit(&w->lock, memory_order_release); +} + +/* The actor's list, made by the first caller; NULL only out of memory. */ +static AetherWatch* watch_of(ActorBase* a) { + AetherWatch* w = atomic_load_explicit(&a->death_watch, memory_order_acquire); + if (w) return w; + AetherWatch* fresh = (AetherWatch*)calloc(1, sizeof(AetherWatch)); + if (!fresh) return NULL; + atomic_flag_clear_explicit(&fresh->lock, memory_order_relaxed); + AetherWatch* expected = NULL; + if (atomic_compare_exchange_strong_explicit(&a->death_watch, &expected, fresh, + memory_order_acq_rel, memory_order_acquire)) { + return fresh; + } + free(fresh); + return expected; +} + +/* Under the list's lock. 0 out of memory. */ +static int watch_add(AetherWatch* w, int kind, int id, ActorBase* other) { + if (w->count == w->cap) { + int cap = w->cap ? w->cap * 2 : 4; + AetherWatchEntry* grown = (AetherWatchEntry*)realloc(w->e, (size_t)cap * sizeof(AetherWatchEntry)); + if (!grown) return 0; + w->e = grown; + w->cap = cap; + } + w->e[w->count].kind = kind; + w->e[w->count].id = id; + w->e[w->count].other = other; + w->count++; + return 1; +} + +/* Under the list's lock: removes the first entry of `kind` with `id` (when + * `id` is not 0) or `other`; returns it, or kind 0 when there is none. */ +static AetherWatchEntry watch_remove(AetherWatch* w, int kind, int id, ActorBase* other) { + AetherWatchEntry found = { 0, 0, NULL }; + for (int i = 0; i < w->count; i++) { + AetherWatchEntry* e = &w->e[i]; + if (e->kind != kind) continue; + if (id ? e->id != id : e->other != other) continue; + found = *e; + w->e[i] = w->e[--w->count]; + break; + } + return found; +} + +/* Removes an entry from `a`'s list, if it has one. */ +static void watch_remove_from(ActorBase* a, int kind, int id, ActorBase* other) { + AetherWatch* w = atomic_load_explicit(&a->death_watch, memory_order_acquire); + if (!w) return; + watch_lock(w); + (void)watch_remove(w, kind, id, other); + watch_unlock(w); +} + +/* A Down for `to`: `dead` ended with `reason`; `id` the monitor's, 0 for a + * link. The reason is a counted string the message owns. */ +static void send_down(ActorBase* to, ActorBase* dead, const char* reason, int id) { + AetherDownMsg* p = (AetherDownMsg*)malloc(sizeof(AetherDownMsg)); + const char* text = reason ? reason : "normal"; + const char* owned = p ? (const char*)string_new_with_length(text, strlen(text)) : NULL; + if (!p || !owned) { + free(p); + fprintf(stderr, "aether: out of memory for the Down of actor %d to actor %d; " + "it is not sent\n", dead->id, to->id); + return; + } + p->_message_id = AETHER_MSG_DOWN; + p->ref = dead; + p->reason = owned; + Message m; + memset(&m, 0, sizeof m); + m.type = AETHER_MSG_DOWN; + m.payload_ptr = p; + m.payload_int = id; + scheduler_send_remote(to, m, aether_core_id_get()); +} + +/* The ends links pass on (#3394). Where a death is finalized as soon as it + * is seen (the cooperative scheduler, and the threaded one while it is not + * running), ending an actor tells its own links at once, so a chain of + * linked actors ended one inside the other: a few frames per actor, and a + * chain of thousands overflowed a small stack. An end asked for while this + * thread is making one waits in the thread's queue instead, and the + * outermost end makes them until none is left: the stack holds one link's + * frames whatever the chain's length (a chain queues one end at a time). + * The queue is a stack whose entries each end pushed are turned round, so + * the ends are made in the order the recursion made them: an actor's links + * in their list's order, each one's own links before the next. The queue + * goes when it is empty. + * + * A panic can leave the drain. Ending an actor drops what waits for it, and + * a dropped message runs its release, which is the program's own code for a + * field of an owned type (its drop function, #3142) and may panic. That + * panic is caught at the drop and reported through the death hook (#3401), + * but the hook is a C host's code and runs in the drain too. A jump out of + * it goes to the nearest frame below (the step barrier of the handler whose + * kill started the chain, or a `try`), past the drain, and the thread would + * keep `t_link_ending` set: every later end a link passed on there would be + * queued and never made. So the outermost end journals the queue with the + * unwind journal, as a step journals what a panic must release + * (aether_unwind.h): a panic that leaves it puts the queue back as it was + * before the end, empty and not ending. The ends still queued are given up + * with the rest of the work the panic cut short, as the recursion's unwind + * gave them up. */ +typedef struct { + ActorBase* to; + char* reason; +} AetherLinkEnd; + +static AETHER_TLS AetherLinkEnd* t_link_ends = NULL; +static AETHER_TLS int t_link_end_count = 0; +static AETHER_TLS int t_link_end_cap = 0; +static AETHER_TLS int t_link_ending = 0; +static AETHER_TLS char* t_link_end_reason = NULL; // the reason of the end being made + +/* The queue as it is before an outermost end: empty, nothing held. */ +static void link_end_reset(void) { + for (int i = 0; i < t_link_end_count; i++) free(t_link_ends[i].reason); + free(t_link_ends); + t_link_ends = NULL; + t_link_end_count = 0; + t_link_end_cap = 0; + free(t_link_end_reason); + t_link_end_reason = NULL; + t_link_ending = 0; +} + +/* The journal's release, run by a panic that leaves the drain. */ +static void link_end_unwind(const void* p) { + (void)p; + link_end_reset(); +} + +/* 1 when the end of `to` is queued; 0 out of memory. */ +static int link_end_queue(ActorBase* to, const char* reason) { + if (t_link_end_count == t_link_end_cap) { + int cap = t_link_end_cap ? t_link_end_cap * 2 : 16; + AetherLinkEnd* q = (AetherLinkEnd*)realloc(t_link_ends, (size_t)cap * sizeof(AetherLinkEnd)); + if (!q) return 0; + t_link_ends = q; + t_link_end_cap = cap; + } + char* copy = strdup(reason); + if (!copy) return 0; + t_link_ends[t_link_end_count].to = to; + t_link_ends[t_link_end_count].reason = copy; + t_link_end_count++; + return 1; +} + +/* `to` ended, with what its end queued from `base` on turned round, so the + * first of them is taken next. */ +static void link_end_make(ActorBase* to, const char* reason, int base) { + scheduler_actor_end(to, reason, AETHER_ACTOR_KILLED); + for (int lo = base, hi = t_link_end_count - 1; lo < hi; lo++, hi--) { + AetherLinkEnd t = t_link_ends[lo]; + t_link_ends[lo] = t_link_ends[hi]; + t_link_ends[hi] = t; + } +} + +static void link_end(ActorBase* to, const char* reason) { + if (t_link_ending) { + // No memory to queue it: ended here instead, on the stack. + if (!link_end_queue(to, reason)) scheduler_actor_end(to, reason, AETHER_ACTOR_KILLED); + return; + } + t_link_ending = 1; + aether_unwind_track(&t_link_ending, link_end_unwind); + link_end_make(to, reason, 0); + while (t_link_end_count > 0) { + AetherLinkEnd e = t_link_ends[--t_link_end_count]; + t_link_end_reason = e.reason; // the journal's to free, should the end panic + link_end_make(e.to, e.reason, t_link_end_count); + t_link_end_reason = NULL; + free(e.reason); + } + aether_unwind_forget(&t_link_ending); + link_end_reset(); +} + +/* `dead` ended with `reason`, and `to` was linked to it: `to` gets a Down + * when it traps exits, else it ends with the same reason, "killed" by it, + * unless that reason is "normal", which ends nothing. */ +static void link_effect(ActorBase* to, ActorBase* dead, const char* reason) { + const char* r = reason ? reason : "normal"; + if (atomic_load_explicit(&to->exit_flags, memory_order_acquire) & AETHER_EXIT_TRAP) { + send_down(to, dead, r, 0); + } else if (strcmp(r, "normal") != 0) { + link_end(to, r); + } +} + +void aether_actor_claim_reason(ActorBase* a, const char* reason) { + AetherWatch* w = watch_of(a); + if (!w) { + fprintf(stderr, "aether: out of memory keeping why actor %d ended\n", a->id); + return; + } + if (atomic_load_explicit(&w->reason, memory_order_acquire)) return; + char* copy = strdup(reason ? reason : "normal"); + char* none = NULL; + if (copy && !atomic_compare_exchange_strong_explicit(&w->reason, &none, copy, + memory_order_acq_rel, + memory_order_acquire)) { + free(copy); + } +} + +const char* aether_actor_death_reason(ActorBase* a) { + AetherWatch* w = a ? atomic_load_explicit(&a->death_watch, memory_order_acquire) : NULL; + return w ? atomic_load_explicit(&w->reason, memory_order_acquire) : NULL; +} + +int aether_actor_claim_end(ActorBase* a, const char* reason, int bit) { + if (!(atomic_load_explicit(&a->dead, memory_order_acquire) & AETHER_ACTOR_ENDED)) + aether_actor_claim_reason(a, reason); + int was = atomic_fetch_or_explicit(&a->dead, bit, memory_order_acq_rel); + return !(was & AETHER_ACTOR_ENDED); +} + +/* The journal's release of the entries a death notice took. */ +static void watch_entries_unwind(const void* p) { + free((void*)p); +} + +void aether_watch_notify(ActorBase* a, const char* reason) { + AetherWatch* w = watch_of(a); + if (!w) { + fprintf(stderr, "aether: out of memory telling the death of actor %d\n", a->id); + return; + } + watch_lock(w); + if (w->finished) { + watch_unlock(w); + return; + } + w->finished = 1; + AetherWatchEntry* e = w->e; + int n = w->count; + w->e = NULL; + w->count = 0; + w->cap = 0; + watch_unlock(w); + + /* The entries are this call's now, and a link's effect ends actors, so + * a panic that leaves it skips the free below: journaled until then. */ + if (e) aether_unwind_track(e, watch_entries_unwind); + for (int i = 0; i < n; i++) { + switch (e[i].kind) { + case WATCH_WATCHER: + send_down(e[i].other, a, reason, e[i].id); + break; + case WATCH_MONITORS: + // It watches no more: its target's death tells it nothing. + watch_remove_from(e[i].other, WATCH_WATCHER, e[i].id, NULL); + break; + case WATCH_LINK: + // The link goes, both ways, and the other side takes the death. + watch_remove_from(e[i].other, WATCH_LINK, 0, a); + link_effect(e[i].other, a, reason); + break; + default: + break; + } + } + if (e) aether_unwind_forget(e); + free(e); +} + +void aether_watch_free(ActorBase* a) { + AetherWatch* w = atomic_exchange_explicit(&a->death_watch, NULL, memory_order_acq_rel); + if (w) { + free(w->e); + free(atomic_load_explicit(&w->reason, memory_order_acquire)); + free(w); + } +} + +/* A restart in place (#2974): the old incarnation's entries and reason go, the + * list itself stays. Another actor's monitor or link of the dead child may + * hold it, to read the death it told under its lock, while the supervisor + * restarts the child; the new incarnation's death is told through it. */ +static void watch_reset(ActorBase* a) { + AetherWatch* w = atomic_load_explicit(&a->death_watch, memory_order_acquire); + if (!w) return; + watch_lock(w); + AetherWatchEntry* e = w->e; + char* reason = atomic_exchange_explicit(&w->reason, NULL, memory_order_acq_rel); + w->e = NULL; + w->count = 0; + w->cap = 0; + w->finished = 0; + watch_unlock(w); + free(e); + free(reason); +} + +/* The reason a finished list keeps, copied: read under the list's lock, since + * a restart may empty it once the lock goes. NULL out of memory. */ +static char* watch_reason_copy(AetherWatch* w) { + const char* r = atomic_load_explicit(&w->reason, memory_order_acquire); + return strdup(r ? r : "normal"); +} + +int aether_down_accept(ActorBase* self, Message* msg) { + int id = (int)msg->payload_int; + if (id == 0) return 1; // a link's, or one a program sent + AetherWatch* w = atomic_load_explicit(&self->death_watch, memory_order_acquire); + if (!w) return 0; + watch_lock(w); + AetherWatchEntry found = watch_remove(w, WATCH_MONITORS, id, NULL); + watch_unlock(w); + return found.kind != 0; +} + +static void free_reason(const void* p) { + free((void*)p); +} + +/* The actor stepping on this thread, for the std.actors surface. */ +static ActorBase* current_self(const char* what) { + ActorBase* self = (ActorBase*)g_current_step_actor; + if (!self) { + char msg[160]; + snprintf(msg, sizeof msg, "%s outside an actor's handler: it acts on the actor running it", what); + char* owned = strdup(msg); + if (owned) aether_panic_owned(owned, free_reason); + aether_panic(what); + } + return self; +} + +int aether_actor_monitor(void* ref) { + ActorBase* self = current_self("actors.monitor()"); + ActorBase* target = (ActorBase*)ref; + if (!target) aether_panic("actors.monitor() of a null actor reference"); + int id = atomic_fetch_add_explicit(&g_next_monitor_id, 1, memory_order_relaxed); + if (id <= 0) { // wrapped: ids stay positive (0 marks a link's Down) + atomic_store_explicit(&g_next_monitor_id, 2, memory_order_relaxed); + id = 1; + } + AetherWatch* mine = watch_of(self); + if (!mine) aether_panic("actors.monitor(): out of memory"); + watch_lock(mine); + int ok = watch_add(mine, WATCH_MONITORS, id, target); + watch_unlock(mine); + if (!ok) aether_panic("actors.monitor(): out of memory"); + + // Released: told at its release; its block may already hold another + // actor's lists, so it is answered without touching them. + if (atomic_load_explicit(&target->dead, memory_order_acquire) & AETHER_ACTOR_RELEASED) { + send_down(self, target, "normal", id); + return id; + } + AetherWatch* theirs = watch_of(target); + if (!theirs) { + watch_remove_from(self, WATCH_MONITORS, id, NULL); + aether_panic("actors.monitor(): out of memory"); + } + + watch_lock(theirs); + if (theirs->finished) { + // Ended already: told now, with the reason its death left. + char* why = watch_reason_copy(theirs); + watch_unlock(theirs); + send_down(self, target, why ? why : "normal", id); + free(why); + return id; + } + ok = watch_add(theirs, WATCH_WATCHER, id, self); + watch_unlock(theirs); + if (!ok) { + watch_remove_from(self, WATCH_MONITORS, id, NULL); + aether_panic("actors.monitor(): out of memory"); + } + return id; +} + +void aether_actor_demonitor(int id) { + ActorBase* self = current_self("actors.demonitor()"); + if (id <= 0) return; + AetherWatch* mine = atomic_load_explicit(&self->death_watch, memory_order_acquire); + if (!mine) return; + watch_lock(mine); + AetherWatchEntry found = watch_remove(mine, WATCH_MONITORS, id, NULL); + watch_unlock(mine); + if (found.kind) watch_remove_from(found.other, WATCH_WATCHER, id, NULL); +} + +void aether_actor_stop(void) { + ActorBase* self = current_self("actors.stop()"); + // Finalized when this handler returns (the step barrier's check). + aether_actor_claim_end(self, "normal", AETHER_ACTOR_STOPPED); +} + +void aether_actor_kill(void* ref) { + if (ref) scheduler_actor_end((ActorBase*)ref, "killed", AETHER_ACTOR_KILLED); +} + +int64_t aether_actor_dead_sends(void) { + return (int64_t)scheduler_dead_sends(); +} + +// Plain sends to `ref` dropped because its mailbox was full (#2974). +int64_t aether_actor_full_drops(void* ref) { + return ref ? (int64_t)atomic_load_explicit(&((ActorBase*)ref)->full_drops, memory_order_relaxed) : 0; +} + +void aether_actor_link(void* ref) { + ActorBase* self = current_self("actors.link()"); + ActorBase* other = (ActorBase*)ref; + if (!other) aether_panic("actors.link() of a null actor reference"); + if (other == self) return; + AetherWatch* mine = watch_of(self); + if (!mine) aether_panic("actors.link(): out of memory"); + watch_lock(mine); + int ok = watch_add(mine, WATCH_LINK, 0, other); + watch_unlock(mine); + if (!ok) aether_panic("actors.link(): out of memory"); + + // Ended already: the link takes the death now, as it would have then. + const char* ended_with = NULL; + char* ended_copy = NULL; + if (atomic_load_explicit(&other->dead, memory_order_acquire) & AETHER_ACTOR_RELEASED) { + ended_with = "normal"; + } else { + AetherWatch* theirs = watch_of(other); + if (!theirs) { + watch_remove_from(self, WATCH_LINK, 0, other); + aether_panic("actors.link(): out of memory"); + } + watch_lock(theirs); + if (theirs->finished) { + ended_copy = watch_reason_copy(theirs); + ended_with = ended_copy ? ended_copy : "normal"; + } else { + ok = watch_add(theirs, WATCH_LINK, 0, self); + } + watch_unlock(theirs); + if (!ok) { + watch_remove_from(self, WATCH_LINK, 0, other); + aether_panic("actors.link(): out of memory"); + } + } + if (ended_with) { + watch_remove_from(self, WATCH_LINK, 0, other); + link_effect(self, other, ended_with); + } + free(ended_copy); +} + +void aether_actor_unlink(void* ref) { + ActorBase* self = current_self("actors.unlink()"); + ActorBase* other = (ActorBase*)ref; + if (!other || other == self) return; + watch_remove_from(self, WATCH_LINK, 0, other); + if (!(atomic_load_explicit(&other->dead, memory_order_acquire) & AETHER_ACTOR_RELEASED)) + watch_remove_from(other, WATCH_LINK, 0, self); +} + +void aether_actor_trap_exits(int on) { + ActorBase* self = current_self("actors.trap_exits()"); + if (on) atomic_fetch_or_explicit(&self->exit_flags, AETHER_EXIT_TRAP, memory_order_acq_rel); + else atomic_fetch_and_explicit(&self->exit_flags, ~AETHER_EXIT_TRAP, memory_order_acq_rel); +} + +// --------------------------------------------------------------------------- +// Restart in place (#2974) +// +// A supervisor restarts a child under the ref its siblings and clients hold: +// it reruns the child's factory, and the factory's spawn of the child's type +// takes the child's own block. The block is claimed only when no message for +// it can still arrive: the child has ended and its death is finalized (what +// was queued is dropped), its step lock is taken (nothing steps it), and no +// message is counted in flight for it. A counted send reads its target's +// death after counting itself (send_through_core), so one counted after that +// check drops itself while the child is dead, and one that finds it alive +// again reaches the new incarnation: nothing sent before the restart reaches +// it. A local send reads the death on the child's core and drops. The child's +// core touches a dead, quiescent actor through nothing else: it delivers only +// counted messages and steps only under the step lock, which the rebirth +// holds until the new incarnation is alive (scheduler_spawn_ready). +// +// The claim and the reborn block are this thread's (one rebirth at a time), +// never named by generated code. +// --------------------------------------------------------------------------- + +static AETHER_TLS int t_capture_depth = 0; // the spawn capture, below +/* The brackets below this depth are an enclosing step's (#3389): a step + * nested in it notes no spawn into them and closes none of them. */ +static AETHER_TLS int t_capture_floor = 0; +/* Live rebirth claims and spawn captures, on any thread, are counted in + * aether_caps_slow from bit 8 (AETHER_CAPS_SPAWN_ONE each, aether_resource_caps.h), + * the word a step reads anyway to know what to put aside. Every spawn passes + * through aether_rebirth_take and scheduler_spawn_ready; while the count is 0 + * they read no thread-local, which on some targets is a call (emutls). A + * thread with a claim or a capture of its own sees its own count. A claim + * counts itself before it reads its child's inflight count, so the + * scheduler's batch flush, which counts each message in flight first, reads a + * target's death again only while the count is not 0 (multicore_scheduler.c). */ +static AETHER_TLS ActorBase* t_rebirth_target = NULL; +static AETHER_TLS ActorBase* t_reborn = NULL; + +int aether_actor_rebirth_begin(void* ref) { + ActorBase* a = (ActorBase*)ref; + if (!a || a->auto_process || !a->scheduler_owned) return -1; + int d = atomic_load_explicit(&a->dead, memory_order_acquire); + if (!(d & AETHER_ACTOR_ENDED) || (d & AETHER_ACTOR_RELEASED)) return -1; + if (t_rebirth_target || t_reborn) return -1; + if (!(atomic_load_explicit(&a->exit_flags, memory_order_acquire) & AETHER_EXIT_FINALIZED)) + return 0; // its death is still being finalized + if (atomic_flag_test_and_set_explicit(&a->step_lock, memory_order_acquire)) return 0; + // Counted before inflight is read, both seq_cst, against a sender that + // counts its message in inflight and then reads this count (the batch + // flush): either the claim sees the message in flight, or the sender + // sees the claim and reads the death again. + atomic_fetch_add_explicit(&aether_caps_slow, AETHER_CAPS_SPAWN_ONE, memory_order_seq_cst); + if (atomic_load_explicit(&a->inflight, memory_order_seq_cst) != 0) { + // Messages sent before its death still on their way: each is + // dropped where it arrives, and then the child can be reborn. + atomic_fetch_sub_explicit(&aether_caps_slow, AETHER_CAPS_SPAWN_ONE, memory_order_relaxed); + atomic_flag_clear_explicit(&a->step_lock, memory_order_release); + return 0; + } + t_rebirth_target = a; + return 1; +} + +/* The block made empty for the state initializers to fill: the old + * incarnation's state destroyed, its fields zeroed. */ +static void rebirth_clear_state(ActorBase* a) { + if (a->destroy_state) a->destroy_state(a); + if (a->alloc_size > sizeof(ActorBase)) + memset((char*)a + sizeof(ActorBase), 0, a->alloc_size - sizeof(ActorBase)); +} + +ActorBase* aether_rebirth_take(void (*step)(void*), size_t size) { + if (likely(!aether_spawn_watched())) return NULL; + ActorBase* a = t_rebirth_target; + if (likely(a == NULL) || a->step != step || a->alloc_size != size) return NULL; + t_rebirth_target = NULL; + t_reborn = a; + rebirth_clear_state(a); + Message discard; + while (mailbox_receive(&a->mailbox, &discard)) { // none: finalized, and nothing + aether_mailbox_untake(a, discard.type); // is delivered to a dead actor + aether_message_drop(a, &discard); + scheduler_count_dead_sends(1); + } + a->step_payload = NULL; + a->step_type = 0; + a->timeout_ns = 0; + a->last_activity_ns = 0; + atomic_store_explicit(&a->active, 0, memory_order_relaxed); + atomic_store_explicit(&a->migrate_to, -1, memory_order_relaxed); + atomic_store_explicit(&a->reply_slot, NULL, memory_order_relaxed); + watch_reset(a); // the old incarnation's lists and reason + atomic_store_explicit(&a->exit_flags, 0, memory_order_relaxed); + atomic_fetch_add_explicit(&a->incarnation, 1, memory_order_relaxed); + return a; +} + +void scheduler_spawn_ready(ActorBase* a) { + if (likely(!aether_spawn_watched())) return; + if (unlikely(t_capture_depth > t_capture_floor) && a) aether_actor_capture_note(a); + if (likely(t_reborn != a) || !a) return; // a fresh spawn: alive already + t_reborn = NULL; + atomic_fetch_sub_explicit(&aether_caps_slow, AETHER_CAPS_SPAWN_ONE, memory_order_relaxed); // the claim is done + // Its state is written: the release makes it visible to whoever reads + // the cleared bits (a sender, the core that steps it). + atomic_fetch_and_explicit(&a->dead, + ~(AETHER_ACTOR_PANICKED | AETHER_ACTOR_KILLED | AETHER_ACTOR_STOPPED), + memory_order_acq_rel); + atomic_flag_clear_explicit(&a->step_lock, memory_order_release); + scheduler_actor_revived(a); +} + +/* A rebirth the factory did not finish: the child stays ended, finalized, + * with this reason, and its step lock goes. */ +static void rebirth_undo(ActorBase* a, int half_made) { + if (half_made) { + rebirth_clear_state(a); + aether_actor_claim_reason(a, "the restart failed"); + atomic_fetch_or_explicit(&a->exit_flags, AETHER_EXIT_FINALIZED, memory_order_acq_rel); + aether_watch_notify(a, aether_actor_death_reason(a)); + } + atomic_flag_clear_explicit(&a->step_lock, memory_order_release); +} + +int aether_actor_rebirth_end(void* ref) { + ActorBase* a = (ActorBase*)ref; + if (!a) return 0; + if (t_rebirth_target == a) { // the factory spawned no actor of its type + t_rebirth_target = NULL; + rebirth_undo(a, 0); + atomic_fetch_sub_explicit(&aether_caps_slow, AETHER_CAPS_SPAWN_ONE, memory_order_relaxed); + return 0; + } + if (t_reborn == a) { // it panicked between the spawn and the ready + t_reborn = NULL; + rebirth_undo(a, 1); + atomic_fetch_sub_explicit(&aether_caps_slow, AETHER_CAPS_SPAWN_ONE, memory_order_relaxed); + return 0; + } + return !(atomic_load_explicit(&a->dead, memory_order_acquire) & AETHER_ACTOR_ENDED); +} + +void aether_rebirth_abandon(void) { + if (likely(!aether_spawn_watched())) return; + if (likely(!t_rebirth_target && !t_reborn)) return; + if (t_rebirth_target) rebirth_undo(t_rebirth_target, 0); + if (t_reborn) rebirth_undo(t_reborn, 1); + t_rebirth_target = NULL; + t_reborn = NULL; + atomic_fetch_sub_explicit(&aether_caps_slow, AETHER_CAPS_SPAWN_ONE, memory_order_relaxed); +} + +/* The spawn brackets the running step opened and did not close (a panic + * skipped their end): closed, down to the step's floor. */ +static void capture_close_to_floor(void) { + while (t_capture_depth > t_capture_floor) { + t_capture_depth--; + atomic_fetch_sub_explicit(&aether_caps_slow, AETHER_CAPS_SPAWN_ONE, memory_order_relaxed); + } +} + +/* A step begins inside another (aether_step_barrier.h, #3389). The claim, + * the reborn block and the brackets this thread holds are the enclosing + * step's: put aside, so the step's spawns take no claimed block and are no + * bracket's child, and its panic undoes none of them. 0 when the thread + * holds none (the caller has seen the spawn count set: some thread + * does). */ +int aether_spawn_put_aside(AetherSpawnAside* s) { + ActorBase* target = t_rebirth_target; + ActorBase* reborn = t_reborn; + int depth = t_capture_depth; + if (!target && !reborn && depth == t_capture_floor) return 0; + s->target = target; + s->reborn = reborn; + s->capture_floor = t_capture_floor; + t_rebirth_target = NULL; + t_reborn = NULL; + t_capture_floor = depth; + return 1; +} + +/* The step ends, either way. A claim it made and brackets it opened and did + * not end are undone (they are its own), then the enclosing step's are back. */ +void aether_spawn_give_back(const AetherSpawnAside* s) { + aether_rebirth_abandon(); + capture_close_to_floor(); + t_rebirth_target = s->target; + t_reborn = s->reborn; + t_capture_floor = s->capture_floor; +} + +/* The step panicked: what it made of its own is undone, the enclosing + * step's (put aside, when there was any) untouched. */ +void aether_spawn_step_panicked(void) { + if (likely(!aether_spawn_watched())) return; + aether_rebirth_abandon(); + capture_close_to_floor(); +} + +int aether_actor_incarnation(void* ref) { + return ref ? atomic_load_explicit(&((ActorBase*)ref)->incarnation, memory_order_acquire) : 0; +} + +// --------------------------------------------------------------------------- +// The child a factory spawned (#2974) +// +// A supervisor's child spec is a closure that spawns the child (and may +// configure it, or spawn helpers first). The supervisor brackets the call: +// the first actor that comes alive on this thread inside the bracket +// (scheduler_spawn_ready, a fresh spawn or a rebirth) is the child. +// Brackets nest, a factory that starts a supervisor of its own included. +// --------------------------------------------------------------------------- + +#define AETHER_CAPTURE_DEPTH 16 +// t_capture_depth is defined with the rebirth claim, above. +static AETHER_TLS ActorBase* t_captured[AETHER_CAPTURE_DEPTH]; + +void aether_actor_capture_begin(void) { + if (t_capture_depth < AETHER_CAPTURE_DEPTH) t_captured[t_capture_depth] = NULL; + t_capture_depth++; + atomic_fetch_add_explicit(&aether_caps_slow, AETHER_CAPS_SPAWN_ONE, memory_order_relaxed); +} + +void* aether_actor_capture_end(void) { + if (t_capture_depth <= t_capture_floor) return NULL; // an enclosing step's + t_capture_depth--; + atomic_fetch_sub_explicit(&aether_caps_slow, AETHER_CAPS_SPAWN_ONE, memory_order_relaxed); + return t_capture_depth < AETHER_CAPTURE_DEPTH ? t_captured[t_capture_depth] : NULL; +} + +void aether_actor_capture_note(ActorBase* a) { + int top = t_capture_depth - 1; + if (top >= t_capture_floor && top < AETHER_CAPTURE_DEPTH && !t_captured[top]) t_captured[top] = a; +} diff --git a/runtime/actors/aether_unwind.c b/runtime/actors/aether_unwind.c index 2ea0fbb99..48beb9603 100644 --- a/runtime/actors/aether_unwind.c +++ b/runtime/actors/aether_unwind.c @@ -3,100 +3,240 @@ // that keeps this out of aether_panic.c. #include "aether_unwind.h" +#include "../utils/aether_fatal.h" /* aether_fatal_flush (#3435) */ #include "../utils/aether_compiler.h" +#include "aether_frames.h" +#include +#include #include +#include /* An entry is a value (`free_fn(ptr)` on a drain) or a place (#2923): `ptr` * is the address of a slot holding a value, `owned` the address of the flag * that says the slot owns it (NULL: it owns whatever it holds), and a drain - * releases what the slot holds then, leaving it empty. */ -typedef struct { + * releases what the slot holds then, leaving it empty. A place of an owned + * pointer type (#3406) has `drop_ptr`, a thunk of the type's drop function + * that takes the pointer as `void*` (#3413), in place of `free_fn`. */ +typedef struct AetherUnwindEntry { const void* ptr; const void* owned; AetherUnwindFree free_fn; + AetherUnwindDropPtr drop_ptr; int depth; - int place; + unsigned char place; /* a place, not a value */ + unsigned char draining; /* to be released by the drain running now */ } AetherUnwindEntry; -typedef struct { - AetherUnwindEntry* items; - int count; - int capacity; -} AetherUnwindJournal; -/* AETHER_TLS_SHARED, not AETHER_TLS: this object is pulled into both +/* The per-thread frames block (aether_frames.h): the try and execution + * frames, the executions' records, the journal. Owned by this file and read + * only here: every other file, the runtime's included, reaches it through + * aether_frames_here() and never names the thread-local (aether_frames.h + * says why). The paths every actor step and every `try` take, the frames' + * push and pop and the journal, are here so they read it directly. + * AETHER_TLS_SHARED, not AETHER_TLS: this object is pulled into both * executables and --emit=lib shared objects (the emitted prologue * references the track/forget symbols from every program). Initial-Exec - * TLS relocations do not work inside shared objects; Global-Dynamic - * works in both contexts. Same rationale as aether_panic.c's stack. */ -static AETHER_TLS_SHARED AetherUnwindJournal tls_journal = { NULL, 0, 0 }; -static AETHER_TLS_SHARED int tls_depth = 0; + * TLS relocations do not work inside shared objects; Global-Dynamic works + * in both contexts. Here rather than in aether_panic.c, so that journaling + * pulls in no more than this file. */ +static AETHER_TLS_SHARED AetherFrames t_frames; -void aether_unwind_enter_frame(void) { - tls_depth++; +AetherFrames* aether_frames_here(void) { + return &t_frames; +} + +AetherJmpFrame* aether_try_push(void) { + AetherFrames* F = &t_frames; + if (F->depth >= AETHER_PANIC_MAX_DEPTH) { + aether_fatal_flush(); /* the program's lines first (#3435, aether_fatal.h) */ + fprintf(stderr, "aether: try/catch nesting exceeded %d, aborting\n", + AETHER_PANIC_MAX_DEPTH); + abort(); + } + AetherJmpFrame* f = &F->frames[F->depth++]; + f->reason = NULL; + f->reason_release = NULL; + f->exec_buf = NULL; + return f; +} + +/* aether_frames.h. NULL until a library call ran on some thread of this + * runtime copy: until then no step can find an execution to put aside. */ +void (*_Atomic aether_step_execs_hook)(AetherExecAside* s, int hide) = NULL; + +/* The executions an actor step runs inside are not its own (#3392): it is + * code a library call reached, a send's inline step or a cooperative ask's + * poll, not the call. Above the floor, the step's own: a loop head in its + * handler sees no call's deadline or cancel, a try catches its ordinary + * panics (no interruption is on its way), a failed allocation notes no stop + * for the call, and its caller info is none. */ +static void step_put_aside_execs(AetherFrames* F, AetherExecAside* s) { + s->exec_floor = F->exec_floor; + s->interrupting = F->interrupting; + F->exec_floor = F->exec_depth; + F->interrupting = 0; + void (*hook)(AetherExecAside*, int) = atomic_load_explicit(&aether_step_execs_hook, + memory_order_acquire); + s->execs_part = hook != NULL; + if (hook) hook(s, 1); +} + +AetherJmpFrame* aether_step_push(AetherExecAside* s, int* hid) { + AetherFrames* F = &t_frames; + /* An execution above the floor, or an interruption on its way (its + * unwind runs defers, which can send): the step puts them aside. */ + if (unlikely(F->exec_depth != F->exec_floor || F->interrupting)) { + step_put_aside_execs(F, s); + *hid |= AETHER_STEP_HID_EXECS; + } + return aether_try_push(); +} + +void aether_step_give_back_execs(const AetherExecAside* s) { + AetherFrames* F = &t_frames; + if (s->execs_part) { + void (*hook)(AetherExecAside*, int) = atomic_load_explicit(&aether_step_execs_hook, + memory_order_acquire); + hook((AetherExecAside*)s, 0); + } + F->exec_floor = s->exec_floor; + F->interrupting = s->interrupting; +} + +void aether_try_pop(void) { + AetherFrames* F = &t_frames; + if (F->depth <= 0) { + // Defensive: popping an empty stack means codegen/runtime mismatch. + aether_fatal_flush(); /* the program's lines first (#3435, aether_fatal.h) */ + fprintf(stderr, "aether: aether_try_pop on empty stack\n"); + abort(); + } + /* The journal's entries of this frame move to its parent (or go, at + * depth zero): only when it has any. */ + if (F->journal_count) aether_unwind_exit_frame(); + F->depth--; +} + +AetherJmpFrame* aether_current_frame(void) { + AetherFrames* F = &t_frames; + if (F->depth == 0) return NULL; + return &F->frames[F->depth - 1]; +} + +int aether_try_depth(void) { + return t_frames.depth; +} + +/* A `try` caught an interruption of a library call (#2956, aether_exec.c): + * it passes it on after its own cleanup (aether_exec_reraise). Here, where + * every program links it, and not in aether_exec.c. */ +int aether_exec_interrupting(void) { + return t_frames.interrupting != 0; +} + +/* aether_exec.h (#3404). The execution it targets is the innermost one + * this code runs in (an interruption is raised only there); none left + * running here (it already ended) takes nothing. */ +void aether_exec_defer_interruption(void) { + AetherFrames* F = &t_frames; + int why = F->interrupting; + F->interrupting = 0; + if (!why || F->exec_depth <= F->exec_floor) return; + AetherExecRecord* x = &F->exec[F->exec_depth - 1]; + if (!x->stop) x->stop = why; } static void journal_at(const void* p, AetherUnwindFree free_fn, int depth); void aether_unwind_track(const void* p, AetherUnwindFree free_fn) { - if (tls_depth == 0 || !p || !free_fn) return; - journal_at(p, free_fn, tls_depth); + if (t_frames.depth == 0 || !p || !free_fn) return; + journal_at(p, free_fn, t_frames.depth); } void aether_unwind_track_place(const void* place, const void* owned, AetherUnwindFree release) { - if (tls_depth == 0 || !place || !release) return; - journal_at(place, release, tls_depth); - for (int i = tls_journal.count - 1; i >= 0; i--) { - if (tls_journal.items[i].ptr == place) { - tls_journal.items[i].owned = owned; - tls_journal.items[i].place = 1; + if (t_frames.depth == 0 || !place || !release) return; + journal_at(place, release, t_frames.depth); + for (int i = t_frames.journal_count - 1; i >= 0; i--) { + if (t_frames.journal[i].ptr == place) { + t_frames.journal[i].owned = owned; + t_frames.journal[i].place = 1; return; } } } +/* An owned place is journaled once, where its function or arm begins, at + * a slot of the stack no live entry can name (every exit forgets its + * place, and a drain takes what it runs), so it is appended without the + * search a value's re-tracking needs: this runs on every call of a + * function with an owned local inside a frame (#3406). */ +int aether_unwind_track_owned_place(const void* place, AetherUnwindDropPtr drop) { + AetherFrames* F = &t_frames; + if (F->depth == 0 || !place || !drop) return 0; + if (F->journal_count == F->journal_capacity) { + int cap = F->journal_capacity ? F->journal_capacity * 2 : 32; + AetherUnwindEntry* grown = + (AetherUnwindEntry*)realloc(F->journal, (size_t)cap * sizeof(AetherUnwindEntry)); + if (!grown) return 0; /* no room to journal it: the old leak at worst */ + F->journal = grown; + F->journal_capacity = cap; + } + AetherUnwindEntry* e = &F->journal[F->journal_count++]; + e->ptr = place; + e->owned = NULL; + e->free_fn = NULL; + e->drop_ptr = drop; + e->depth = F->depth; + e->place = 1; + e->draining = 0; + return 1; +} + static void journal_at(const void* p, AetherUnwindFree free_fn, int depth) { // Re-tracking an already-journaled pointer updates in place: the // ownership handoff shapes (callee returns, caller re-arms its own // defer) must never accumulate two entries for one allocation. - for (int i = tls_journal.count - 1; i >= 0; i--) { - if (tls_journal.items[i].ptr == p) { - tls_journal.items[i].free_fn = free_fn; - tls_journal.items[i].depth = depth; + for (int i = t_frames.journal_count - 1; i >= 0; i--) { + if (t_frames.journal[i].ptr == p) { + t_frames.journal[i].free_fn = free_fn; + t_frames.journal[i].depth = depth; return; } } - if (tls_journal.count == tls_journal.capacity) { - int cap = tls_journal.capacity ? tls_journal.capacity * 2 : 32; + if (t_frames.journal_count == t_frames.journal_capacity) { + int cap = t_frames.journal_capacity ? t_frames.journal_capacity * 2 : 32; AetherUnwindEntry* grown = - (AetherUnwindEntry*)realloc(tls_journal.items, + (AetherUnwindEntry*)realloc(t_frames.journal, (size_t)cap * sizeof(AetherUnwindEntry)); if (!grown) return; // OOM: skip journaling; worst case is the old leak - tls_journal.items = grown; - tls_journal.capacity = cap; + t_frames.journal = grown; + t_frames.journal_capacity = cap; } - tls_journal.items[tls_journal.count].ptr = p; - tls_journal.items[tls_journal.count].owned = NULL; - tls_journal.items[tls_journal.count].free_fn = free_fn; - tls_journal.items[tls_journal.count].depth = depth; - tls_journal.items[tls_journal.count].place = 0; - tls_journal.count++; + t_frames.journal[t_frames.journal_count].ptr = p; + t_frames.journal[t_frames.journal_count].owned = NULL; + t_frames.journal[t_frames.journal_count].free_fn = free_fn; + t_frames.journal[t_frames.journal_count].drop_ptr = NULL; + t_frames.journal[t_frames.journal_count].depth = depth; + t_frames.journal[t_frames.journal_count].place = 0; + t_frames.journal[t_frames.journal_count].draining = 0; + t_frames.journal_count++; } void aether_unwind_forget(const void* p) { - if (tls_journal.count == 0 || !p) return; + if (t_frames.journal_count == 0 || !p) return; // Scan newest-first: frees are overwhelmingly LIFO. - for (int i = tls_journal.count - 1; i >= 0; i--) { - if (tls_journal.items[i].ptr == p) { - tls_journal.items[i] = tls_journal.items[tls_journal.count - 1]; - tls_journal.count--; + for (int i = t_frames.journal_count - 1; i >= 0; i--) { + if (t_frames.journal[i].ptr == p) { + t_frames.journal[i] = t_frames.journal[t_frames.journal_count - 1]; + t_frames.journal_count--; return; } } } int aether_unwind_journal_size(void) { - return tls_journal.count; + return t_frames.journal_count; } // Free every entry tagged at the innermost depth and remove it. Runs @@ -105,40 +245,45 @@ int aether_unwind_journal_size(void) { // TLS, so intactness only matters for the free functions, which take // the raw pointer and touch nothing else). // -// The entries are taken off the journal before any release runs: a release -// may forget or track other entries, which must not move under the loop. +// It allocates nothing (#2956): a panic may be the interruption of a call +// whose allocation just failed. The entries to release are marked first; +// then, newest first, each is taken out of the journal before its release +// runs, since a release may forget or track other entries. An entry a +// release forgets is not released again (it used to be, from a copy of the +// journal the drain had taken); one a release tracks is not marked, and +// stays in the journal for the frame that receives it. void aether_unwind_drain_current(void) { int n = 0; - for (int i = 0; i < tls_journal.count; i++) - if (tls_journal.items[i].depth >= tls_depth) n++; - if (n == 0) return; - AetherUnwindEntry* drained = (AetherUnwindEntry*)malloc((size_t)n * sizeof(AetherUnwindEntry)); - int w = 0, d = 0; - for (int i = 0; i < tls_journal.count; i++) { - if (tls_journal.items[i].depth < tls_depth) { - tls_journal.items[w++] = tls_journal.items[i]; - } else if (drained) { - drained[d++] = tls_journal.items[i]; + for (int i = 0; i < t_frames.journal_count; i++) { + if (t_frames.journal[i].depth >= t_frames.depth) { + t_frames.journal[i].draining = 1; + n++; } } - tls_journal.count = w; /* Newest first, as scope exits run: a deferred statement still sees the * locals declared before it. */ - for (int i = d - 1; i >= 0; i--) { - AetherUnwindEntry* e = &drained[i]; - if (!e->place) { - e->free_fn(e->ptr); + while (n > 0) { + int i = t_frames.journal_count - 1; + while (i >= 0 && !t_frames.journal[i].draining) i--; + if (i < 0) break; /* the rest were forgotten by a release */ + AetherUnwindEntry e = t_frames.journal[i]; + memmove(&t_frames.journal[i], &t_frames.journal[i + 1], + (size_t)(t_frames.journal_count - i - 1) * sizeof(AetherUnwindEntry)); + t_frames.journal_count--; + n--; + if (!e.place) { + e.free_fn(e.ptr); continue; } - const void* volatile* slot = (const void* volatile*)e->ptr; - volatile int* owned = (volatile int*)e->owned; + const void* volatile* slot = (const void* volatile*)e.ptr; + volatile int* owned = (volatile int*)e.owned; const void* v = *slot; if (!v || (owned && !*owned)) continue; if (owned) *owned = 0; *slot = NULL; - e->free_fn(v); + if (e.drop_ptr) e.drop_ptr((void*)v); + else e.free_fn(v); } - free(drained); } // Normal frame exit: surviving entries of the popped depth are owned by @@ -146,17 +291,15 @@ void aether_unwind_drain_current(void) { // protection. With no parent frame nothing can unwind past them; their // deferred frees run normally, so the entries just leave the journal. void aether_unwind_exit_frame(void) { - if (tls_depth <= 1) { - tls_journal.count = 0; - tls_depth = 0; + if (t_frames.depth <= 1) { + t_frames.journal_count = 0; return; } - for (int i = 0; i < tls_journal.count; i++) { - if (tls_journal.items[i].depth >= tls_depth) { - tls_journal.items[i].depth = tls_depth - 1; + for (int i = 0; i < t_frames.journal_count; i++) { + if (t_frames.journal[i].depth >= t_frames.depth) { + t_frames.journal[i].depth = t_frames.depth - 1; } } - tls_depth--; } typedef void (*AetherTeardown)(void* obj); @@ -171,7 +314,13 @@ void aether_teardown(void* obj) { if (tls_teardown_count == tls_teardown_capacity) { int cap = tls_teardown_capacity ? tls_teardown_capacity * 2 : 16; void** q = (void**)realloc(tls_teardown_queue, (size_t)cap * sizeof(void*)); - if (!q) abort(); + if (!q) { + /* No memory to queue it (#2956): torn down here instead, on + * the stack. The process used to stop, and a library call + * could not end with its outcome. */ + (*(AetherTeardown*)obj)(obj); + return; + } tls_teardown_queue = q; tls_teardown_capacity = cap; } @@ -188,10 +337,10 @@ void aether_teardown(void* obj) { } void aether_unwind_thread_cleanup(void) { - free(tls_journal.items); - tls_journal.items = NULL; - tls_journal.count = 0; - tls_journal.capacity = 0; + free(t_frames.journal); + t_frames.journal = NULL; + t_frames.journal_count = 0; + t_frames.journal_capacity = 0; free(tls_teardown_queue); tls_teardown_queue = NULL; tls_teardown_capacity = 0; diff --git a/runtime/actors/aether_unwind.h b/runtime/actors/aether_unwind.h index 0a0b26ae9..ee62444a4 100644 --- a/runtime/actors/aether_unwind.h +++ b/runtime/actors/aether_unwind.h @@ -25,10 +25,12 @@ // LINK-SURFACE WARNING: this journal lives in its OWN translation unit // on purpose. The emitted C prologue references the track/forget // symbols from every program, so whatever object provides them is -// pulled from libaether.a unconditionally. aether_panic.o drags the -// Windows DbgHelp stack-trace imports (-ldbghelp) with it; keeping the -// journal here means programs without try/catch never inherit that -// link requirement. Do not move these definitions into aether_panic.c. +// pulled from libaether.a unconditionally. Keeping the journal here (and +// the per-thread frames block it shares with aether_panic.c, aether_frames.h) +// means programs without try/catch never pull aether_panic.o in. (It used +// to drag the Windows DbgHelp imports, -ldbghelp, with it; it loads +// dbghelp at run time now.) Do not move these definitions into +// aether_panic.c. // // All calls are branch-cheap no-ops while no frame is live; the // scheduler's per-step barrier cost discipline (see aether_panic.h's @@ -47,14 +49,24 @@ void aether_unwind_forget(const void* p); // the slot's own stores and releases never touch the journal. Forgotten by // the place's address (aether_unwind_forget) where the slot goes out of scope. void aether_unwind_track_place(const void* place, const void* owned, AetherUnwindFree release); +// #3406: journal a place of an owned pointer type (`type T = owned ptr drop +// f`): a drain gives what it holds to `drop` and leaves it empty. `drop` is +// the compiler's thunk of the type's drop function (#3413: a drop may return +// a value, or take the pointer typed, as fclose takes a FILE*), which calls +// it as the scope's exit does. Forgotten as any place is. Outside every +// frame (nothing can unwind) it writes nothing. 1 when it journaled the +// place: only then is there anything for the scope exit to forget. +typedef void (*AetherUnwindDropPtr)(void* p); +int aether_unwind_track_owned_place(const void* place, AetherUnwindDropPtr drop); -// Frame lifecycle, called by aether_try_push / aether_try_pop / -// aether_panic. enter increments the journal's own frame depth; exit -// re-tags the popped depth's surviving entries to the parent frame -// (their owning locals are still live) or empties the journal at -// depth zero; drain_current frees the innermost frame's still-live -// entries (the panic path, just before the longjmp). -void aether_unwind_enter_frame(void); +// Frame lifecycle, called by aether_try_pop / aether_panic and the +// execution frames (aether_exec.c). The journal's depth is the frame depth +// itself (runtime/actors/aether_frames.h), so pushing a frame costs it +// nothing. exit, called with the frame about to go and only while the +// journal holds entries, re-tags the popped depth's surviving entries to +// the parent frame (their owning locals are still live) or empties the +// journal at depth zero; drain_current frees the innermost frame's +// still-live entries (the panic path, just before the longjmp). void aether_unwind_exit_frame(void); void aether_unwind_drain_current(void); @@ -70,7 +82,8 @@ void aether_unwind_thread_cleanup(void); // teardown function, `void (*)(void*)`. A teardown called while another runs // on this thread is queued and runs once that one returns, so a chain of // any length (a closure holding the closure it replaced, 50,000 deep) is -// torn down in constant stack. +// torn down in constant stack. With no memory to grow the queue, the +// teardown runs at once instead, on the stack. void aether_teardown(void* obj); #ifdef __cplusplus diff --git a/runtime/aether_abi.h b/runtime/aether_abi.h new file mode 100644 index 000000000..183e143d5 --- /dev/null +++ b/runtime/aether_abi.h @@ -0,0 +1,51 @@ +/* aether_abi.h: which runtime a library's generated C was made for + * (#2957; docs/abi-policy.md has the policy). + * + * The runtime ABI is what generated C and the runtime it links share: the + * layouts of the structs both sides read (ActorBase, Message, a try frame, + * a counted string) and the entry points the code generator calls. It is + * not stable across releases: a library's generated C runs only on the + * runtime it was made for. A self-contained library carries that runtime; + * a --shared-runtime library, and --emit=csrc or object code compiled into + * a host, must find the same one, and are refused otherwise (at compile + * time, at import, at load), never run on another. + * + * AETHER_RUNTIME_ABI_REV counts the changes to that ABI between releases, + * since a source tree carries the last release's version until the next is + * cut: bump it in the commit that changes a layout or an entry point the + * generated C uses. tests/runtime/test_abi_layout.c records the layouts at + * this revision, and fails when one changes without the bump. Dependency + * free (#defines and one declaration), so generated C includes it cheaply. */ +#ifndef AETHER_ABI_H +#define AETHER_ABI_H + +#include "aether_version.h" + +#define AETHER_RUNTIME_ABI_REV 1 + +/* Build flags that change a layout generated C reads. */ +#ifdef AETHER_DEBUG_MAILBOX +# define AETHER_ABI_FLAGS "+debug-mailbox" +#else +# define AETHER_ABI_FLAGS "" +#endif + +#define AETHER_ABI_STR2(x) #x +#define AETHER_ABI_STR(x) AETHER_ABI_STR2(x) + +/* "0.857.0/abi1": the version, the revision and the flags. */ +#define AETHER_RUNTIME_ABI_ID \ + AETHER_VERSION "/abi" AETHER_ABI_STR(AETHER_RUNTIME_ABI_REV) AETHER_ABI_FLAGS + +/* The runtime's own, compiled into it: what a library compiled against + * these headers compares its AETHER_RUNTIME_ABI_ID with before its first + * call runs. */ +#ifdef __cplusplus +extern "C" { +#endif +const char* aether_runtime_abi(void); +#ifdef __cplusplus +} +#endif + +#endif /* AETHER_ABI_H */ diff --git a/runtime/aether_callbacks.c b/runtime/aether_callbacks.c index 96db55d8e..de7ad76da 100644 --- a/runtime/aether_callbacks.c +++ b/runtime/aether_callbacks.c @@ -20,6 +20,7 @@ * use) rather than allocate in a constructor. */ #include "aether_callbacks.h" +#include "utils/aether_compiler.h" #include #include @@ -59,3 +60,18 @@ void* aether_callback_lookup(const char* name) { } return NULL; } + +/* Why the thread's last refusing callback refused (#3426): see the header. */ +static AETHER_TLS_SHARED char t_callback_reason[512]; + +void aether_callback_set_reason(const char* reason) { + if (!reason) { t_callback_reason[0] = '\0'; return; } + size_t n = strlen(reason); + if (n >= sizeof(t_callback_reason)) n = sizeof(t_callback_reason) - 1; + memcpy(t_callback_reason, reason, n); + t_callback_reason[n] = '\0'; +} + +const char* aether_callback_reason(void) { + return t_callback_reason; +} diff --git a/runtime/aether_callbacks.h b/runtime/aether_callbacks.h index a6a184b7d..02257db00 100644 --- a/runtime/aether_callbacks.h +++ b/runtime/aether_callbacks.h @@ -10,4 +10,15 @@ void aether_callback_register(const char* name, void* fn); void* aether_callback_lookup(const char* name); +/* Why a callback gave its failure value (#3426), for the C that called it, + * on the same thread: the callback sets it as it refuses, and the caller, + * which clears it (NULL) before the call, reads it after. The text is + * copied, up to 511 bytes, and holds until the thread's next set. It lives + * here, beside the registry the caller already reaches, so that a module + * explaining a refusal links nothing of its caller's: std.cryptography's + * pure TLS server, which explains why a chain and key are refused, used to + * call into the HTTP server for it and so linked the whole of it. */ +void aether_callback_set_reason(const char* reason); +const char* aether_callback_reason(void); + #endif /* AETHER_CALLBACKS_H */ diff --git a/runtime/aether_exec.c b/runtime/aether_exec.c new file mode 100644 index 000000000..f4b685001 --- /dev/null +++ b/runtime/aether_exec.c @@ -0,0 +1,421 @@ +/* aether_exec.c: execution frames and outcomes (#2955, #2956). See + * aether_exec.h. + * + * What an execution may change on this thread, and how it gets back: + * - the deadline: a host service a running execution calls sets one for + * the executions it enters (aether_set_call_deadline, while one runs, + * lands here), and the running execution keeps its own; a nested + * execution's deadline is the outer one's again when it returns. At the + * outermost level the host owns the deadline and reads it after the + * call, as before; + * - the caller info: kept per execution by aether_host.c, which this + * tells when an execution returns; + * - the sandbox levels: back to their depth at entry (a panic has already + * left the levels pushed inside the frame, aether_panic.c). + * Nothing done before a failure is rolled back: host effects, module state, + * messages sent. + * + * The cost of a call that does not fail is the floor a catchable panic + * needs: the stub's context save (__builtin_setjmp, aether_panic.h) and a + * frame where a panic finds it, one thread-local block (aether_frames.h) + * entered and left. The rest is read only once something asks for it: the + * deadline when executions nest or one was ever armed, the sandbox depth + * when some thread has a level, the caller info when a host service + * changed it during the call. */ + +#include "libaether.h" +#include "utils/aether_fatal.h" /* aether_fatal_flush (#3435) */ +#include "aether_exec.h" +#include "aether_abi.h" +#include "aether_resource_caps.h" +#include "aether_host.h" +#include "aether_sandbox.h" +#include "actors/aether_frames.h" +#include "actors/aether_unwind.h" +#include "utils/aether_compiler.h" + +#include +#include +#include + +/* The host API (libaether.h, aether_host.h) of a runtime copy. A C host sets + * a call's deadline, memory cap and caller, and reads its outcome, in the + * copy a library runs on, so a copy carries all of it even when its code + * calls none of it: a static archive links only what is called, and a DLL + * had no aether_set_call_deadline. Every library with a runtime of its own + * links this object (its stubs enter executions), and so does a program + * that loads libraries (std.dl keeps aether_runtime_abi). The table was in + * each library's generated C (#3439), where it also made a library built to + * run on its host's runtime, a gateway script, reference objects the host + * never linked, and a wasm library reference what the cooperative build + * does not compile. */ +#if defined(__GNUC__) || defined(__clang__) +__attribute__((used)) +#endif +static void (*const exec_host_api[])(void) = { + (void (*)(void))aether_set_memory_cap, (void (*)(void))aether_set_call_deadline, + (void (*)(void))aether_deadline_tripped, (void (*)(void))aether_set_caller, + (void (*)(void))aether_clear_caller, (void (*)(void))aether_exec_token, + (void (*)(void))aether_exec_cancel, (void (*)(void))aether_exec_account_new, + (void (*)(void))aether_exec_account_free, (void (*)(void))aether_exec_use, + (void (*)(void))aether_exec_account_used, (void (*)(void))aether_exec_account_tripped, +}; + +/* The two the public header (libaether.h) declares: exported however the + * runtime is built. */ +#if defined(__GNUC__) || defined(__clang__) +# define AETHER_EXEC_API __attribute__((visibility("default"))) +#else +# define AETHER_EXEC_API +#endif + +#ifdef AETHER_HAS_SANDBOX +/* Some thread has a sandbox level in force (aether_sandbox.h). Unresolved, + * this copy has pushed nothing, so this thread's levels here are none. */ +static inline int sandbox_any(void) { + atomic_int* in_force = atomic_load_explicit(&aether_sandbox_in_force, memory_order_acquire); + return in_force && atomic_load_explicit(in_force, memory_order_relaxed) != 0; +} +#endif + +int aether_exec_depth(void) { + return aether_frames_here()->exec_depth; +} + +int aether_exec_floor(void) { + return aether_frames_here()->exec_floor; +} + +/* An actor step's part of the executions it runs inside, in the files only + * a library call links (aether_frames.h, aether_unwind.c; #3392, #3396): + * the call's deadline, which the step's loop heads must not see, and the + * caller info the host set for the call, which the step must not read. */ +static void step_execs(AetherExecAside* s, int hide) { + if (hide) { + AetherDeadlineState d; + aether_caps_deadline_save(&d); + s->deadline_at_ns = d.at_ns; + s->deadline_tripped = d.tripped; + AetherDeadlineState none = { 0, 0 }; + aether_caps_deadline_restore(&none); + s->caller = aether_caller_put_aside(); + } else { + aether_caller_give_back(s->caller); + AetherDeadlineState d = { s->deadline_at_ns, s->deadline_tripped }; + aether_caps_deadline_restore(&d); + } +} + +/* Installed before any execution of this copy can run: at load where the + * compiler has constructors, else at the first entry. */ +#if AETHER_GCC_COMPAT +AETHER_CONSTRUCTOR static void exec_install_step_part(void) { + atomic_store_explicit(&aether_step_execs_hook, step_execs, memory_order_release); +} +#define EXEC_STEP_PART_AT_ENTRY() ((void)0) +#else +#define EXEC_STEP_PART_AT_ENTRY() \ + do { \ + if (!atomic_load_explicit(&aether_step_execs_hook, memory_order_relaxed)) \ + atomic_store_explicit(&aether_step_execs_hook, step_execs, memory_order_release); \ + } while (0) +#endif + +void aether_exec_mark_view(int depth) { + AetherFrames* F = aether_frames_here(); + if (depth >= 1 && depth <= F->exec_depth) F->exec[depth - 1].has_view = 1; +} + +void aether_exec_set_next_deadline(int64_t ms) { + AetherFrames* F = aether_frames_here(); + if (F->exec_depth <= F->exec_floor) { /* none of this code's */ + aether_caps_set_deadline_ms(ms); + return; + } + AetherExecRecord* running = &F->exec[F->exec_depth - 1]; + AetherDeadlineState s = aether_caps_deadline_for_ms(ms); + running->next_at_ns = s.at_ns; + running->next_tripped = 0; + running->next_set = 1; + if (ms > 0) atomic_store_explicit(&aether_caps_armed, 1, memory_order_relaxed); +} + +static inline AetherJmpFrame* exec_push(AetherFrames* F, void** buf) { + if (F->depth >= AETHER_PANIC_MAX_DEPTH) { + aether_fatal_flush(); /* the program's lines first (#3435, aether_fatal.h) */ + fprintf(stderr, "aether: try/catch and executions nested deeper than %d on one thread, aborting\n", + AETHER_PANIC_MAX_DEPTH); + abort(); + } + EXEC_STEP_PART_AT_ENTRY(); + int d = F->exec_depth; + AetherExecRecord* x = &F->exec[d]; + if (d > F->exec_floor) { + /* Nested: the outer execution's deadline comes back when this one + * returns, and the one a host service set for it applies. */ + AetherDeadlineState s; + aether_caps_deadline_save(&s); + x->deadline_at_ns = s.at_ns; + x->deadline_tripped = s.tripped; + AetherExecRecord* outer = &F->exec[d - 1]; + if (outer->next_set) { + AetherDeadlineState n = { outer->next_at_ns, outer->next_tripped }; + aether_caps_deadline_restore(&n); + } + } +#ifdef AETHER_HAS_SANDBOX + x->sandbox_depth = sandbox_any() ? aether_sandbox_depth() : 0; +#else + x->sandbox_depth = 0; +#endif + x->next_set = 0; + x->has_view = 0; + x->stop = 0; + x->serial = ++F->serial; + /* An account in use (#2954): its deadline, and its ancestors', is the + * execution's too, the earlier of it and the one already in force; the + * one in force is put back when the execution returns, as a nested + * execution's is. The execution holds the account until it returns, so + * one the host frees meanwhile is no freed block to it (#3407). */ + /* This copy finds the accounts it shares once (a library with a runtime + * of its own works through the copy loaded first); then one load says + * whether any account exists in the process. */ + if (__builtin_expect(!atomic_load_explicit(&aether_accounts_found, memory_order_relaxed), 0)) + aether_accounts_register(); + x->account = (atomic_load_explicit(&aether_caps_slow, memory_order_relaxed) & AETHER_CAPS_SLOW_ACCOUNTS) + ? aether_account_hold() : NULL; + x->account_deadline = 0; + x->account_timed = 0; + if (x->account) { + int tripped = 0; + int64_t at = 0; + /* An account it could not time (no memory for the thread's list) + * ends it at once, as an allocation that fails does: its budget is + * never left unenforced. */ + if (aether_account_exec_begin(x->account, &at, &tripped, &x->account_timed) != 0) { + x->stop = AETHER_STOP_OUT_OF_MEMORY; + atomic_store_explicit(&aether_caps_armed, 1, memory_order_relaxed); + } + if (at || tripped) { + AetherDeadlineState cur; + aether_caps_deadline_save(&cur); + if (d == F->exec_floor) { /* the outermost here: not saved above */ + x->deadline_at_ns = cur.at_ns; + x->deadline_tripped = cur.tripped; + } + AetherDeadlineState s = { cur.at_ns && cur.at_ns < at ? cur.at_ns : at, + cur.tripped || tripped }; + if (!s.at_ns) s.at_ns = cur.at_ns; + aether_caps_deadline_restore(&s); + x->account_deadline = 1; + } + } + F->exec_depth = d + 1; + AetherJmpFrame* f = &F->frames[F->depth++]; + /* reason and reason_release are written by the panic that targets + * this frame, before it jumps (aether_panic.c); nothing else reads + * them. */ + f->exec_buf = buf; + return f; +} + +AetherJmpFrame* aether_exec_enter(void) { + return exec_push(aether_frames_here(), NULL); +} + +#if AETHER_EXEC_BUILTIN_SJLJ +void aether_exec_enter_at(void** buf) { + (void)exec_push(aether_frames_here(), buf); +} +#endif + +static void set_text(AetherFrames* F, const char* text) { + if (!text) text = ""; + size_t n = strlen(text); + if (n >= sizeof(F->outcome_text)) n = sizeof(F->outcome_text) - 1; + memcpy(F->outcome_text, text, n); + F->outcome_text[n] = '\0'; +} + +static inline void exec_finish(AetherFrames* F, int outcome) { + /* Its panic frame, as aether_try_pop takes one. */ + if (F->journal_count) aether_unwind_exit_frame(); + F->depth--; + int leaving = F->exec_depth; + F->exec_depth--; + AetherExecRecord* x = &F->exec[F->exec_depth]; + /* Under an account (#2954): the time it spent (an ACTIVE deadline, once + * however executions nest, #3409) and a trip of its deadline are the + * account's, and the account in use when it + * began is in use again, whatever the code it ran left, unless the host + * freed it meanwhile; the execution lets go of it (#3407). */ + if (x->account) { + aether_account_exec_end(x->account, x->account_timed, aether_caps_deadline_flag()); + aether_account_restore(x->account); + } else if (atomic_load_explicit(&aether_caps_slow, memory_order_relaxed) & AETHER_CAPS_SLOW_ACCOUNTS) { + aether_account_restore(NULL); + } + /* A nested execution: the outer one's deadline, trip flag and all; and + * the host's at the outermost, when an account's deadline replaced it. */ + if (F->exec_depth > F->exec_floor || x->account_deadline) { + AetherDeadlineState s = { x->deadline_at_ns, x->deadline_tripped }; + aether_caps_deadline_restore(&s); + } +#ifdef AETHER_HAS_SANDBOX + if (sandbox_any()) aether_sandbox_restore(x->sandbox_depth); +#endif + if (x->has_view) aether_caller_leave(leaving); + /* A cancel of this execution is spent with it. */ + if (F->cell) { + uint64_t s = x->serial; + atomic_compare_exchange_strong_explicit(&F->cell->pending, &s, 0, + memory_order_acq_rel, memory_order_relaxed); + } + F->outcome = outcome; +} + +int aether_exec_leave_outcome(void) { + AetherFrames* F = aether_frames_here(); + /* A stop noted for it: a C-backed std loop that stopped early (it + * returned an error, and no safe point came before the export + * returned), or an interruption a containment frame took off its way + * (#3404, which an allocation failure's needs no arming for). The trip + * flag only once some deadline or cancel was ever armed. */ + int why = F->exec[F->exec_depth - 1].stop; + if (atomic_load_explicit(&aether_caps_armed, memory_order_relaxed) && aether_caps_deadline_flag()) + why = AETHER_DEADLINE; + if (why) { + set_text(F, aether_exec_reason(why)); + exec_finish(F, why); + return why; + } + exec_finish(F, AETHER_OK); + return AETHER_OK; +} + +/* Kept for code generated before #3388, which returns its result whatever + * the outcome. */ +void aether_exec_leave(void) { + (void)aether_exec_leave_outcome(); +} + +void aether_exec_failed(void) { + AetherFrames* F = aether_frames_here(); + /* The panic reached this execution's frame: the innermost one. */ + AetherJmpFrame* f = &F->frames[F->depth - 1]; + int outcome = AETHER_PANICKED; + if (F->interrupting) { + /* An interruption, delivered as a panic so every frame on the way + * ran its cleanup: the outcome it carries. */ + outcome = F->interrupting; + F->interrupting = 0; + } + set_text(F, aether_panic_reason_text(f->reason)); + /* A reason built at run time belongs to whoever catches it: this frame. */ + if (f->reason_release) { + void (*release)(const void*) = f->reason_release; + f->reason_release = NULL; + release(f->reason); + } + exec_finish(F, outcome); +} + +// --------------------------------------------------------------------------- +// Interruptions (#2956, D5): a deadline that passed or a cancel from another +// thread ends the execution at a safe point instead of breaking a loop and +// returning what the loop had. It is raised as a panic, so each frame on the +// way releases what it holds (the allocation journal: owned locals and +// defers), and a user `try` it meets re-raises it after its own cleanup +// instead of running its handler (aether_exec_reraise): a script cannot +// swallow its own deadline. The execution's frame ends the call with the +// outcome, AETHER_DEADLINE or AETHER_CANCELLED, and the zero value. +// --------------------------------------------------------------------------- + +#if defined(__GNUC__) || defined(__clang__) +__attribute__((noreturn)) +#endif +static void interrupt(AetherFrames* F, int why) { + F->interrupting = why; + aether_panic(aether_exec_reason(why)); +} + +int aether_exec_poll(void) { + AetherFrames* F = aether_frames_here(); + if (F->exec_depth > F->exec_floor) { + /* A stop already noted (a failed allocation, a C-backed operation + * that stopped early), else the deadline or a cancel. */ + int why = F->exec[F->exec_depth - 1].stop; + if (!why) why = aether_exec_stop_reason(); + if (why) interrupt(F, why); + return 0; + } + /* Outside any execution a loop still breaks at a passed deadline, as + * it did before (a library's actor, say). */ + return aether_caps_deadline_tripped(); +} + +/* What a `try` calls when it catches one, aether_exec_interrupting() and + * aether_exec_reraise(), are aether_unwind.c's and aether_panic.c's: every + * program with a `try` calls them, and one that never runs a library call + * must not link this file (and the caller info and sandbox it reaches). */ + +// --------------------------------------------------------------------------- +// Cancellation tokens +// --------------------------------------------------------------------------- + +static AetherCancelCell* _Atomic g_cells = NULL; /* every cell made, kept */ + +AETHER_EXEC_API AetherExecToken aether_exec_token(void) { + AetherFrames* F = aether_frames_here(); + if (!F->cell) { + AetherCancelCell* c = (AetherCancelCell*)calloc(1, sizeof(AetherCancelCell)); + if (c) { + AetherCancelCell* head = atomic_load_explicit(&g_cells, memory_order_relaxed); + do { c->next = head; } while (!atomic_compare_exchange_weak_explicit( + &g_cells, &head, c, memory_order_release, memory_order_relaxed)); + F->cell = c; + } + } + AetherExecToken t; + t.cell = F->cell; + /* The running execution's, or the next one's when none runs. */ + t.serial = F->exec_depth > F->exec_floor ? F->exec[F->exec_depth - 1].serial : F->serial + 1; + return t; +} + +AETHER_EXEC_API int aether_exec_cancel(AetherExecToken token) { + AetherCancelCell* c = (AetherCancelCell*)token.cell; + if (!c || !token.serial) return -1; + atomic_store_explicit(&c->pending, token.serial, memory_order_release); + atomic_store_explicit(&aether_caps_armed, 1, memory_order_relaxed); + return 0; +} + +/* aether_abi.h (#2957): the runtime ABI this runtime was built at, which a + * library compiled against its headers compares with its own before its + * first call runs. */ +AETHER_EXEC_API const char* aether_runtime_abi(void) { + return AETHER_RUNTIME_ABI_ID; +} + +AETHER_EXEC_API int aether_last_outcome(void) { + return aether_frames_here()->outcome; +} + +AETHER_EXEC_API const char* aether_last_outcome_text(void) { + /* Written only for a failure: an OK call leaves the last text, unread. */ + AetherFrames* F = aether_frames_here(); + return F->outcome == AETHER_OK ? "" : F->outcome_text; +} + +/* The stub of an export nothing can end from outside its code (a frameless + * one, codegen.c lib_export_frameless): its call ended OK. */ +void aether_exec_ok(void) { + aether_frames_here()->outcome = AETHER_OK; +} + +void aether_exec_note_outcome(int outcome, const char* text) { + AetherFrames* F = aether_frames_here(); + F->outcome = outcome; + set_text(F, outcome == AETHER_OK ? "" : text); +} diff --git a/runtime/aether_exec.h b/runtime/aether_exec.h new file mode 100644 index 000000000..e5cac9523 --- /dev/null +++ b/runtime/aether_exec.h @@ -0,0 +1,131 @@ +/* aether_exec.h: an execution, one call from a host into a library export + * until it returns (#2955, #2956). + * + * Every aether_ export stub runs its function inside an execution + * frame: + * + * AetherJmpFrame* x = aether_exec_enter(); + * if (AETHER_SIGSETJMP(x->buf, 1)) { aether_exec_failed(); return zero; } + * r = f(args); + * if (aether_exec_leave_outcome() != 0) { release r; return zero; } + * return r; + * + * The frame is a panic frame, so a panic the export does not catch ends + * the execution instead of the process, and it keeps what a nested + * execution may change on this thread (the caller info a host service + * sets, the deadline, the sandbox levels), putting it back however the + * execution ends. Each execution leaves an outcome the caller reads with + * aether_last_outcome() (libaether.h): a zero value with an outcome other + * than AETHER_OK is not a result. A call that returned but ended other than + * AETHER_OK (a stop noted after its last safe point) returns its zero value + * too: the stub releases what it would have returned (#3388). */ + +#ifndef AETHER_EXEC_H +#define AETHER_EXEC_H + +#include +#include "actors/aether_panic.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#ifndef AETHER_OK +#define AETHER_OK 0 +#define AETHER_PANICKED 1 +#define AETHER_DEADLINE 2 +#define AETHER_CANCELLED 3 +#define AETHER_MEMORY_CAP 4 +#define AETHER_OUT_OF_MEMORY 5 +#define AETHER_INCOMPATIBLE 6 /* made for another runtime (#2957) */ +#endif + +/* The deepest executions nest on one thread: each is a panic frame. */ +#define AETHER_EXEC_MAX_DEPTH AETHER_PANIC_MAX_DEPTH + +AetherJmpFrame* aether_exec_enter(void); +/* The execution ends: its outcome (AETHER_OK, or the stop noted for it), + * which aether_last_outcome() then reads. aether_exec_leave is the same + * without the result, for stubs generated before #3388. */ +int aether_exec_leave_outcome(void); +void aether_exec_leave(void); +void aether_exec_failed(void); + +#if AETHER_EXEC_BUILTIN_SJLJ +/* The stub's form where the compiler has __builtin_setjmp (aether_panic.h): + * the context saved in the stub's own `void* buf[5]`, before entering. + * + * void* b[5]; + * if (__builtin_setjmp(b)) { aether_exec_failed(); return zero; } + * aether_exec_enter_at(b); + * r = f(args); + * aether_exec_leave(); + */ +void aether_exec_enter_at(void** buf); +#endif + +/* Executions running on this thread (0 outside any). */ +int aether_exec_depth(void); +/* The executions below this depth are the ones an actor step runs inside + * (#3392): not the running code's. 0 outside any step that put some aside. */ +int aether_exec_floor(void); + +/* aether_host.c keeps a copy of the caller info the execution at `depth` + * was entered with (aether_host.h): it is put back when that one returns. */ +void aether_exec_mark_view(int depth); + +/* A deadline a host service sets while an execution runs: for the + * executions it enters from there, until the running one returns. */ +void aether_exec_set_next_deadline(int64_t ms); + +/* The stub of an export that can neither panic, allocate, loop nor call + * anything has no frame: it records the OK outcome only. */ +void aether_exec_ok(void); + +/* A safe point (#2956): every loop head of --emit=lib + * code calls this once aether_caps_armed is set. Inside an execution whose + * deadline passed or that was cancelled, it ends the execution (it does + * not return); outside any execution it returns whether the deadline + * passed, and the loop breaks, as before. */ +int aether_exec_poll(void); + +/* For a C-backed std operation that runs long on its input: 1 when the + * running execution should stop, so the operation returns early, cleanly + * (an error, its own memory freed); the next safe point, or the export's + * return, ends the execution with the outcome. */ +int aether_exec_should_stop(void); + +/* A user `try` caught an interruption: it re-raises it after its own + * cleanup, instead of running its handler. In aether_unwind.c and + * aether_panic.c, which a program with a `try` links anyway, so it does not + * link this file's. */ +int aether_exec_interrupting(void); +void aether_exec_reraise(void); +/* A containment frame caught an interruption of the call it runs inside + * (#3404, aether_contain): it is not the frame's to end. It is taken off + * its way and noted as the call's stop, as a failed allocation's is: the + * call ends with it at its next safe point, or when it returns. */ +void aether_exec_defer_interruption(void); + +/* The reason text of an interruption's outcome (aether_panic.c). */ +const char* aether_exec_reason(int outcome); + +#ifndef AETHER_EXEC_TOKEN_DEFINED +#define AETHER_EXEC_TOKEN_DEFINED +typedef struct AetherExecToken { void* cell; uint64_t serial; } AetherExecToken; +#endif +AetherExecToken aether_exec_token(void); +int aether_exec_cancel(AetherExecToken token); + +int aether_last_outcome(void); +const char* aether_last_outcome_text(void); + +/* The outcome of a call into a library with a runtime of its own, as the + * importer's runtime records it, so std.host's last_outcome() sees it. */ +void aether_exec_note_outcome(int outcome, const char* text); + +#ifdef __cplusplus +} +#endif + +#endif /* AETHER_EXEC_H */ diff --git a/runtime/aether_host.c b/runtime/aether_host.c index 6dac3fa58..83d5a7674 100644 --- a/runtime/aether_host.c +++ b/runtime/aether_host.c @@ -9,6 +9,7 @@ #include "aether_host.h" #include "utils/aether_compiler.h" +#include "aether_exec.h" #include #include #include @@ -205,9 +206,104 @@ typedef struct { * Initial-Exec TLS model emits R_X86_64_TPOFF32 relocations that * the linker rejects when building a shared object; the * Global-Dynamic model in AETHER_TLS_SHARED works in both contexts - * with a sub-nanosecond extra GOT indirection per access. */ + * with a sub-nanosecond extra GOT indirection per access. + * + * tls_caller is the setting: what aether_set_caller wrote last, what the + * next execution entered sees, and what code outside any execution + * reads. An execution reads the setting it was entered with: tls_caller + * itself, until a host service it called sets another while it runs. Then + * the running executions that still read tls_caller get a copy of it + * first (t_view, one block shared by them, counted), and when an + * execution with a copy returns, the copy is the setting again (#2955). + * The copy is made only on that path: an export call costs nothing here. */ static AETHER_TLS_SHARED AetherCallerInfo tls_caller = { .identity_off = -1 }; +typedef struct { + int refs; + AetherCallerInfo info; +} CallerView; + +static AETHER_TLS_SHARED CallerView* t_view[AETHER_EXEC_MAX_DEPTH + 1]; + +/* Actor steps running inside executions that could not put the setting + * away for lack of memory (aether_caller_put_aside): while there are any, + * the thread reads no caller info and its settings are refused. */ +static AETHER_TLS_SHARED int t_caller_blind = 0; +static const AetherCallerInfo k_no_caller = { .identity_off = -1 }; + +static const AetherCallerInfo* caller_seen(void) { + if (t_caller_blind) return &k_no_caller; + int d = aether_exec_depth(); + /* An execution an actor step runs inside is not the step's (#3396): + * below the floor its view is not read. */ + if (d > aether_exec_floor() && d <= AETHER_EXEC_MAX_DEPTH && t_view[d]) return &t_view[d]->info; + return &tls_caller; +} + +/* Before the setting changes while executions run: each that reads it + * keeps what it saw. 0 when there was no room for the copy. The executions + * an actor step runs inside are not running meanwhile; the step's end puts + * their setting back (aether_caller_give_back). */ +static int caller_keep_views(void) { + int d = aether_exec_depth(); + if (d > AETHER_EXEC_MAX_DEPTH) d = AETHER_EXEC_MAX_DEPTH; + int first = aether_exec_floor() + 1; + int need = 0; + for (int k = first; k <= d; k++) if (!t_view[k]) need++; + if (need == 0) return 1; + CallerView* v = (CallerView*)malloc(sizeof(CallerView)); + if (!v) return 0; + v->refs = need; + v->info = tls_caller; + for (int k = first; k <= d; k++) { + if (t_view[k]) continue; + t_view[k] = v; + aether_exec_mark_view(k); + } + return 1; +} + +void aether_caller_leave(int depth) { + if (depth < 1 || depth > AETHER_EXEC_MAX_DEPTH) return; + CallerView* v = t_view[depth]; + if (!v) return; + t_view[depth] = NULL; + tls_caller = v->info; + if (--v->refs == 0) free(v); +} + +/* aether_host.h (#3396). */ +static char k_caller_blind_token; + +void* aether_caller_put_aside(void) { + if (t_caller_blind) { + t_caller_blind++; + return &k_caller_blind_token; + } + if (!tls_caller.present) return NULL; /* empty: the step starts as it is */ + AetherCallerInfo* keep = (AetherCallerInfo*)malloc(sizeof(AetherCallerInfo)); + if (!keep) { + t_caller_blind++; + return &k_caller_blind_token; + } + *keep = tls_caller; + tls_caller = k_no_caller; + return keep; +} + +void aether_caller_give_back(void* token) { + if (token == &k_caller_blind_token) { + t_caller_blind--; + return; + } + if (token) { + tls_caller = *(AetherCallerInfo*)token; + free(token); + } else { + tls_caller = k_no_caller; + } +} + /* Append a NUL-terminated copy of `s` to the arena. Returns the * starting offset on success, or -1 if there isn't room. */ static int caller_arena_dup(AetherCallerInfo* ci, const char* s) { @@ -225,6 +321,7 @@ int aether_set_caller(const char* identity, const char** attr_vals, size_t n, int64_t deadline_ms) { + if (t_caller_blind) return -1; /* the enclosing setting could not be kept (#3396) */ if (n > AETHER_CALLER_INFO_MAX_ATTRS) return -1; if (n > 0 && (!attr_keys || !attr_vals)) return -1; @@ -250,33 +347,41 @@ int aether_set_caller(const char* identity, staged.attr_count = (int)n; staged.present = 1; + /* The running executions keep what they were entered with. */ + if (!caller_keep_views()) return -1; /* Commit. Single struct copy, no torn write across fields. */ tls_caller = staged; return 0; } void aether_clear_caller(void) { + if (t_caller_blind) return; + /* Without room to keep the running executions' views, nothing changes. */ + if (!caller_keep_views()) return; AetherCallerInfo empty = { .identity_off = -1 }; tls_caller = empty; } const char* aether_caller_identity(void) { - if (!tls_caller.present || tls_caller.identity_off < 0) return ""; - return tls_caller.arena + tls_caller.identity_off; + const AetherCallerInfo* ci = caller_seen(); + if (!ci->present || ci->identity_off < 0) return ""; + return ci->arena + ci->identity_off; } const char* aether_caller_attribute(const char* key) { - if (!key || !tls_caller.present) return ""; - for (int i = 0; i < tls_caller.attr_count; i++) { - const char* k = tls_caller.arena + tls_caller.key_off[i]; + const AetherCallerInfo* ci = caller_seen(); + if (!key || !ci->present) return ""; + for (int i = 0; i < ci->attr_count; i++) { + const char* k = ci->arena + ci->key_off[i]; if (strcmp(k, key) == 0) { - return tls_caller.arena + tls_caller.val_off[i]; + return ci->arena + ci->val_off[i]; } } return ""; } int64_t aether_caller_deadline_ms(void) { - if (!tls_caller.present) return 0; - return tls_caller.deadline_ms; + const AetherCallerInfo* ci = caller_seen(); + if (!ci->present) return 0; + return ci->deadline_ms; } diff --git a/runtime/aether_host.h b/runtime/aether_host.h index ea497c2a1..9c5984929 100644 --- a/runtime/aether_host.h +++ b/runtime/aether_host.h @@ -261,6 +261,22 @@ const char* aether_caller_identity(void); const char* aether_caller_attribute(const char* key); int64_t aether_caller_deadline_ms(void); +/* Nesting (#2955): each execution (a call into an export, runtime/ + * aether_exec.h) sees the caller info that was set when it was entered. + * A host service the execution calls may set another for the executions + * it enters in turn; the running one keeps its own, and when it returns + * the setting it was entered with is back. aether_exec.c calls this as an + * execution at `depth` returns. */ +void aether_caller_leave(int depth); + +/* An actor step inside an execution (#3396, aether_step_barrier.h): the + * setting is put away for the step, which reads none and sets its own, + * and is back when the step ends. The token is NULL for an empty setting. + * With no memory to keep it, the step still reads none, and a setting it + * makes is refused (-1), so the enclosing one is never lost. */ +void* aether_caller_put_aside(void); +void aether_caller_give_back(void* token); + #ifdef __cplusplus } /* extern "C" */ #endif diff --git a/runtime/aether_lib_meta.h b/runtime/aether_lib_meta.h index 167e06c0a..1537b7d88 100644 --- a/runtime/aether_lib_meta.h +++ b/runtime/aether_lib_meta.h @@ -89,6 +89,26 @@ * library's runtime, which made it, releases it. Set only when some export * returns a string. * + * v11 (schema "1.10", #2956) appends `outcome`: the name of the function + * `int (void)` the library exports, with `const char* _text(void)` + * beside it: the outcome of the library's last call on the calling thread, + * in the library's own runtime (aether_last_outcome, libaether.h). Every + * export runs in an execution frame, so a panic ends the call with + * AETHER_PANICKED instead of the process; an Aether importer reads the + * outcome after each call and raises a failure again as a panic in its own + * runtime. Every library has it, so every catalog was "1.10" until v12. + * + * v12 (schema "1.11", #2957) appends `host_abi`, the AETHER_HOST_ABI level + * of libaether.h it was built at (none recorded is level 0, Aether 0.856 + * and before); `runtime_abi`, the AETHER_RUNTIME_ABI_ID (aether_abi.h) of + * the runtime headers it was compiled against, which its stubs check + * against the runtime they run on before the first call; and `requires`, + * the duties its importer must perform, by name, comma-separated: + * outcome_check (1.10), string_release (1.9), shared_runtime (1.5), + * runtime_abi (1.11), actors (1.7). An importer refuses a catalog that + * requires a duty it does not know, and reads one whose newer fields it + * only does not know (docs/abi-policy.md). Every catalog is "1.11". + * * Schema versioning: `schema_version` is "1.0" for function-only * artifacts, "1.1" once closure records are present, "1.2" once * constant records are present, "1.3" once struct records or source @@ -227,6 +247,15 @@ typedef struct { const char* string_release; /* schema >= 1.9: the symbol a caller gives a returned string back through */ + const char* outcome; /* schema >= 1.10: the symbol + of the library's outcome + accessor (#2956) */ + int host_abi; /* schema >= 1.11: the host + ABI level (#2957) */ + const char* runtime_abi; /* schema >= 1.11: the runtime + ABI it was compiled for */ + const char* requires; /* schema >= 1.11: its + importer's duties */ } AetherLibMeta; /* The single entry point. Every `--emit=lib` artifact exports this diff --git a/runtime/aether_resource_caps.c b/runtime/aether_resource_caps.c index e83afd6e7..362f3719d 100644 --- a/runtime/aether_resource_caps.c +++ b/runtime/aether_resource_caps.c @@ -6,16 +6,44 @@ * forwarders) calls in via the header. */ +#if !defined(_WIN32) && !defined(_GNU_SOURCE) +# define _GNU_SOURCE /* RTLD_DEFAULT in glibc's */ +#endif #include "aether_resource_caps.h" #include "utils/aether_thread.h" #include "utils/aether_compiler.h" +#include "actors/aether_frames.h" +#include "aether_rv_mark.h" + +/* The host API (libaether.h): exported however the runtime is built. */ +#if defined(__GNUC__) || defined(__clang__) +# define AETHER_CAPS_API __attribute__((visibility("default"))) +#else +# define AETHER_CAPS_API +#endif #include +#include #include #include +#include /* offsetof: the marks (#3445) */ #ifdef _WIN32 # include +# include /* the module list, under the loader's lock (#3445) */ +#endif +#if !defined(_WIN32) && !defined(AETHER_NO_THREADING) +# include +#endif +#if !defined(_WIN32) && (defined(__unix__) || defined(__APPLE__)) +# include +# if defined(__APPLE__) +# include +# include +# elif defined(__linux__) || defined(__FreeBSD__) || defined(__NetBSD__) || defined(__OpenBSD__) +# include +# define AETHER_RV_PHDR 1 +# endif #endif /* Process-wide. _Atomic for the lock-free fast path — every alloc @@ -24,6 +52,23 @@ static _Atomic uint64_t g_mem_used = 0; static _Atomic uint64_t g_mem_cap = 0; /* 0 = unlimited */ +/* What the allocation fast path must ask about, in one word it reads once + * (#2954): a cap is set, an execution account exists, the test hook is + * armed. None of them (AETHER_CAPS_ALLOC_BITS clear), and an allocation only + * counts its bytes. The word's other bits are the step barrier's + * (aether_resource_caps.h). */ +#define CAPS_SLOW_CAP 1u +#define CAPS_SLOW_ACCOUNTS 2u +#define CAPS_SLOW_TEST 4u +_Static_assert(AETHER_CAPS_ALLOC_BITS == (CAPS_SLOW_CAP | CAPS_SLOW_ACCOUNTS | CAPS_SLOW_TEST), + "the allocation fast path tests exactly its own bits"); +_Static_assert(CAPS_SLOW_ACCOUNTS == AETHER_CAPS_SLOW_ACCOUNTS, "one accounts bit"); +_Static_assert(((CAPS_SLOW_CAP | CAPS_SLOW_TEST) & AETHER_CAPS_STEP_BITS) == 0 && + (AETHER_CAPS_SLOW_SANDBOX & AETHER_CAPS_ALLOC_BITS) == 0 && + (AETHER_CAPS_SPAWN_MASK & (AETHER_CAPS_ALLOC_BITS | AETHER_CAPS_SLOW_SANDBOX)) == 0, + "the step's bits and the allocation's share only the accounts bit"); +_Atomic unsigned aether_caps_slow = 0; + /* Per-thread. The deadline is monotonic-clock nanoseconds; 0 means * "no deadline armed". The tripped flag is sticky once set, until * aether_caps_set_deadline_ms is called again. */ @@ -85,16 +130,28 @@ void aether_caps_account_free(size_t bytes) { void aether_caps_set_memory_cap(uint64_t bytes) { atomic_store_explicit(&g_mem_cap, bytes, memory_order_relaxed); + if (bytes) atomic_fetch_or_explicit(&aether_caps_slow, CAPS_SLOW_CAP, memory_order_relaxed); + else atomic_fetch_and_explicit(&aether_caps_slow, ~CAPS_SLOW_CAP, memory_order_relaxed); } -/* Nonzero once any thread has armed or tripped a deadline. The codegen's - * loop-head check reads this first, so code that never uses deadlines pays - * one load instead of a call and two TLS reads. It is never cleared: another - * thread may still be armed, and the full check below stays exact. */ -int aether_caps_armed = 0; +/* Nonzero once any thread has armed or tripped a deadline, or cancelled a + * call (aether_exec.c). The codegen's loop-head check reads this first, so + * code that never uses deadlines pays one load instead of a call and two + * TLS reads. It is never cleared: another thread may still be armed, and + * the full check below stays exact. + * + * Atomic, read with relaxed loads (a plain load on x86 and ARM): a cancel + * comes from another thread while the call runs, and a loop that started + * before it must see it. As a plain int, clang read it once before a loop + * whose body calls nothing and never again, so the loop ran to its end. */ +_Atomic int aether_caps_armed = 0; + +static inline void caps_arm(void) { + atomic_store_explicit(&aether_caps_armed, 1, memory_order_relaxed); +} void aether_caps_set_deadline_ms(int64_t ms) { - if (ms > 0) aether_caps_armed = 1; + if (ms > 0) caps_arm(); if (ms <= 0) { g_deadline_at_ns = 0; } else { @@ -108,7 +165,7 @@ int aether_caps_deadline_tripped(void) { if (g_tripped) return 1; if (g_deadline_at_ns == 0) return 0; if (now_ns() >= g_deadline_at_ns) { - aether_caps_armed = 1; + caps_arm(); g_tripped = 1; return 1; } @@ -116,10 +173,32 @@ int aether_caps_deadline_tripped(void) { } void __aether_abort_call(void) { - aether_caps_armed = 1; + caps_arm(); g_tripped = 1; } +void aether_caps_deadline_save(AetherDeadlineState* out) { + out->at_ns = g_deadline_at_ns; + out->tripped = g_tripped; +} + +void aether_caps_deadline_restore(const AetherDeadlineState* s) { + if (s->at_ns || s->tripped) caps_arm(); + g_deadline_at_ns = s->at_ns; + g_tripped = s->tripped; +} + +AetherDeadlineState aether_caps_deadline_for_ms(int64_t ms) { + AetherDeadlineState s; + s.at_ns = ms > 0 ? now_ns() + ms * 1000000LL : 0; + s.tripped = 0; + return s; +} + +int aether_caps_deadline_flag(void) { + return g_tripped; +} + uint64_t aether_caps_used_bytes(void) { return atomic_load_explicit(&g_mem_used, memory_order_relaxed); } @@ -127,43 +206,1323 @@ uint64_t aether_caps_used_bytes(void) { #include #include +/* ------------------------------------------------------------------ + * Allocation failure inside an execution (#2956) + * ------------------------------------------------------------------ + * A cap-aware allocation that fails, past the cap or with the allocator + * out of memory, returns NULL to its caller, which fails cleanly (an + * error, its own memory freed), as it does outside an execution. Inside + * one the failure is also noted on the innermost execution, and its next + * safe point, or its return, ends it with AETHER_MEMORY_CAP or + * AETHER_OUT_OF_MEMORY. The call does not unwind from inside the + * allocator: that would skip the C code between it and the safe point, + * which holds memory and may hold a lock. */ + +/* aether_test_fail_alloc_after: one global load per allocation while no + * test has asked, the count per thread so a test is deterministic. The flag + * is read by generated code too (_aether_alloc, #3377), so it is exported. */ +_Atomic int aether_alloc_test_armed = 0; +static AETHER_TLS_SHARED int t_alloc_fail_after = 0; + +void aether_test_fail_alloc_after(int n) { + t_alloc_fail_after = n > 0 ? n : 0; + if (n > 0) { + atomic_store_explicit(&aether_alloc_test_armed, 1, memory_order_relaxed); + atomic_fetch_or_explicit(&aether_caps_slow, CAPS_SLOW_TEST, memory_order_relaxed); + } +} + +int aether_alloc_test_refuses(void) { + if (!atomic_load_explicit(&aether_alloc_test_armed, memory_order_relaxed)) return 0; + if (t_alloc_fail_after <= 0) return 0; + return --t_alloc_fail_after == 0; +} + +void aether_alloc_failed(int why) { + AetherFrames* F = aether_frames_here(); + if (F->exec_depth <= F->exec_floor) return; /* none of this code's (#3392) */ + AetherExecRecord* x = &F->exec[F->exec_depth - 1]; + if (!x->stop) x->stop = why; + caps_arm(); +} + +/* ------------------------------------------------------------------ + * Execution accounts (#2954) + * ------------------------------------------------------------------ + * A budget the host owns, for executions that outlive one call: a script + * that yields to its host and resumes later, while other scripts run on the + * same thread in between. The account in use on a thread (aether_exec_use) + * is charged every cap-aware allocation made there, and its parent chain + * with it; an allocation that would take any account on the chain past its + * allowance is refused, as the process cap refuses one, and inside an + * execution the call then ends with AETHER_MEMORY_CAP (aether_alloc_failed). + * + * A block remembers the account it was charged to, so a free credits that + * account wherever it happens: in another execution, under another account, + * or in the host outside any. The blocks of every account are in one table, + * kept only while some account exists (CAPS_SLOW_ACCOUNTS), behind one lock + * with the accounts' parent links and the list of live accounts. A block + * allocated under no account is in no table and credits nobody. */ + +static atomic_flag g_acct_lock = ATOMIC_FLAG_INIT; +static AetherExecAccount* g_live_accounts = NULL; +static int g_live_count = 0; + +typedef struct { const void* p; AetherExecAccount* a; } AcctSlot; +static AcctSlot* g_slots = NULL; /* open addressing, linear probes */ +static size_t g_slot_cap = 0; /* a power of two, or 0 */ +static size_t g_slot_used = 0; + +/* Held for a table probe or a chain walk, and for a free's one pass over + * the table. A waiter spins a little, pausing, then gives up the processor + * rather than spin hot (#3410). */ +static inline void acct_yield(void) { +#if defined(AETHER_NO_THREADING) + AETHER_CPU_PAUSE(); +#elif defined(_WIN32) + SwitchToThread(); +#else + sched_yield(); +#endif +} + +static inline void acct_lock(void) { + unsigned spins = 0; + while (atomic_flag_test_and_set_explicit(&g_acct_lock, memory_order_acquire)) { + if (++spins < 64) AETHER_CPU_PAUSE(); + else acct_yield(); + } +} +static inline void acct_unlock(void) { + atomic_flag_clear_explicit(&g_acct_lock, memory_order_release); +} + +static inline size_t slot_of(const void* p, size_t cap) { + uint64_t h = (uint64_t)(uintptr_t)p; + h ^= h >> 33; + h *= 0xff51afd7ed558ccdULL; + h ^= h >> 33; + return (size_t)h & (cap - 1); +} + +/* The table's own memory is plain malloc: it is no account's and no cap's. */ +static int slots_grow(void) { + size_t cap = g_slot_cap ? g_slot_cap * 2 : 1024; + AcctSlot* fresh = (AcctSlot*)calloc(cap, sizeof(AcctSlot)); + if (!fresh) return 0; + for (size_t i = 0; i < g_slot_cap; i++) { + if (!g_slots[i].p) continue; + size_t s = slot_of(g_slots[i].p, cap); + while (fresh[s].p) s = (s + 1) & (cap - 1); + fresh[s] = g_slots[i]; + } + free(g_slots); + g_slots = fresh; + g_slot_cap = cap; + return 1; +} + +/* Which addresses may have a block in the table (#3410): one bit per hash + * of an address. A thread with no account in use that frees or allocates a + * block whose bit is clear asks nothing more, and takes no lock. + * + * Why a clear bit is enough. A block is recorded only under the lock, and + * its bit is set first (a release), before the allocator returns the + * block to its caller. Whatever thread frees it got the pointer from that + * caller, after that return (the program's own hand-over orders the two), + * so its load (an acquire) sees the bit. A free that sees the bit clear is + * of a block no account was charged for. The bits are cleared only under + * the lock, when the table holds no block at all and enough of them are + * set to be worth it: no address is in the table then, and a block + * recorded after sets its bit again before it is handed out. + * + * What a false positive costs. A free (or an allocation with no account in + * use) whose bit another recorded block set takes the lock and finds no + * entry: one table probe under the lock. With n blocks recorded, about + * n / 2^18 of such frees take it (20,000 blocks: 8%). + * + * 32 KiB, the owning copy's only. */ +#define ACCT_FILTER_BITS (1u << 18) +#define ACCT_FILTER_WORDS (ACCT_FILTER_BITS / 64) +static _Atomic uint64_t g_filter[ACCT_FILTER_WORDS]; +static size_t g_filter_set = 0; /* bits set, under the lock */ + +static inline uint32_t filter_bit(const void* p) { + return (uint32_t)(((uint64_t)(uintptr_t)p * 0x9e3779b97f4a7c15ULL) >> (64 - 18)); +} + +static inline int filter_may_hold(const void* p) { + uint32_t b = filter_bit(p); + return (int)((atomic_load_explicit(&g_filter[b >> 6], memory_order_acquire) >> (b & 63)) & 1u); +} + +/* Under the lock. */ +static inline void filter_note(const void* p) { + uint32_t b = filter_bit(p); + uint64_t bit = (uint64_t)1 << (b & 63); + if (!(atomic_fetch_or_explicit(&g_filter[b >> 6], bit, memory_order_release) & bit)) g_filter_set++; +} + +/* Under the lock, with no block in the table. */ +static void filter_clear(void) { + for (size_t i = 0; i < ACCT_FILTER_WORDS; i++) atomic_store_explicit(&g_filter[i], 0, memory_order_relaxed); + g_filter_set = 0; +} + +/* Under the lock. */ +static int slot_put(const void* p, AetherExecAccount* a) { + if ((g_slot_used + 1) * 2 > g_slot_cap && !slots_grow()) return 0; + size_t s = slot_of(p, g_slot_cap); + while (g_slots[s].p && g_slots[s].p != p) s = (s + 1) & (g_slot_cap - 1); + if (!g_slots[s].p) { + filter_note(p); + g_slot_used++; + } + g_slots[s].p = p; + g_slots[s].a = a; + return 1; +} + +/* Under the lock: the slot of `p` emptied (the probe chain after it shifted + * back), and the account it named returned. */ +static AetherExecAccount* slot_take(const void* p) { + if (!g_slot_cap) return NULL; + size_t s = slot_of(p, g_slot_cap); + while (g_slots[s].p && g_slots[s].p != p) s = (s + 1) & (g_slot_cap - 1); + if (!g_slots[s].p) return NULL; + AetherExecAccount* a = g_slots[s].a; + g_slots[s].p = NULL; + if (--g_slot_used == 0 && g_filter_set > ACCT_FILTER_BITS / 8) filter_clear(); + size_t hole = s; + for (size_t j = (s + 1) & (g_slot_cap - 1); g_slots[j].p; j = (j + 1) & (g_slot_cap - 1)) { + size_t home = slot_of(g_slots[j].p, g_slot_cap); + /* Moves back when its home is not in (hole, j] cyclically. */ + int stays = hole <= j ? (home > hole && home <= j) : (home > hole || home <= j); + if (!stays) { + g_slots[hole] = g_slots[j]; + g_slots[j].p = NULL; + hole = j; + } + } + return a; +} + +/* Under the lock: `bytes` charged to `a` and its ancestors, or to none of + * them when one would pass its allowance (which is then marked tripped). */ +static int chain_charge(AetherExecAccount* a, uint64_t bytes) { + for (AetherExecAccount* x = a; x; x = x->parent) { + uint64_t cur = atomic_load_explicit(&x->used, memory_order_relaxed); + if (x->memory_bytes && (cur + bytes > x->memory_bytes || cur + bytes < cur)) { + for (AetherExecAccount* y = a; y != x; y = y->parent) + atomic_fetch_sub_explicit(&y->used, bytes, memory_order_relaxed); + atomic_fetch_or_explicit(&x->tripped, AETHER_ACCOUNT_TRIPPED_MEMORY, memory_order_relaxed); + return 0; + } + atomic_store_explicit(&x->used, cur + bytes, memory_order_relaxed); + } + return 1; +} + +static void chain_credit(AetherExecAccount* a, uint64_t bytes) { + for (AetherExecAccount* x = a; x; x = x->parent) { + uint64_t cur = atomic_load_explicit(&x->used, memory_order_relaxed); + atomic_store_explicit(&x->used, cur >= bytes ? cur - bytes : 0, memory_order_relaxed); + } +} + +/* The allocation fast-path word (aether_caps_slow) of every copy that shares + * this copy's accounts, its own first: CAPS_SLOW_ACCOUNTS is set in each + * while an account exists, so a copy's fast path stays one load of its own + * word. Every copy registers at its first execution or account call, used + * or not: a library's copy allocates under the account a host put in use + * through another copy, so it must know when one exists. A copy leaves when + * it is unloaded (#3411), so its word is never written after its image is + * gone. The list grows as needed (plain malloc); a copy that cannot join it + * keeps its own word slow and always asks the accounts. Under the lock. */ +static _Atomic unsigned* g_copy_words_here[16] = { &aether_caps_slow }; +static _Atomic unsigned** g_copy_words = g_copy_words_here; +static int g_copy_count = 1; +static int g_copy_cap = 16; + +static void copies_mark(int on) { + for (int i = 0; i < g_copy_count; i++) { + if (on) atomic_fetch_or_explicit(g_copy_words[i], CAPS_SLOW_ACCOUNTS, memory_order_release); + else atomic_fetch_and_explicit(g_copy_words[i], ~CAPS_SLOW_ACCOUNTS, memory_order_release); + } +} + +static int own_register(_Atomic unsigned* word) { + acct_lock(); + for (int i = 0; i < g_copy_count; i++) { + if (g_copy_words[i] == word) { acct_unlock(); return 0; } + } + if (g_copy_count == g_copy_cap) { + int cap = g_copy_cap * 2; + _Atomic unsigned** more = (_Atomic unsigned**)malloc(sizeof(*more) * (size_t)cap); + if (!more) { acct_unlock(); return -1; } + memcpy(more, g_copy_words, sizeof(*more) * (size_t)g_copy_count); + if (g_copy_words != g_copy_words_here) free(g_copy_words); + g_copy_words = more; + g_copy_cap = cap; + } + g_copy_words[g_copy_count++] = word; + if (g_live_count > 0) atomic_fetch_or_explicit(word, CAPS_SLOW_ACCOUNTS, memory_order_release); + acct_unlock(); + return 0; +} + +/* A copy is unloaded, or the process ends (#3411): its word leaves. At + * process exit on Windows the other threads are gone first, and one may + * have held the lock: the wait is bounded, and past it the word stays + * (nothing marks one at exit). */ +static void own_unregister(_Atomic unsigned* word) { + int got = 0; + for (int tries = 0; tries < 100000; tries++) { + if (!atomic_flag_test_and_set_explicit(&g_acct_lock, memory_order_acquire)) { got = 1; break; } + acct_yield(); + } + if (!got) return; + for (int i = 1; i < g_copy_count; i++) { + if (g_copy_words[i] == word) { + g_copy_words[i] = g_copy_words[--g_copy_count]; + break; + } + } + acct_unlock(); +} + +/* Under the lock: one thing less names `a`. 1 when it was the last, and the + * caller frees the block once it has let go of the lock. */ +static int acct_unref(AetherExecAccount* a) { + return --a->refs == 0; +} + +/* Under the lock. The account in use on this thread becomes `a`: `a` taken + * for this thread, the one it replaces let go (and returned in `*gone` when + * nothing names it any more). 0, or -1 when `a` is in use on another + * thread (the account in use is then left as it was). */ +static int account_switch(AetherFrames* F, AetherExecAccount* a, AetherExecAccount** gone) { + AetherExecAccount* was = F->account; + if (a == was) return 0; + if (a) { + uintptr_t expected = 0; + if (!atomic_compare_exchange_strong_explicit(&a->thread, &expected, (uintptr_t)F, + memory_order_acq_rel, memory_order_relaxed)) + return -1; + a->refs++; + } + F->account = a; + if (was) { + atomic_store_explicit(&was->thread, 0, memory_order_release); + if (acct_unref(was)) *gone = was; + } + return 0; +} + +/* The account in use on this thread. One the host freed meanwhile (on + * another thread, while it was in use here) is let go here, the thread + * whose it was: the next allocation, call or switch here finds it gone. */ +static AetherExecAccount* own_here(void) { + AetherFrames* F = aether_frames_here(); + AetherExecAccount* a = F->account; + if (!a || !atomic_load_explicit(&a->dead, memory_order_acquire)) return a; + AetherExecAccount* gone = NULL; + acct_lock(); + if (F->account && atomic_load_explicit(&F->account->dead, memory_order_relaxed)) + (void)account_switch(F, NULL, &gone); + a = F->account; + acct_unlock(); + free(gone); + return a; +} + +/* An execution begins: the account in use, held until it ends. */ +static AetherExecAccount* own_hold(void) { + AetherExecAccount* a = own_here(); + if (!a) return NULL; + acct_lock(); + if (atomic_load_explicit(&a->dead, memory_order_relaxed)) a = NULL; + else a->refs++; + acct_unlock(); + return a; +} + +int64_t aether_caps_now_ns(void) { + return now_ns(); +} + +/* The ACTIVE accounts this thread is timing (#3409), innermost last: each + * since the start of the outermost execution open here under it or under a + * descendant of it, and held until that execution ends. An execution nested + * inside one that already times an account adds no time of its own to it, + * so time spent in executions under an account is charged once, however + * they nest, across runtime copies too: they all time through the copy + * whose accounts they share. The first ACCT_TIMED_INLINE are kept here, the + * rest in a block that grows as needed (plain malloc, no account's), freed + * when this thread times none. Under the lock, this thread's only. */ +#define ACCT_TIMED_INLINE 8 +typedef struct { AetherExecAccount* a; int64_t since; } AcctTimed; +static AETHER_TLS_SHARED AcctTimed t_timed_inline[ACCT_TIMED_INLINE]; +static AETHER_TLS_SHARED AcctTimed* t_timed_more; /* entries past the inline ones */ +static AETHER_TLS_SHARED int t_timed_more_cap; +static AETHER_TLS_SHARED int t_timed_n; + +static inline AcctTimed* timed_at(int i) { + return i < ACCT_TIMED_INLINE ? &t_timed_inline[i] : &t_timed_more[i - ACCT_TIMED_INLINE]; +} + +/* Under the lock: when this thread began timing `a`, or -1. */ +static int64_t timed_since(const AetherExecAccount* a) { + for (int i = t_timed_n - 1; i >= 0; i--) + if (timed_at(i)->a == a) return timed_at(i)->since; + return -1; +} + +/* Under the lock: `a` timed from `now` (and held). 0 when there was no room + * and none could be had. */ +static int timed_push(AetherExecAccount* a, int64_t now) { + int i = t_timed_n; + if (i >= ACCT_TIMED_INLINE && i - ACCT_TIMED_INLINE >= t_timed_more_cap) { + int cap = t_timed_more_cap ? t_timed_more_cap * 2 : ACCT_TIMED_INLINE; + /* A checked allocation: the test hook refuses it as it does any. */ + if (aether_alloc_test_refuses()) return 0; + AcctTimed* more = (AcctTimed*)realloc(t_timed_more, sizeof(AcctTimed) * (size_t)cap); + if (!more) return 0; + t_timed_more = more; + t_timed_more_cap = cap; + } + timed_at(i)->a = a; + timed_at(i)->since = now; + t_timed_n = i + 1; + a->refs++; + return 1; +} + +/* An execution begins under `a` (held for it): `*at` the deadline in force + * for it, the earliest of `a`'s and its ancestors' as absolute monotonic ns + * (0: none), `*tripped` set when one already passed. The ACTIVE accounts on + * the chain no execution open here times yet are timed from now, for this + * one: `*timed` says how many, for its end. -1 when one of them could not + * be timed (no memory for the thread's list): the execution must not run, + * or its account's budget would go unenforced. */ +static int own_exec_begin(AetherExecAccount* a, int64_t* at_out, int* tripped, int* timed) { + int64_t at = 0, now = now_ns(); + int tr = 0, n = 0, ok = 1; + acct_lock(); + /* A freed account sets no deadline: its chain is unlinked. */ + if (a && atomic_load_explicit(&a->dead, memory_order_relaxed)) a = NULL; + for (AetherExecAccount* x = a; x; x = x->parent) { + int64_t own = 0; + if (x->deadline_counts == AETHER_DEADLINE_ACTIVE) { + int64_t since = timed_since(x); + if (since < 0) { + if (!timed_push(x, now)) { ok = 0; break; } + n++; + since = now; + } + /* What is left of it: its budget, less what it spent before and + * since this thread began timing it. */ + if (x->active_budget_ns) + own = since + x->active_budget_ns - atomic_load_explicit(&x->active_spent_ns, memory_order_relaxed); + } else { + own = x->deadline_at_ns; + } + if (atomic_load_explicit(&x->tripped, memory_order_relaxed) & AETHER_ACCOUNT_TRIPPED_DEADLINE) tr = 1; + if (own && (!at || own < at)) at = own; + } + acct_unlock(); + if (at && at <= now) tr = 1; + *at_out = at; + *tripped = tr; + *timed = n; + return ok ? 0 : -1; +} + +/* The execution begun under `a` ends, its deadline tripped or not: the + * accounts it began timing are charged the time since, and let go; the + * trip, where a deadline on the chain passed, recorded. */ +static void own_exec_end(AetherExecAccount* a, int timed, int tripped) { + int64_t now = now_ns(); + AetherExecAccount* gone = NULL; /* let go for the last time, freed after the lock */ + acct_lock(); + for (int i = 0; i < timed && t_timed_n > 0; i++) { + AcctTimed e = *timed_at(--t_timed_n); + AetherExecAccount* x = e.a; + /* Nothing is charged to an account the host freed meanwhile. */ + if (!atomic_load_explicit(&x->dead, memory_order_relaxed)) { + int64_t spent = atomic_fetch_add_explicit(&x->active_spent_ns, now - e.since, + memory_order_relaxed) + (now - e.since); + if (tripped && x->active_budget_ns && spent >= x->active_budget_ns) + atomic_fetch_or_explicit(&x->tripped, AETHER_ACCOUNT_TRIPPED_DEADLINE, memory_order_relaxed); + } + if (acct_unref(x)) { /* dead and unlinked: its live-list link is free */ + x->next_live = gone; + gone = x; + } + } + if (t_timed_n == 0 && t_timed_more) { + free(t_timed_more); + t_timed_more = NULL; + t_timed_more_cap = 0; + } + /* An account trips on its own deadline: the call's trip flag is only the + * sign that a safe point saw one pass. The ACTIVE ones an enclosing + * execution times count the time open so far. */ + if (tripped && a && !atomic_load_explicit(&a->dead, memory_order_relaxed)) { + for (AetherExecAccount* x = a; x; x = x->parent) { + int own = 0; + if (x->deadline_counts == AETHER_DEADLINE_ACTIVE) { + int64_t since = timed_since(x); + own = since >= 0 && x->active_budget_ns && + atomic_load_explicit(&x->active_spent_ns, memory_order_relaxed) + (now - since) >= + x->active_budget_ns; + } else { + own = x->deadline_at_ns && now >= x->deadline_at_ns; + } + if (own) atomic_fetch_or_explicit(&x->tripped, AETHER_ACCOUNT_TRIPPED_DEADLINE, memory_order_relaxed); + } + } + acct_unlock(); + while (gone) { + AetherExecAccount* next = gone->next_live; + free(gone); + gone = next; + } +} + +static AetherExecAccount* own_new(const AetherExecLimits* limits) { + AetherExecAccount* a = (AetherExecAccount*)calloc(1, sizeof(AetherExecAccount)); + if (!a) return NULL; + a->owner = &g_acct_lock; + if (limits) { + a->memory_bytes = limits->memory_bytes; + a->deadline_counts = limits->deadline_counts == AETHER_DEADLINE_ACTIVE + ? AETHER_DEADLINE_ACTIVE : AETHER_DEADLINE_ELAPSED; + if (limits->deadline_ms > 0) { + if (a->deadline_counts == AETHER_DEADLINE_ACTIVE) + a->active_budget_ns = limits->deadline_ms * 1000000LL; + else + a->deadline_at_ns = now_ns() + limits->deadline_ms * 1000000LL; + caps_arm(); + } + } + acct_lock(); + /* Only a live parent of these accounts is linked. One they do not hold + * (the host freed it, or a copy that does not share them made it) is + * refused, not dropped (#3412): the account would be wider than the + * parent meant to narrow it. Found by its address alone, never read. */ + if (limits && limits->parent) { + for (AetherExecAccount* x = g_live_accounts; x; x = x->next_live) + if (x == limits->parent) { a->parent = x; break; } + if (!a->parent) { + acct_unlock(); + free(a); + return NULL; + } + } + a->refs = 1; /* the live list's */ + a->next_live = g_live_accounts; + g_live_accounts = a; + g_live_count++; + if (g_live_count == 1) copies_mark(1); + acct_unlock(); + return a; +} + +/* The host frees `a` (#3407): nothing in the runtime names it after this. + * In use on this thread, it is in use no more. In use on another thread, + * or named by an execution that began under it (here or there), it is + * dead: charged, put in use and linked no more, and the block goes when + * the last of them lets go of it. */ +static void own_free(AetherExecAccount* a) { + if (!a) return; + acct_lock(); + AetherExecAccount** link = &g_live_accounts; + while (*link && *link != a) link = &(*link)->next_live; + if (!*link) { acct_unlock(); return; } /* not a live account */ + *link = a->next_live; + g_live_count--; + atomic_store_explicit(&a->dead, 1, memory_order_release); + /* Its blocks credit nobody from now on: they count only against the + * process cap until they are freed. So its ancestors are credited now + * with what its own live blocks were charged (#3408): its count, less + * what its live children hold. Those stay charged where they are: a + * child goes under its parent's parent, which its blocks were charged + * to all along, and keeps narrowing under it. */ + uint64_t own = atomic_load_explicit(&a->used, memory_order_relaxed); + for (AetherExecAccount* x = g_live_accounts; x; x = x->next_live) { + if (x->parent != a) continue; + uint64_t held = atomic_load_explicit(&x->used, memory_order_relaxed); + own = own >= held ? own - held : 0; + x->parent = a->parent; + } + if (a->parent && own) chain_credit(a->parent, own); + a->parent = NULL; + /* One pass (#3410): a take shifts later entries of its probe chain back + * into the hole, so the slot is looked at again until it holds none of + * its blocks. An entry shifted to a slot already passed comes from the + * wrapped end of a chain, passed too, so it is none of them either. */ + for (size_t i = 0; g_live_count > 0 && i < g_slot_cap; i++) + while (g_slots[i].p && g_slots[i].a == a) (void)slot_take(g_slots[i].p); + if (g_live_count == 0) { /* every block left was its: the table goes */ + free(g_slots); + g_slots = NULL; + g_slot_cap = 0; + g_slot_used = 0; + if (g_filter_set) filter_clear(); + copies_mark(0); + } + AetherExecAccount* gone = NULL; + AetherFrames* F = aether_frames_here(); + if (F->account == a) (void)account_switch(F, NULL, &gone); + if (acct_unref(a)) gone = a; /* the live list's */ + acct_unlock(); + free(gone); +} + +/* aether_exec_use: an account these accounts did not make (one another copy + * of the runtime made, where the copies did not find each other) is refused, + * so a copy never charges, links or records a block for an account another + * copy's lock guards and another copy may free; so is one the host freed. + * The one it replaces is given back unless the host freed it. */ +static int own_use(AetherExecAccount* a, AetherExecAccount** previous) { + if (a && a->owner != (const void*)&g_acct_lock) return -1; + AetherFrames* F = aether_frames_here(); + AetherExecAccount* gone = NULL; + acct_lock(); + if (a && atomic_load_explicit(&a->dead, memory_order_relaxed)) { acct_unlock(); return -1; } + AetherExecAccount* was = F->account; + if (was && atomic_load_explicit(&was->dead, memory_order_relaxed)) was = NULL; + int r = account_switch(F, a, &gone); + acct_unlock(); + free(gone); + if (r != 0) return -1; + if (previous) *previous = was; + return 0; +} + +/* An execution ends: the account in use when it began is in use again, + * whatever the code it ran left (#2954), unless the host freed it, and the + * execution lets go of it. One another thread has taken meanwhile is not + * taken from it: then no account is in use. */ +static void own_restore(AetherExecAccount* a) { + AetherFrames* F = aether_frames_here(); + AetherExecAccount* gone[2] = { NULL, NULL }; + acct_lock(); + AetherExecAccount* back = a && !atomic_load_explicit(&a->dead, memory_order_relaxed) ? a : NULL; + if (account_switch(F, back, &gone[0]) != 0) (void)account_switch(F, NULL, &gone[0]); + if (a && acct_unref(a)) gone[1] = a; /* the execution's */ + acct_unlock(); + free(gone[0]); + if (gone[1] != gone[0]) free(gone[1]); +} + +/* An actor step begins (aether_step_barrier.h): the account in use is the + * code's the step runs inside, not the step's, whose allocations an actor's + * own thread would not charge to it. Put aside, still this thread's, so no + * other thread takes it meanwhile; NULL when none was in use. */ +static AetherExecAccount* own_step_aside(void) { + AetherFrames* F = aether_frames_here(); + AetherExecAccount* was = F->account; + F->account = NULL; + return was; +} + +/* The step ends: one it left in use is let go, and the one put aside is in + * use again, or let go too when the host freed it meanwhile. */ +static void own_step_back(AetherExecAccount* was) { + AetherFrames* F = aether_frames_here(); + if (!F->account && !was) return; + AetherExecAccount* gone[2] = { NULL, NULL }; + acct_lock(); + if (F->account && F->account != was) (void)account_switch(F, NULL, &gone[0]); + F->account = was; + if (was && atomic_load_explicit(&was->dead, memory_order_relaxed)) + (void)account_switch(F, NULL, &gone[1]); + acct_unlock(); + free(gone[0]); + free(gone[1]); +} + +static uint64_t own_used(const AetherExecAccount* a) { + return a ? atomic_load_explicit(&a->used, memory_order_relaxed) : 0; +} + +static int own_tripped(const AetherExecAccount* a) { + return a ? atomic_load_explicit(&a->tripped, memory_order_relaxed) : 0; +} + +/* The account in use's charge for a block of `bytes`, under the lock: 1 and + * the block recorded (when an account is in use), or 0 refused. With no + * account in use the block is in no table: a stale entry at its address + * (a block freed with plain free()) is dropped. */ +static int own_alloc(void* p, size_t bytes) { + AetherExecAccount* a = own_here(); + /* No account in use here: nothing to charge, and a stale entry at this + * address (a block an account was charged for, freed with plain free()) + * only where its bit is set. No lock otherwise (#3410). */ + if (!a && !filter_may_hold(p)) return 1; + acct_lock(); + if (a && atomic_load_explicit(&a->dead, memory_order_relaxed)) a = NULL; /* freed meanwhile */ + if (!a) { + (void)slot_take(p); + acct_unlock(); + return 1; + } + int ok = chain_charge(a, (uint64_t)bytes); + if (ok && !slot_put(p, a)) { + chain_credit(a, (uint64_t)bytes); /* no room to remember it: charged to none */ + } + acct_unlock(); + return ok; +} + +static void own_free_block(const void* p, size_t bytes) { + if (!filter_may_hold(p)) return; /* no account was charged for it: no lock */ + acct_lock(); + AetherExecAccount* a = slot_take(p); + if (a) chain_credit(a, (uint64_t)bytes); + acct_unlock(); +} + +/* A realloc of a cap-aware block, for the accounts: the old block's bytes + * back to the account it was charged to, the new block's charged to the + * account in use, before the realloc, so a refusal leaves it as it was. 0 + * refused. own_realloc_end records the new block, or undoes the charge + * when the realloc itself failed (np NULL). Both accounts are held from one + * to the other, so a free meanwhile leaves neither naming a freed block. */ +static int own_realloc_begin(void* p, size_t old_bytes, size_t new_bytes, + AetherExecAccount** old_owner, AetherExecAccount** now) { + *now = own_here(); + if (!*now && (!p || !filter_may_hold(p))) { /* nothing to charge or credit: no lock */ + *old_owner = NULL; + return 1; + } + acct_lock(); + if (*now && atomic_load_explicit(&(*now)->dead, memory_order_relaxed)) *now = NULL; + *old_owner = p ? slot_take(p) : NULL; + if (*old_owner) chain_credit(*old_owner, (uint64_t)old_bytes); + if (*now && !chain_charge(*now, (uint64_t)new_bytes)) { + if (*old_owner) { + (void)chain_charge(*old_owner, (uint64_t)old_bytes); /* it was within it */ + (void)slot_put(p, *old_owner); + } + acct_unlock(); + return 0; + } + if (*old_owner) (*old_owner)->refs++; + if (*now) (*now)->refs++; + acct_unlock(); + return 1; +} + +static void own_realloc_end(void* p, void* np, size_t old_bytes, size_t new_bytes, + AetherExecAccount* old_owner, AetherExecAccount* now) { + if (!old_owner && !now && !(np && filter_may_hold(np))) return; /* nothing held: no lock */ + AetherExecAccount* gone[2] = { NULL, NULL }; + acct_lock(); + AetherExecAccount* was = old_owner && !atomic_load_explicit(&old_owner->dead, memory_order_relaxed) + ? old_owner : NULL; + AetherExecAccount* in_use = now && !atomic_load_explicit(&now->dead, memory_order_relaxed) ? now : NULL; + if (!np) { + if (in_use) chain_credit(in_use, (uint64_t)new_bytes); + if (was) { + (void)chain_charge(was, (uint64_t)old_bytes); + (void)slot_put(p, was); + } + } else if (in_use) { + if (!slot_put(np, in_use)) chain_credit(in_use, (uint64_t)new_bytes); + } else { + (void)slot_take(np); + } + if (old_owner && acct_unref(old_owner)) gone[0] = old_owner; + if (now && acct_unref(now)) gone[1] = now; + acct_unlock(); + free(gone[0]); + if (gone[1] != gone[0]) free(gone[1]); +} + +/* ------------------------------------------------------------------ + * The accounts are one per process (#2954) + * ------------------------------------------------------------------ + * A library with a runtime of its own makes its allocations through its own + * copy of this file, while the account in use was set through another (an + * Aether host's std.host, or a C host's). So the accounts, the table of + * blocks, the lock and each thread's account in use live in one copy, the + * first loaded image with a record of them (aether_rendezvous_find, as the + * sandbox levels do; an executable that exports nothing has its record, + * #3445), and every + * copy works through it: the lock is one per process, and an account in use + * on one thread is refused on another whichever copy asks. A copy finds it + * once, the first time it enters an execution or calls the account API, and + * is given the accounts' bit in its own fast-path word from then on. + * + * The rendezvous carries a version and the sizes of what the copies pass to + * each other. A copy whose layout differs refuses: it reports it once, its + * account calls fail (aether_exec_account_new returns NULL, aether_exec_use + * -1), its allocations are charged to no account, and it never reads or + * writes the other copy's state. The copy that owns the accounts must stay + * loaded while another uses them: unloading it first is not supported. */ + +#define AETHER_ACCOUNTS_RV_VERSION 1 + +typedef struct AetherAccountsRendezvous { + uint32_t version; + uint32_t size; /* sizeof(AetherAccountsRendezvous) */ + uint32_t account_size; /* sizeof(struct AetherExecAccount) */ + uint32_t limits_size; /* sizeof(AetherExecLimits) */ + int (*register_copy)(_Atomic unsigned* fast_path_word); + void (*unregister_copy)(_Atomic unsigned* fast_path_word); + AetherExecAccount* (*account_new)(const AetherExecLimits* limits); + void (*account_free)(AetherExecAccount* account); + int (*use)(AetherExecAccount* account, AetherExecAccount** previous); + void (*restore)(AetherExecAccount* account); + AetherExecAccount* (*here)(void); + AetherExecAccount* (*hold)(void); + uint64_t (*used)(const AetherExecAccount* account); + int (*tripped)(const AetherExecAccount* account); + int (*exec_begin)(AetherExecAccount* account, int64_t* at, int* tripped, int* timed); + void (*exec_end)(AetherExecAccount* account, int timed, int tripped); + int (*alloc)(void* p, size_t bytes); + void (*free_block)(const void* p, size_t bytes); + int (*realloc_begin)(void* p, size_t old_bytes, size_t new_bytes, + AetherExecAccount** old_owner, AetherExecAccount** now); + void (*realloc_end)(void* p, void* np, size_t old_bytes, size_t new_bytes, + AetherExecAccount* old_owner, AetherExecAccount* now); + AetherExecAccount* (*step_aside)(void); + void (*step_back)(AetherExecAccount* was); +} AetherAccountsRendezvous; + +static const AetherAccountsRendezvous g_own_accounts = { + AETHER_ACCOUNTS_RV_VERSION, (uint32_t)sizeof(AetherAccountsRendezvous), + (uint32_t)sizeof(struct AetherExecAccount), (uint32_t)sizeof(AetherExecLimits), + own_register, own_unregister, own_new, own_free, own_use, own_restore, own_here, own_hold, + own_used, own_tripped, + own_exec_begin, own_exec_end, own_alloc, own_free_block, own_realloc_begin, own_realloc_end, + own_step_aside, own_step_back, +}; + +/* This copy's, which its record names (aether_rv_mark.h, #3445): the other + * copies find it by the record where records are read, and by name where + * not (AETHER_RV_FN). */ +AETHER_RV_FN const AetherAccountsRendezvous* aether_accounts_rendezvous(void) { + return &g_own_accounts; +} + +#if AETHER_RV_MARKS +AETHER_RV_MARK(g_accounts_mark, AETHER_RV_KIND_ACCOUNTS, aether_accounts_rendezvous); +# define ACCOUNTS_SELF (&g_accounts_mark) +#else +# define ACCOUNTS_SELF NULL +#endif + +/* A copy whose layout differs from the owning copy's: no account, ever. */ +static int refused_register(_Atomic unsigned* w) { (void)w; return -1; } +static void refused_unregister(_Atomic unsigned* w) { (void)w; } +static AetherExecAccount* refused_new(const AetherExecLimits* l) { (void)l; return NULL; } +static void refused_free(AetherExecAccount* a) { (void)a; } +static int refused_use(AetherExecAccount* a, AetherExecAccount** prev) { + if (prev) *prev = NULL; + return a ? -1 : 0; +} +static void refused_restore(AetherExecAccount* a) { (void)a; } +static AetherExecAccount* refused_here(void) { return NULL; } +static uint64_t refused_used(const AetherExecAccount* a) { (void)a; return 0; } +static int refused_tripped(const AetherExecAccount* a) { (void)a; return 0; } +static int refused_begin(AetherExecAccount* a, int64_t* at, int* tr, int* n) { + (void)a; + *at = 0; + *tr = 0; + *n = 0; + return 0; +} +static void refused_end(AetherExecAccount* a, int n, int t) { (void)a; (void)n; (void)t; } +static int refused_alloc(void* p, size_t n) { (void)p; (void)n; return 1; } +static void refused_free_block(const void* p, size_t n) { (void)p; (void)n; } +static int refused_realloc_begin(void* p, size_t o, size_t n, AetherExecAccount** oo, AetherExecAccount** now) { + (void)p; (void)o; (void)n; *oo = NULL; *now = NULL; + return 1; +} +static void refused_realloc_end(void* p, void* np, size_t o, size_t n, AetherExecAccount* oo, AetherExecAccount* now) { + (void)p; (void)np; (void)o; (void)n; (void)oo; (void)now; +} +static AetherExecAccount* refused_step_aside(void) { return NULL; } +static void refused_step_back(AetherExecAccount* was) { (void)was; } +static const AetherAccountsRendezvous g_refused_accounts = { + AETHER_ACCOUNTS_RV_VERSION, (uint32_t)sizeof(AetherAccountsRendezvous), + (uint32_t)sizeof(struct AetherExecAccount), (uint32_t)sizeof(AetherExecLimits), + refused_register, refused_unregister, refused_new, refused_free, refused_use, refused_restore, + refused_here, refused_here, + refused_used, refused_tripped, refused_begin, refused_end, refused_alloc, refused_free_block, + refused_realloc_begin, refused_realloc_end, refused_step_aside, refused_step_back, +}; + +static const AetherAccountsRendezvous* _Atomic g_accounts = NULL; +_Atomic int aether_accounts_found = 0; + +typedef const AetherAccountsRendezvous* (*accounts_rv_fn)(void); + +static void accounts_copy_unloads(void); + +/* The accounts this copy works through, found the first time. */ +static const AetherAccountsRendezvous* accounts(void) { + const AetherAccountsRendezvous* r = atomic_load_explicit(&g_accounts, memory_order_acquire); + if (r) return r; + const AetherAccountsRendezvous* found = &g_own_accounts; + void* p = aether_rendezvous_find("aether_accounts_rendezvous", AETHER_RV_KIND_ACCOUNTS, ACCOUNTS_SELF); + if (p) { + accounts_rv_fn f; + memcpy(&f, &p, sizeof(f)); + const AetherAccountsRendezvous* other = f(); + if (other && other != &g_own_accounts) { + /* The version and size first: only those are read before it + * is known the rest is laid out as this copy lays it out. */ + if (other->version == AETHER_ACCOUNTS_RV_VERSION && + other->size == (uint32_t)sizeof(AetherAccountsRendezvous) && + other->account_size == (uint32_t)sizeof(struct AetherExecAccount) && + other->limits_size == (uint32_t)sizeof(AetherExecLimits)) { + found = other; + } else { + fprintf(stderr, + "aether: this runtime copy cannot share execution accounts with the one " + "loaded first (accounts version %u, %u-byte table, against %u, %u): its " + "account calls fail and its allocations are charged to no account; build " + "the libraries with one Aether version, or with --shared-runtime\n", + (unsigned)AETHER_ACCOUNTS_RV_VERSION, (unsigned)sizeof(AetherAccountsRendezvous), + (unsigned)other->version, (unsigned)other->size); + found = &g_refused_accounts; + } + } + } + const AetherAccountsRendezvous* expected = NULL; + if (atomic_compare_exchange_strong_explicit(&g_accounts, &expected, found, + memory_order_acq_rel, memory_order_acquire)) { + /* One that cannot join the list (no memory, #3411) asks the + * accounts on every allocation rather than miss one in use. */ + if (found->register_copy(&aether_caps_slow) != 0 && found != &g_refused_accounts) + atomic_fetch_or_explicit(&aether_caps_slow, CAPS_SLOW_ACCOUNTS, memory_order_release); +#if !(defined(__GNUC__) || defined(__clang__)) + if (found != &g_own_accounts) atexit(accounts_copy_unloads); +#endif + } + atomic_store_explicit(&aether_accounts_found, 1, memory_order_release); + return atomic_load_explicit(&g_accounts, memory_order_acquire); +} + +/* This copy is unloaded, or the process ends (#3411): its word leaves the + * accounts of the copy it shares them with, which would otherwise write + * into its image once it is gone. Run as a destructor (with atexit where + * there is none, which a library's C runtime runs at its unload). */ +static void accounts_copy_unloads(void) { + const AetherAccountsRendezvous* r = atomic_load_explicit(&g_accounts, memory_order_acquire); + if (r && r != &g_own_accounts) r->unregister_copy(&aether_caps_slow); +} +#if defined(__GNUC__) || defined(__clang__) +__attribute__((destructor)) static void accounts_copy_dtor(void) { + accounts_copy_unloads(); +} +#endif + +void aether_accounts_register(void) { + (void)accounts(); +} + +AetherExecAccount* aether_account_here(void) { + return accounts()->here(); +} + +AetherExecAccount* aether_account_hold(void) { + return accounts()->hold(); +} + +int aether_account_exec_begin(AetherExecAccount* a, int64_t* at, int* tripped, int* timed) { + return accounts()->exec_begin(a, at, tripped, timed); +} + +void aether_account_exec_end(AetherExecAccount* a, int timed, int tripped) { + accounts()->exec_end(a, timed, tripped); +} + +AetherExecAccount* aether_account_step_aside(void) { + return accounts()->step_aside(); +} + +void aether_account_step_back(AetherExecAccount* was) { + accounts()->step_back(was); +} + +void aether_account_restore(AetherExecAccount* a) { + accounts()->restore(a); +} + +int aether_accounts_in_force(void) { + return (atomic_load_explicit(&aether_caps_slow, memory_order_relaxed) & CAPS_SLOW_ACCOUNTS) != 0; +} + +AETHER_CAPS_API AetherExecAccount* aether_exec_account_new(const AetherExecLimits* limits) { + return accounts()->account_new(limits); +} + +AETHER_CAPS_API void aether_exec_account_free(AetherExecAccount* account) { + accounts()->account_free(account); +} + +AETHER_CAPS_API int aether_exec_use(AetherExecAccount* account, AetherExecAccount** previous) { + return accounts()->use(account, previous); +} + +AETHER_CAPS_API uint64_t aether_exec_account_used(const AetherExecAccount* account) { + return accounts()->used(account); +} + +AETHER_CAPS_API int aether_exec_account_tripped(const AetherExecAccount* account) { + return accounts()->tripped(account); +} + +/* std.host's: an account from scalars, for a caller with no C struct. */ +AETHER_CAPS_API AetherExecAccount* aether_exec_account_make(uint64_t memory_bytes, int64_t deadline_ms, + int counts, AetherExecAccount* parent) { + AetherExecLimits l = { memory_bytes, deadline_ms, counts, parent }; + return aether_exec_account_new(&l); +} + +/* ------------------------------------------------------------------ + * Runtime copies that share state (#2955, #2954) + * ------------------------------------------------------------------ + * A library built without --shared-runtime carries a copy of the runtime, + * and on Windows and macOS that copy stays apart from the program's. State + * that must be one per process (the sandbox levels a thread is inside, the + * execution accounts) lives in one copy, the first loaded image, in load + * order, with a record of it (aether_rv_mark.h): every copy asks the same + * question, so they agree, whatever the images export (#3445). A record + * counts only when the function it names lies inside its image. + * + * The walk stops at the first record, and at the asking copy's own image + * whether its record is read or not, so it reads no image loaded after that + * one. Those before it are loaded whole, relocated: the loader loads one + * library at a time (glibc's and musl's load lock, dyld's, the Windows + * loader lock), adds each at the end of the list, and relocates what one + * load brings before any of its constructors runs. So no copy waits for a + * load or tests an image by name, and every copy answers alike at any + * moment. An image is held, or read under the loader's lock, while it is + * read; the copy that owns the state must stay loaded while another uses + * it. A namespace of its own (glibc's dlmopen) is a process of its own here: + * its copies find none outside it. + * ------------------------------------------------------------------ */ + +#if AETHER_RV_MARKS +# if AETHER_RV_ELF +# define RV_DESC_AT offsetof(AetherRvMark, fn_offset) +# define RV_DESC_SIZE (2 * sizeof(int32_t)) +# else +# define RV_DESC_AT offsetof(AetherRvMark, fn) +# define RV_DESC_SIZE sizeof(aether_rv_fn) +# endif + +/* The record of `kind` in [p, p + size), or NULL: its 24-byte head (the note + * header and the name, aether_rv_mark.h) at any 4-aligned place. Not by + * walking the notes: a linker that puts notes of both alignments in one + * PT_NOTE (gold), or starts it 4 past an 8-aligned place (after .interp), + * leaves a walk by the segment's alignment out of step with the record. The + * head is 24 exact bytes, and what it names must lie inside its image, which + * the callers check. */ +static const AetherRvMark* rv_mark_in(const unsigned char* p, size_t size, uint32_t kind) { + unsigned char head[RV_DESC_AT]; + uint32_t words[3] = { (uint32_t)sizeof AETHER_RV_NAME, (uint32_t)RV_DESC_SIZE, kind }; + memcpy(head, words, sizeof words); + memset(head + sizeof words, 0, sizeof head - sizeof words); + memcpy(head + sizeof words, AETHER_RV_NAME, sizeof AETHER_RV_NAME); + if (!p || size < sizeof(AetherRvMark)) return NULL; + for (size_t at = 0; at <= size - sizeof(AetherRvMark); at += 4) + if (memcmp(p + at, head, sizeof head) == 0) return (const AetherRvMark*)(const void*)(p + at); + return NULL; +} + +/* The function a record names, as aether_rendezvous_find returns it. */ +static void* rv_mark_fn(const AetherRvMark* m) { +# if AETHER_RV_ELF + int32_t off; + memcpy(&off, (const unsigned char*)m + RV_DESC_AT, sizeof(off)); + return (void*)((uintptr_t)m + RV_DESC_AT + (uintptr_t)(intptr_t)off); +# else + aether_rv_fn f; + memcpy(&f, (const unsigned char*)m + RV_DESC_AT, sizeof(f)); + void* p; + memcpy(&p, &f, sizeof(p)); + return p; +# endif +} +#endif + +#if defined(_WIN32) && AETHER_RV_MARKS +/* The function the module's record of `kind` names (its .aerv section, from + * the headers the loader maps with it), when it lies inside the module; + * NULL otherwise. The module is held while it is read, and one another + * thread has unloaded since the list was taken is skipped. */ +static void* rv_pe_mark(HMODULE listed, uint32_t kind) { + HMODULE m = NULL; + if (!GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, (LPCWSTR)(const void*)listed, &m) || + !m) + return NULL; + void* f = NULL; + const unsigned char* base = (const unsigned char*)m; + const IMAGE_DOS_HEADER* dos = (const IMAGE_DOS_HEADER*)(const void*)base; + if (m == listed && dos->e_magic == IMAGE_DOS_SIGNATURE && dos->e_lfanew > 0) { + const IMAGE_NT_HEADERS* nt = (const IMAGE_NT_HEADERS*)(const void*)(base + dos->e_lfanew); + if (nt->Signature == IMAGE_NT_SIGNATURE) { + const IMAGE_SECTION_HEADER* s = IMAGE_FIRST_SECTION(nt); + for (WORD i = 0; i < nt->FileHeader.NumberOfSections; i++) { + if (memcmp(s[i].Name, AETHER_RV_SECTION "\0\0\0", IMAGE_SIZEOF_SHORT_NAME) != 0) continue; + const AetherRvMark* mark = + rv_mark_in(base + s[i].VirtualAddress, (size_t)s[i].Misc.VirtualSize, kind); + if (mark) { + uintptr_t a = (uintptr_t)rv_mark_fn(mark), lo = (uintptr_t)base; + if (a >= lo && a - lo < (uintptr_t)nt->OptionalHeader.SizeOfImage) f = rv_mark_fn(mark); + } + break; + } + } + } + FreeLibrary(m); + return f; +} +#endif + +#if defined(__APPLE__) && AETHER_RV_MARKS && defined(RTLD_NOLOAD) +/* The function image `i`'s record of `kind` names (its __DATA_CONST, + * __aether_rv section), when it lies inside the image; NULL otherwise. An + * image but the program is held while it is read (a dlopen of the path the + * loader gives for its header, without loading), and read only while that + * header is still an image's. */ +static void* rv_macho_mark(uint32_t i, const struct mach_header* h, uint32_t kind) { + void* held = NULL; + if (i > 0) { + Dl_info di; + if (!dladdr(h, &di) || di.dli_fbase != (const void*)h || !di.dli_fname) return NULL; + held = dlopen(di.dli_fname, RTLD_LAZY | RTLD_NOLOAD); + if (!held) return NULL; + if (!dladdr(h, &di) || di.dli_fbase != (const void*)h) { + dlclose(held); + return NULL; + } + } + void* f = NULL; + unsigned long size = 0; +# if defined(__LP64__) + const uint8_t* sec = getsectiondata((const struct mach_header_64*)(const void*)h, AETHER_RV_SEGMENT, + AETHER_RV_SECTNAME, &size); +# else + const uint8_t* sec = getsectiondata(h, AETHER_RV_SEGMENT, AETHER_RV_SECTNAME, &size); +# endif + const AetherRvMark* mark = sec ? rv_mark_in(sec, (size_t)size, kind) : NULL; + if (mark) { + void* q = rv_mark_fn(mark); + Dl_info di; + if (dladdr(q, &di) && di.dli_fbase == (const void*)h) f = q; + } + if (held) dlclose(held); + return f; +} +#endif + +#if defined(AETHER_RV_PHDR) && AETHER_RV_ELF +/* The function the first object with a record of `kind` names, read while + * dl_iterate_phdr holds the loader's lock (glibc, musl, FreeBSD; NetBSD's + * lets go around each call, so an object another thread unloads then may be + * read as it goes): its notes, each inside the object's loaded segments, and + * the function inside them too (#3442). The walk ends at the asking copy's + * own object: none loaded after it is read, so none another thread is still + * relocating. */ +typedef struct { uint32_t kind; const void* self; void* fn; } RvWalk; + +static int rv_collect(struct dl_phdr_info* info, size_t size, void* data) { + (void)size; + RvWalk* w = (RvWalk*)data; + uintptr_t lo = UINTPTR_MAX, hi = 0; + for (int k = 0; k < (int)info->dlpi_phnum; k++) { + if (info->dlpi_phdr[k].p_type != PT_LOAD) continue; + uintptr_t a = (uintptr_t)info->dlpi_addr + (uintptr_t)info->dlpi_phdr[k].p_vaddr; + uintptr_t b = a + (uintptr_t)info->dlpi_phdr[k].p_memsz; + if (a < lo) lo = a; + if (b > hi) hi = b; + } + for (int k = 0; k < (int)info->dlpi_phnum; k++) { + if (info->dlpi_phdr[k].p_type != PT_NOTE) continue; + uintptr_t a = (uintptr_t)info->dlpi_addr + (uintptr_t)info->dlpi_phdr[k].p_vaddr; + size_t n = (size_t)info->dlpi_phdr[k].p_memsz; + if (a < lo || a >= hi || n > hi - a) continue; + const AetherRvMark* m = rv_mark_in((const unsigned char*)a, n, w->kind); + if (!m) continue; + uintptr_t f = (uintptr_t)rv_mark_fn(m); + if (f >= lo && f < hi) { + w->fn = (void*)f; + return 1; /* the first: the walk ends */ + } + } + uintptr_t s = (uintptr_t)w->self; + return (w->self && s >= lo && s < hi) ? 1 : 0; /* the asking copy's own: no further */ +} +#endif + +void* aether_rendezvous_find(const char* symbol, uint32_t kind, const AetherRvMark* self) { + void* p = NULL; + (void)symbol; + (void)kind; + (void)self; +#if defined(_WIN32) && AETHER_RV_MARKS + /* The modules in load order, the program first: the first with a + * record, up to the asking copy's own. A snapshot of the list taken + * under the loader's lock (a walk of the list without it fails or skips + * while another thread loads or unloads one), retried while it says the + * list changed under it, with no bound on the modules it holds. */ + HMODULE self_mod = NULL; + if (self) GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + (LPCWSTR)(const void*)self, &self_mod); + HANDLE snap = INVALID_HANDLE_VALUE; + for (int tries = 0; tries < 1000 && snap == INVALID_HANDLE_VALUE; tries++) { + snap = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE, 0); + if (snap == INVALID_HANDLE_VALUE && GetLastError() != ERROR_BAD_LENGTH) break; + } + if (snap == INVALID_HANDLE_VALUE) return NULL; + MODULEENTRY32W me; + me.dwSize = sizeof(me); + for (BOOL ok = Module32FirstW(snap, &me); ok && !p; ok = Module32NextW(snap, &me)) { + p = rv_pe_mark(me.hModule, kind); + if (self_mod && me.hModule == self_mod) break; /* the asking copy's own: no further */ + } + CloseHandle(snap); +#elif defined(__APPLE__) && AETHER_RV_MARKS && defined(RTLD_NOLOAD) + /* The images in load order, the program first: the first with a + * record, up to the asking copy's own. dyld's list is read by index + * without its lock, so an image unloaded meanwhile shifts the rest: the + * walk is made again while the count it read changes under it. */ + Dl_info sd; + const void* self_hdr = (self && dladdr(self, &sd)) ? sd.dli_fbase : NULL; + for (int tries = 0; tries < 8; tries++) { + uint32_t n = _dyld_image_count(); + p = NULL; + for (uint32_t i = 0; i < n && !p; i++) { + const struct mach_header* h = _dyld_get_image_header(i); + if (!h) continue; + p = rv_macho_mark(i, h, kind); + if (self_hdr && (const void*)h == self_hdr) break; /* the asking copy's own */ + } + if (_dyld_image_count() == n) break; + } +#elif defined(AETHER_RV_PHDR) && AETHER_RV_ELF + /* The loaded objects in load order, the program first: the first with a + * record, up to the asking copy's own. */ + RvWalk w = { kind, self, NULL }; + dl_iterate_phdr(rv_collect, &w); + p = w.fn; +#elif (defined(__unix__) || defined(__APPLE__)) && defined(RTLD_DEFAULT) + /* The global scope in load order. */ + p = dlsym(RTLD_DEFAULT, symbol); +#endif + return p; +} + /* Libc-compatible wrappers: the returned pointer has no hidden * header, so libc free() is safe (loses cap accounting but no * heap corruption). For full cap accounting, callers free with * aether_caps_free(p, bytes) passing the original size. */ -void* aether_caps_malloc(size_t bytes) { - if (!aether_caps_check_alloc(bytes)) return NULL; - void* p = malloc(bytes); +/* What every allocation goes through when something is to be asked: the + * test hook, the process cap, the account in use. */ +static void* caps_alloc_slow(size_t bytes, int zeroed, size_t n, size_t size) { + unsigned slow = atomic_load_explicit(&aether_caps_slow, memory_order_acquire); + if ((slow & CAPS_SLOW_TEST) && aether_alloc_test_refuses()) { + aether_alloc_failed(AETHER_STOP_OUT_OF_MEMORY); + return NULL; + } + if (!aether_caps_check_alloc(bytes)) { + aether_alloc_failed(AETHER_STOP_MEMORY_CAP); + return NULL; + } + void* p = zeroed ? calloc(n, size) : malloc(bytes); if (!p) { aether_caps_account_free(bytes); + aether_alloc_failed(AETHER_STOP_OUT_OF_MEMORY); + return NULL; + } + if ((slow & CAPS_SLOW_ACCOUNTS) && !accounts()->alloc(p, bytes)) { + free(p); + aether_caps_account_free(bytes); + aether_alloc_failed(AETHER_STOP_MEMORY_CAP); return NULL; } return p; } +void* aether_caps_malloc(size_t bytes) { + if (__builtin_expect( + (atomic_load_explicit(&aether_caps_slow, memory_order_relaxed) & AETHER_CAPS_ALLOC_BITS) == 0, 1)) { + void* p = malloc(bytes); + if (!p) { + aether_alloc_failed(AETHER_STOP_OUT_OF_MEMORY); + return NULL; + } + atomic_fetch_add_explicit(&g_mem_used, (uint64_t)bytes, memory_order_relaxed); + return p; + } + return caps_alloc_slow(bytes, 0, 0, 0); +} + void* aether_caps_calloc(size_t n, size_t size) { - if (size != 0 && n > (size_t)-1 / size) return NULL; - size_t bytes = n * size; - if (!aether_caps_check_alloc(bytes)) return NULL; - void* p = calloc(n, size); - if (!p) { - aether_caps_account_free(bytes); + if (size != 0 && n > (size_t)-1 / size) { + aether_alloc_failed(AETHER_STOP_OUT_OF_MEMORY); return NULL; } - return p; + size_t bytes = n * size; + if (__builtin_expect( + (atomic_load_explicit(&aether_caps_slow, memory_order_relaxed) & AETHER_CAPS_ALLOC_BITS) == 0, 1)) { + void* p = calloc(n, size); + if (!p) { + aether_alloc_failed(AETHER_STOP_OUT_OF_MEMORY); + return NULL; + } + atomic_fetch_add_explicit(&g_mem_used, (uint64_t)bytes, memory_order_relaxed); + return p; + } + return caps_alloc_slow(bytes, 1, n, size); } void* aether_caps_realloc(void* p, size_t old_bytes, size_t new_bytes) { + unsigned slow = atomic_load_explicit(&aether_caps_slow, memory_order_acquire); + if ((slow & CAPS_SLOW_TEST) && new_bytes > old_bytes && aether_alloc_test_refuses()) { + aether_alloc_failed(AETHER_STOP_OUT_OF_MEMORY); + return NULL; + } if (new_bytes > old_bytes) { size_t delta = new_bytes - old_bytes; - if (!aether_caps_check_alloc(delta)) return NULL; + if (!aether_caps_check_alloc(delta)) { + aether_alloc_failed(AETHER_STOP_MEMORY_CAP); + return NULL; + } } + /* The accounts' view: the old block's bytes back to the account charged + * for them, the new block's charged to the account in use, before the + * realloc, so a refusal leaves the block as it was. */ + AetherExecAccount* old_owner = NULL; + AetherExecAccount* now_in_use = NULL; + int accounted = (slow & CAPS_SLOW_ACCOUNTS) != 0; + if (accounted && !accounts()->realloc_begin(p, old_bytes, new_bytes, &old_owner, &now_in_use)) { + if (new_bytes > old_bytes) aether_caps_account_free(new_bytes - old_bytes); + aether_alloc_failed(AETHER_STOP_MEMORY_CAP); + return NULL; + } + /* The old address, as a key only: the accounts read it again when the + * realloc failed and the old block is still the one they charged. */ + uintptr_t old_key = (uintptr_t)p; void* np = realloc(p, new_bytes); + if (accounted) + accounts()->realloc_end((void*)old_key, new_bytes ? np : NULL, old_bytes, new_bytes, old_owner, + now_in_use); if (!np) { if (new_bytes > old_bytes) { aether_caps_account_free(new_bytes - old_bytes); } + if (new_bytes) aether_alloc_failed(AETHER_STOP_OUT_OF_MEMORY); return NULL; } if (new_bytes < old_bytes) { @@ -174,6 +1533,48 @@ void* aether_caps_realloc(void* p, size_t old_bytes, size_t new_bytes) { void aether_caps_free(void* p, size_t bytes) { if (!p) return; + if (atomic_load_explicit(&aether_caps_slow, memory_order_relaxed) & CAPS_SLOW_ACCOUNTS) + accounts()->free_block(p, bytes); free(p); aether_caps_account_free(bytes); } + +/* ------------------------------------------------------------------ + * Stop checks of an execution (#2956, runtime/aether_exec.h): its + * deadline, or a cancel from another thread. Here, with the deadline they + * read, so a C-backed std operation can ask without linking the execution + * frames (the compiler links the std objects and this file). + * ------------------------------------------------------------------ */ + + +int aether_exec_stop_reason(void) { + AetherFrames* F = aether_frames_here(); + if (aether_caps_deadline_tripped()) return AETHER_STOP_DEADLINE; + AetherCancelCell* c = F->cell; + if (!c) return 0; + uint64_t s = atomic_load_explicit(&c->pending, memory_order_acquire); + if (!s) return 0; + /* The cancelled execution, or one nested inside it: the innermost goes + * first; the cancelled one at its own next safe point, once the host + * code between them has returned. One an actor step runs inside is not + * this code's to stop, and is not spent either: it waits (#3392). */ + for (int i = F->exec_depth - 1; i >= 0; i--) + if (F->exec[i].serial == s) return i >= F->exec_floor ? AETHER_STOP_CANCELLED : 0; + /* A cancel for an execution that already ended: spent. One that has not + * begun yet waits for it. */ + if (s <= F->serial) + atomic_compare_exchange_strong_explicit(&c->pending, &s, 0, memory_order_acq_rel, + memory_order_relaxed); + return 0; +} + +int aether_exec_should_stop(void) { + if (!atomic_load_explicit(&aether_caps_armed, memory_order_relaxed)) return 0; + AetherFrames* F = aether_frames_here(); + if (F->exec_depth <= F->exec_floor) return 0; + /* Already stopping (an allocation failed, or an earlier check): stop. */ + if (F->exec[F->exec_depth - 1].stop) return 1; + int why = aether_exec_stop_reason(); + if (why) F->exec[F->exec_depth - 1].stop = why; + return why != 0; +} diff --git a/runtime/aether_resource_caps.h b/runtime/aether_resource_caps.h index 50d8a05bb..595d7717d 100644 --- a/runtime/aether_resource_caps.h +++ b/runtime/aether_resource_caps.h @@ -35,6 +35,7 @@ #include #include +#include #ifdef __cplusplus extern "C" { @@ -67,8 +68,9 @@ void aether_caps_set_deadline_ms(int64_t ms); * --emit=lib is active. Returns 1 if the deadline has passed OR * the sticky-tripped flag is set; 0 otherwise. Direct TLS read + * atomic load + monotonic clock — no PLT call, no allocation. */ -/* Nonzero once any deadline has been armed or tripped (see the .c). */ -extern int aether_caps_armed; +/* Nonzero once any deadline has been armed or tripped, or a call cancelled + * (see the .c). Read it with a relaxed atomic load. */ +extern _Atomic int aether_caps_armed; int aether_caps_deadline_tripped(void); /* Set the sticky-tripped flag; called by codegen-emitted code on @@ -79,6 +81,170 @@ int aether_caps_deadline_tripped(void); * deadline (which clears the flag). */ void __aether_abort_call(void); +/* The deadline in force on this thread, as an execution frame keeps it + * (runtime/aether_exec.c, #2955): a nested execution gets its own and the + * outer one's is put back when it returns. */ +typedef struct { + int64_t at_ns; /* 0: none */ + int tripped; /* the sticky flag */ +} AetherDeadlineState; + +void aether_caps_deadline_save(AetherDeadlineState* out); +void aether_caps_deadline_restore(const AetherDeadlineState* s); +/* The state aether_caps_set_deadline_ms(ms) would make, without making it. */ +AetherDeadlineState aether_caps_deadline_for_ms(int64_t ms); +/* The sticky flag alone: whether a tripwire saw the deadline pass (or + * __aether_abort_call ran), without reading the clock. */ +int aether_caps_deadline_flag(void); + +/* Why the innermost execution should stop (runtime/aether_exec.h): its + * deadline passed (AETHER_STOP_DEADLINE, the outcome AETHER_DEADLINE) or + * it was cancelled (AETHER_STOP_CANCELLED, AETHER_CANCELLED); 0 for + * neither. For callers that found aether_caps_armed set. */ +#define AETHER_STOP_DEADLINE 2 +#define AETHER_STOP_CANCELLED 3 +#define AETHER_STOP_MEMORY_CAP 4 /* the outcome AETHER_MEMORY_CAP */ +#define AETHER_STOP_OUT_OF_MEMORY 5 /* the outcome AETHER_OUT_OF_MEMORY */ +int aether_exec_stop_reason(void); + +/* An allocation its caller survives failed (it returns NULL, or an error), + * for the reason `why` (AETHER_STOP_MEMORY_CAP or AETHER_STOP_OUT_OF_MEMORY). + * Inside an execution the execution ends with that outcome at its next safe + * point or its return (#2956); outside one nothing changes. The cap-aware + * allocators below call it; so does any runtime allocation that reports + * its failure instead of stopping the process. Allocates nothing. */ +void aether_alloc_failed(int why); + +/* ------------------------------------------------------------------ + * Execution accounts (#2954): budgets a host owns, for executions that + * outlive one call. libaether.h has the public half; this is the runtime's. + * ------------------------------------------------------------------ */ +#ifndef AETHER_EXEC_ACCOUNT_DEFINED +#define AETHER_EXEC_ACCOUNT_DEFINED +typedef struct AetherExecAccount AetherExecAccount; +typedef struct { + uint64_t memory_bytes; /* the live bytes it may hold; 0: no limit of its own */ + int64_t deadline_ms; /* 0: no deadline of its own */ + int deadline_counts; /* AETHER_DEADLINE_ELAPSED or AETHER_DEADLINE_ACTIVE */ + AetherExecAccount* parent; /* NULL, or the account its charges also go to */ +} AetherExecLimits; +#define AETHER_DEADLINE_ELAPSED 0 /* wall time since the account was made */ +#define AETHER_DEADLINE_ACTIVE 1 /* time spent in executions under it */ +#endif + +struct AetherExecAccount { + _Atomic uint64_t used; /* live bytes charged to it (its children's too) */ + uint64_t memory_bytes; + int64_t deadline_at_ns; /* ELAPSED: when it passes; 0: none */ + int64_t active_budget_ns; /* ACTIVE: what it may spend; 0: none */ + _Atomic int64_t active_spent_ns; + int deadline_counts; + _Atomic int tripped; /* AETHER_ACCOUNT_TRIPPED_* bits */ + AetherExecAccount* parent; + _Atomic uintptr_t thread; /* the thread it is in use on, 0 for none */ + const void* owner; /* the accounts that made it: only they take it */ + AetherExecAccount* next_live; /* every live account, for freeing one */ + /* What still names it (#3407), under the accounts' lock: the live list + * until the host frees it, the thread it is in use on, and each + * execution that began under it. The block goes with the last. */ + int refs; + _Atomic int dead; /* the host freed it: charged, used and linked no more */ +}; +#ifndef AETHER_ACCOUNT_TRIPPED_DEADLINE +#define AETHER_ACCOUNT_TRIPPED_DEADLINE 1 +#define AETHER_ACCOUNT_TRIPPED_MEMORY 2 +#endif + +/* The public half (libaether.h). */ +AetherExecAccount* aether_exec_account_new(const AetherExecLimits* limits); +void aether_exec_account_free(AetherExecAccount* account); +int aether_exec_use(AetherExecAccount* account, AetherExecAccount** previous); +uint64_t aether_exec_account_used(const AetherExecAccount* account); +int aether_exec_account_tripped(const AetherExecAccount* account); + +/* std.host's: an account from scalars. */ +AetherExecAccount* aether_exec_account_make(uint64_t memory_bytes, int64_t deadline_ms, int counts, + AetherExecAccount* parent); + +/* The allocation fast path's word (aether_resource_caps.c): 0 when nothing + * is to be asked; AETHER_CAPS_SLOW_ACCOUNTS while an account exists in the + * process. Scalars, read with relaxed loads by the execution frames. */ +extern _Atomic unsigned aether_caps_slow; +#define AETHER_CAPS_SLOW_ACCOUNTS 2u +/* The same word says what an actor step must put aside + * (aether_step_barrier.h), so a plain step reads this one word and takes one + * predicted branch: the accounts bit above; AETHER_CAPS_SLOW_SANDBOX, set for + * good once a sandbox level may be in force where this copy's steps can see + * it: the first thread with a level in this copy's count, or this copy + * sharing another copy's count (aether_sandbox.c; a mere check of a getenv or + * a file read sets nothing, #3419); and, from bit 8 up, the live restart + * claims and spawn captures (aether_supervision.c counts them here, + * AETHER_CAPS_SPAWN_ONE each). The + * allocation fast path asks about its own bits only (AETHER_CAPS_ALLOC_BITS: + * the cap, the accounts, the test hook). */ +#define AETHER_CAPS_SLOW_SANDBOX 8u +#define AETHER_CAPS_SPAWN_ONE (1u << 8) +#define AETHER_CAPS_SPAWN_MASK (~0xFFu) +#define AETHER_CAPS_ALLOC_BITS 7u +#define AETHER_CAPS_STEP_BITS (AETHER_CAPS_SLOW_ACCOUNTS | AETHER_CAPS_SLOW_SANDBOX | AETHER_CAPS_SPAWN_MASK) +/* Some restart claim or spawn capture is live, on any thread. A claim + * counts itself (seq_cst) before it reads its child's inflight count; a + * sender that counted its message in flight (seq_cst) and then finds none + * here cannot race a claim, which will see the message and wait for it. */ +static inline int aether_spawn_watched(void) { + return (atomic_load_explicit(&aether_caps_slow, memory_order_relaxed) & AETHER_CAPS_SPAWN_MASK) != 0; +} +/* Set once this copy has found the accounts it shares (the rendezvous). */ +extern _Atomic int aether_accounts_found; +void aether_accounts_register(void); + +/* The account in use on this thread (aether_exec_use), NULL for none. */ +AetherExecAccount* aether_account_here(void); +/* An execution begins: the account in use, held for it until it ends, so a + * free meanwhile leaves the execution naming no freed block (#3407). */ +AetherExecAccount* aether_account_hold(void); +/* An execution ends: the account in use when it began (the one held) is in + * use again, unless the host freed it meanwhile, and is let go. */ +void aether_account_restore(AetherExecAccount* account); +/* An actor step begins and ends (aether_step_barrier.h): the account in use + * is put aside for the step, still this thread's, and is in use again after + * it; one the step left in use is let go. */ +AetherExecAccount* aether_account_step_aside(void); +void aether_account_step_back(AetherExecAccount* was); +/* An execution is entered now under `a` (held for it): `*at` the deadline + * in force for it, the earliest of `a`'s and its ancestors', as absolute + * monotonic ns (0: none); `*tripped` is set when one already passed. The + * ACTIVE accounts on the chain no execution open on this thread times yet + * are timed from now (#3409); `*timed` says how many, for its end. -1 when + * one could not be timed for want of memory: the execution must then end + * at once (AETHER_OUT_OF_MEMORY), never run with a budget unenforced. */ +int aether_account_exec_begin(AetherExecAccount* a, int64_t* at, int* tripped, int* timed); +/* That execution ends, its deadline tripped or not: the accounts it began + * timing are charged the time spent since (once, however executions nest), + * and the trip is recorded on `a` and its ancestors. */ +void aether_account_exec_end(AetherExecAccount* a, int timed, int tripped); +/* Monotonic ns, the clock the deadlines use. */ +int64_t aether_caps_now_ns(void); + +/* Test hook (runtime C tests and C hosts of tests only; generated code never + * arms it): the n-th checked allocation from now on this thread fails as if + * the allocator were out of memory, once; 0 turns it off. The checked ones + * are the cap-aware allocators, a send's payload copy, every allocation + * the generated code makes (_aether_alloc and _aether_alloc_or_null), and + * the growth of a thread's list of timed ACTIVE accounts (#3409). */ +void aether_test_fail_alloc_after(int n); +/* Whether the hook refuses this allocation (it counts it); read the flag + * first, as the generated code does: nonzero once a test armed the hook. */ +extern _Atomic int aether_alloc_test_armed; +int aether_alloc_test_refuses(void); + +/* For a C-backed std operation that runs long on its input: 1 when the + * running execution should stop, so the operation returns early, cleanly + * (an error, its own memory freed); the next safe point, or the export's + * return, ends the execution with the outcome. One load while nothing is + * armed; 0 outside any execution. */ +int aether_exec_should_stop(void); + /* Snapshot of currently-accounted bytes: what the cap-aware allocators * above (collections, arenas, pools, the actor registry) hold, and only * those. heap.new, strings and closure environments are plain mallocs the diff --git a/runtime/aether_runtime.c b/runtime/aether_runtime.c index 55688c1ea..6926d7318 100644 --- a/runtime/aether_runtime.c +++ b/runtime/aether_runtime.c @@ -139,7 +139,9 @@ void aether_runtime_init(int num_cores, int flags) { // Set defaults based on CPU capabilities g_runtime_config.num_cores = num_cores > 0 ? num_cores : cpu_recommend_cores(); - g_runtime_config.flags = flags; + /* AETHER_FLAG_LOCKFREE_POOLS is accepted and ignored (#3374): there is + * no message pool, so the flag is not kept and has_feature says 0. */ + g_runtime_config.flags = flags & ~AETHER_FLAG_LOCKFREE_POOLS; // Auto-enable features based on CPU if AUTO flag set if (flags & AETHER_FLAG_AUTO_DETECT) { @@ -151,7 +153,6 @@ void aether_runtime_init(int num_cores, int flags) { } // Always prefer lock-free on modern CPUs g_runtime_config.flags |= AETHER_FLAG_LOCKFREE_MAILBOX; - g_runtime_config.flags |= AETHER_FLAG_LOCKFREE_POOLS; } // Apply explicit overrides @@ -159,10 +160,8 @@ void aether_runtime_init(int num_cores, int flags) { (flags & AETHER_FLAG_LOCKFREE_MAILBOX) || (flags & AETHER_FLAG_AUTO_DETECT && cpu->sse42_supported); - g_runtime_config.use_lockfree_pools = - (flags & AETHER_FLAG_LOCKFREE_POOLS) || - (flags & AETHER_FLAG_AUTO_DETECT); - + g_runtime_config.use_lockfree_pools = 0; /* deprecated (#3374) */ + g_runtime_config.use_mwait = (flags & AETHER_FLAG_ENABLE_MWAIT) || (flags & AETHER_FLAG_AUTO_DETECT && cpu->mwait_supported); @@ -210,7 +209,6 @@ void aether_runtime_print_config(void) { printf("Active Optimizations:\n"); printf(" Lock-free mailbox: %s\n", config->use_lockfree_mailbox ? "ENABLED" : "disabled"); - printf(" Lock-free pools: %s\n", config->use_lockfree_pools ? "ENABLED" : "disabled"); printf(" MWAIT idle: %s", config->use_mwait ? "ENABLED" : "disabled"); if (config->use_mwait && !cpu->mwait_supported) { printf(" (WARNING: CPU doesn't support MWAIT!)"); diff --git a/runtime/aether_runtime.h b/runtime/aether_runtime.h index 521db2448..406fa8760 100644 --- a/runtime/aether_runtime.h +++ b/runtime/aether_runtime.h @@ -13,7 +13,11 @@ // Runtime configuration flags #define AETHER_FLAG_AUTO_DETECT (1 << 0) // Auto-detect CPU features (recommended) #define AETHER_FLAG_LOCKFREE_MAILBOX (1 << 1) // Use lock-free SPSC mailboxes -#define AETHER_FLAG_LOCKFREE_POOLS (1 << 2) // Use lock-free TLS message pools +// Deprecated (#3374): accepted and ignored. There is no message pool; the +// flag stays defined so code that passes it still builds, and its bit is +// never given another meaning. aether_runtime_has_feature() reports it off. +// Removed at host ABI level 2 (docs/abi-policy.md, "Deprecated names"). +#define AETHER_FLAG_LOCKFREE_POOLS (1 << 2) #define AETHER_FLAG_ENABLE_SIMD (1 << 3) // Enable AVX2 vectorization #define AETHER_FLAG_ENABLE_MWAIT (1 << 4) // Enable MWAIT for idle #define AETHER_FLAG_VERBOSE (1 << 5) // Print configuration on init @@ -23,7 +27,7 @@ typedef struct { int num_cores; int flags; int use_lockfree_mailbox; - int use_lockfree_pools; + int use_lockfree_pools; // deprecated (#3374): always 0, kept so the fields after it stay put int use_mwait; int use_simd; } AetherRuntimeInitConfig; diff --git a/runtime/aether_rv_mark.h b/runtime/aether_rv_mark.h new file mode 100644 index 000000000..b34101bfa --- /dev/null +++ b/runtime/aether_rv_mark.h @@ -0,0 +1,135 @@ +#ifndef AETHER_RV_MARK_H +#define AETHER_RV_MARK_H + +/* A runtime copy's mark in its own image (#3445). + * + * The sandbox levels and the execution accounts are one per process: they + * live in one runtime copy, which every copy finds the same way, the first + * loaded image, in load order, that carries a record of them + * (aether_rendezvous_find, aether_resource_caps.c). The copies once looked + * for an export instead, which a Windows executable never has, nor one + * linked without -rdynamic on Linux: a program's own copy that looked before + * it loaded a library kept levels and accounts the library's copy never + * found, and code of the library that the program called inside a deny-all + * block was allowed everything. + * + * The record is laid out as an ELF note on every format: the 12-byte header, + * the name "AetherRV" (9 bytes, so what follows starts at 24 whether notes + * are aligned to 4 or to 8), and what names the copy's rendezvous function, + * 32 bytes in all, a multiple of 8, so the note after it is found either way: + * + * - ELF: a note in a read-only section aligned to 8, emitted by assembly, + * whose 8-byte desc is the function's offset from the desc (then 4 zero + * bytes), fixed when the image is linked (the function is hidden, so + * nothing relocates it at load and nothing interposes it). A loaded + * object shows its notes (PT_NOTE). + * - PE: a .aerv section holding the function's address, read from the + * section table the loader maps with the module. + * - Mach-O: a __DATA_CONST,__aether_rv section holding the address. + * + * The record never changes: a copy of any release reads any other's. */ + +#include +#include "utils/aether_compiler.h" + +#define AETHER_RV_KIND_SANDBOX 1 /* the sandbox levels */ +#define AETHER_RV_KIND_ACCOUNTS 2 /* the execution accounts */ + +#define AETHER_RV_NAME "AetherRV" + +/* Any function pointer type converts to this one and back. */ +typedef void (*aether_rv_fn)(void); + +/* Where the images are read in load order: Windows (MinGW), macOS, and the + * ELF systems with dl_iterate_phdr. Not Cygwin (its lookup is the global + * scope's), wasm or a bare-metal build, which load no second copy. */ +#if AETHER_GCC_COMPAT && !defined(__EMSCRIPTEN__) && !defined(__wasi__) && \ + (defined(_WIN32) || defined(__APPLE__) || defined(__linux__) || defined(__FreeBSD__) || \ + defined(__NetBSD__) || defined(__OpenBSD__)) +# define AETHER_RV_MARKS 1 +# if defined(_WIN32) || defined(__APPLE__) +# define AETHER_RV_ELF 0 +# else +# define AETHER_RV_ELF 1 +# endif +#else +# define AETHER_RV_MARKS 0 +# define AETHER_RV_ELF 0 +#endif + +typedef struct AetherRvMark { + uint32_t namesz; /* sizeof AETHER_RV_NAME */ + uint32_t descsz; /* sizeof the field below */ + uint32_t kind; /* AETHER_RV_KIND_* */ + char name[12]; /* AETHER_RV_NAME, padded to 4 */ +#if AETHER_RV_ELF + int32_t fn_offset; /* the function's address less this field's */ + uint32_t reserved; /* 0: the desc is 8 bytes, for notes aligned to 8 */ +#else + aether_rv_fn fn; /* the function's address */ +#endif +} AetherRvMark; + +#define AETHER_RV_STR2(x) #x +#define AETHER_RV_STR(x) AETHER_RV_STR2(x) + +/* A rendezvous function a record names: the copy's own (hidden) where + * records are read, so ELF's offset is fixed at link time and nothing + * interposes it; exported where the copies look each other up by name in + * the global scope instead (Cygwin, and the systems not listed above). */ +#if AETHER_RV_MARKS +# define AETHER_RV_FN __attribute__((used)) AETHER_COPY_LOCAL +#elif AETHER_GCC_COMPAT +# define AETHER_RV_FN __attribute__((used, visibility("default"))) +#else +# define AETHER_RV_FN +#endif + +/* AETHER_RV_MARK(anchor, kind, fn): this image's record of `kind` naming + * `fn`, a function of this image (hidden on ELF, AETHER_COPY_LOCAL, so its + * offset is fixed at link time), as the hidden `anchor`. The finder is given + * the anchor: it knows its own image by it, and the reference keeps the + * record where a link drops what nothing reaches (--gc-sections). */ +#if AETHER_RV_MARKS && AETHER_RV_ELF +# define AETHER_RV_MARK(anchor, kind, fn) \ + __asm__(".pushsection .note.aether.rv,\"a\",%note\n" \ + "\t.balign 8\n" \ + "\t.globl " #anchor "\n" \ + "\t.hidden " #anchor "\n" \ + #anchor ":\n" \ + "\t.long 9, 8, " AETHER_RV_STR(kind) "\n" \ + "\t.asciz \"" AETHER_RV_NAME "\"\n" \ + "\t.balign 4\n" \ + "\t.long " #fn " - .\n" \ + "\t.long 0\n" \ + "\t.popsection\n"); \ + extern __attribute__((visibility("hidden"))) const AetherRvMark anchor +#elif AETHER_RV_MARKS +# if defined(_WIN32) +# define AETHER_RV_SECTION ".aerv" /* 8 bytes at most */ +# else +# define AETHER_RV_SEGMENT "__DATA_CONST" +# define AETHER_RV_SECTNAME "__aether_rv" +# define AETHER_RV_SECTION AETHER_RV_SEGMENT "," AETHER_RV_SECTNAME +# endif +# if defined(__clang__) +# define AETHER_RV_NO_ASAN __attribute__((no_sanitize("address"))) /* no redzone between records */ +# else +# define AETHER_RV_NO_ASAN +# endif +# define AETHER_RV_MARK(anchor, kind, fn) \ + AETHER_RV_NO_ASAN static const AetherRvMark anchor \ + __attribute__((section(AETHER_RV_SECTION), used, aligned(sizeof(void*)))) = { \ + (uint32_t)sizeof AETHER_RV_NAME, (uint32_t)sizeof(aether_rv_fn), (kind), \ + AETHER_RV_NAME, (aether_rv_fn)(fn) } +#endif + +/* The rendezvous function of the copy that owns `kind`: the first loaded + * image, in load order, with a record of it; NULL when none has one. `self` + * is this copy's record (NULL where there are none). Where the images are + * not read (Cygwin), the global scope's `symbol`. Each copy's own: the + * copies may be of different releases. */ +AETHER_COPY_LOCAL void* aether_rendezvous_find(const char* symbol, uint32_t kind, + const AetherRvMark* self); + +#endif /* AETHER_RV_MARK_H */ diff --git a/runtime/aether_sandbox.c b/runtime/aether_sandbox.c index c06c20c7f..4e1c29aca 100644 --- a/runtime/aether_sandbox.c +++ b/runtime/aether_sandbox.c @@ -1,24 +1,507 @@ -// aether_sandbox.c — Global sandbox state +// aether_sandbox.c: the grant levels each thread is inside, and the check. +// +// The levels used to live in the generated code of each program and +// library (a stack per translation unit), with one process-wide pointer to +// the checker an enforce installed and cleared (#2955). A thread leaving +// its enforce cleared the pointer while another thread was inside one, so +// that thread ran unchecked, or called the pointer after it was cleared and +// crashed (D1); and a library's export called from inside another's +// enforce checked against its own, empty stack, or replaced the outer +// checker and then cleared it (D2). Now each thread's levels are here, +// once per runtime image, and nothing is installed: the check reads the +// calling thread's levels, behind a count of threads that have any. + +#if !defined(_WIN32) && !defined(_GNU_SOURCE) +# define _GNU_SOURCE /* RTLD_DEFAULT in glibc's */ +#endif #include "aether_sandbox.h" #ifdef AETHER_HAS_SANDBOX +#include +#include #include #include #include "aether_sandbox_path.h" +#include "actors/aether_panic.h" +#include "aether_resource_caps.h" +#include "aether_rv_mark.h" + +#if defined(_WIN32) +# ifndef WIN32_LEAN_AND_MEAN +# define WIN32_LEAN_AND_MEAN +# endif +# include +#elif defined(__unix__) || defined(__APPLE__) +# include +# if defined(__APPLE__) +# include +# elif defined(__linux__) || defined(__FreeBSD__) || defined(__NetBSD__) || defined(__OpenBSD__) +# include +# define AETHER_SANDBOX_PHDR 1 +# endif +#endif + +/* A grant list is a std.list of (category, pattern) pairs, each element a + * plain C string or an Aether string (a helper that keeps its `string` + * parameter stores its own copy). */ +extern int list_size(void*); +extern void* list_get_raw(void*, int); +extern const char* aether_string_data(const void*); +extern void aether_sandbox_audit(const char* category, const char* resource, int allowed); + +// --------------------------------------------------------------------------- +// Runtime images that share a thread's levels (#2955, D2) +// --------------------------------------------------------------------------- +// +// A library built without --shared-runtime carries its own copy of the +// runtime, and on macOS and Windows that copy stays apart from the +// program's: its levels are its own. Called from inside another copy's +// enforce block (through a host service), it cannot read that block's +// grants, so it must not allow what they would refuse. Every copy counts +// the levels each thread has in all of them in one place, the rendezvous of +// the first copy it finds in the process (the earliest loaded with a record +// of them, aether_rv_mark.h), so a copy sees when this thread is inside +// levels it cannot read, and refuses (aether_sandbox_check_slow). On one +// runtime image all of this +// is one copy, and the count is its own. + +/* The rendezvous's layout (#2957): its version and its size come first, so + * a copy reads nothing else of one laid out otherwise. Version 1 had no + * size. */ +#define AETHER_SANDBOX_RV_VERSION 2 + +typedef struct AetherSandboxRendezvous { + uint32_t version; + uint32_t size; // sizeof(AetherSandboxRendezvous) + atomic_int* threads; // threads with a level in force, in any copy + int (*levels)(int delta); // this thread's levels in every copy: adds delta, returns the total +} AetherSandboxRendezvous; + +static atomic_int g_threads; +static AETHER_TLS_SHARED int t_total; + +/* A step of this copy looks at the levels' count from now on (the step bit + * of its one word, aether_step_barrier.h): levels may be in force where its + * steps can see them (#3419). Set for good, and only then: a program that + * checks a getenv or a file read but enforces nothing keeps its plain + * steps. */ +static void sandbox_levels_may_exist(void) { + if (!(atomic_load_explicit(&aether_caps_slow, memory_order_relaxed) & AETHER_CAPS_SLOW_SANDBOX)) + atomic_fetch_or_explicit(&aether_caps_slow, AETHER_CAPS_SLOW_SANDBOX, memory_order_release); +} + +static const struct AetherSandboxRendezvous* rv(void); + +static int rv_levels(int delta) { + int before = t_total; + int after = before + delta; + if (after < 0) after = 0; + t_total = after; + /* Read only by this thread's own checks: another thread seeing a stale + * count takes the slow path and finds nothing of its own, or skips a + * level that is not its own. */ + if (before == 0 && after > 0) { + if (atomic_fetch_add_explicit(&g_threads, 1, memory_order_relaxed) == 0) { + /* No thread had a level: from now on this copy's steps look. Its + * count and hooks are published first, for a level another copy + * pushed through this one's count (this copy may have looked up + * nothing yet). */ + (void)rv(); + sandbox_levels_may_exist(); + } + } else if (before > 0 && after == 0) { + atomic_fetch_sub_explicit(&g_threads, 1, memory_order_relaxed); + } + return after; +} + +static const AetherSandboxRendezvous g_own_rv = { + AETHER_SANDBOX_RV_VERSION, (uint32_t)sizeof(AetherSandboxRendezvous), &g_threads, rv_levels +}; + +/* This copy's, which its record names (aether_rv_mark.h, #3445): the other + * copies find it by the record where records are read, and by name where + * not (AETHER_RV_FN). */ +AETHER_RV_FN const AetherSandboxRendezvous* aether_sandbox_rendezvous(void) { + return &g_own_rv; +} + +#if AETHER_RV_MARKS +AETHER_RV_MARK(g_rv_mark, AETHER_RV_KIND_SANDBOX, aether_sandbox_rendezvous); +# define RV_SELF (&g_rv_mark) +#else +# define RV_SELF NULL +#endif + +static const AetherSandboxRendezvous* _Atomic g_rv = NULL; +/* aether_sandbox_in_force is aether_panic.c's, where the step barrier reads + * it without linking this file (#3393). */ + +/* This copy's hooks (aether_panic.h's AetherSandboxHooks, #3438): how a + * panic ends the levels pushed inside the frame it leaves (#2955), and a + * step's part (aether_step_barrier.h, #3393), each on this copy's own + * levels, reached through no exported name another copy could interpose. + * Registered before any level of this copy can exist (every push looks for + * the copies first), so that a program that never enforces does not link + * this file for its panics or its steps. */ +static void sandbox_unwind_hook(int frames); +static int sandbox_step_hide(int saved[2]); +static void sandbox_step_show(const int saved[2]); +static const AetherSandboxHooks g_hooks = { sandbox_unwind_hook, sandbox_step_hide, sandbox_step_show }; + +/* Set while this copy's hooks found no place (more than + * AETHER_SANDBOX_HOOKS_MAX copies share one panic): a panic does not unwind + * its levels nor a step put them aside, and a trusted call's barrier a + * panic left would let the rest of a block skip that block's grants, so + * while this thread has a level its checks refuse (decide). Each push tries + * again, as an unloaded copy frees a place. */ +static _Atomic int g_hooks_unserved = 0; + +static void sandbox_register_hooks(void) { + static _Atomic int said = 0; + int full = aether_sandbox_hooks_add(&g_hooks) != 0; + atomic_store_explicit(&g_hooks_unserved, full, memory_order_release); + if (full && atomic_exchange_explicit(&said, 1, memory_order_relaxed) == 0) + fprintf(stderr, + "aether: sandbox: more than %d runtime copies with a sandbox share one panic: " + "this copy refuses every check inside its enforce blocks until one leaves; " + "build the libraries with --shared-runtime\n", + AETHER_SANDBOX_HOOKS_MAX); +} + +typedef const AetherSandboxRendezvous* (*rendezvous_fn)(void); + +#define RV_SYMBOL "aether_sandbox_rendezvous" + +/* The first loaded image with a record of the levels (aether_rendezvous_find, + * in aether_resource_caps.c, which the execution accounts share). */ +static const AetherSandboxRendezvous* find_rendezvous(void) { + void* p = aether_rendezvous_find(RV_SYMBOL, AETHER_RV_KIND_SANDBOX, RV_SELF); + if (!p) return NULL; + rendezvous_fn f; + memcpy(&f, &p, sizeof(f)); + return f(); +} + +/* What a step of this copy reads: the count of the threads with a level, + * and the step bit when the count is another copy's, whose levels this + * copy's steps may run inside (a thread in that copy's enforce block that + * steps an actor of this one). */ +static void sandbox_publish(const AetherSandboxRendezvous* r) { + atomic_store_explicit(&aether_sandbox_in_force, r->threads, memory_order_release); + if (r != &g_own_rv) sandbox_levels_may_exist(); +} + +static const AetherSandboxRendezvous* rv(void) { + const AetherSandboxRendezvous* r = atomic_load_explicit(&g_rv, memory_order_acquire); + if (r) return r; + /* Everything a reader of g_rv needs is in place before g_rv is published + * (#3419): a thread that sees g_rv set sees all of it. */ + sandbox_register_hooks(); + const AetherSandboxRendezvous* found = find_rendezvous(); + if (found && found != &g_own_rv && + (found->version != AETHER_SANDBOX_RV_VERSION || + found->size != (uint32_t)sizeof(AetherSandboxRendezvous))) { + /* Another layout (another Aether): only its version and size were + * read. This copy counts its own levels apart, as copies that never + * found each other do, and says so once. */ + fprintf(stderr, + "aether: sandbox: this runtime copy cannot share sandbox levels with the one " + "loaded first (levels version %u, %u bytes, against %u, %u): the levels that " + "copy enforces are not seen here; build the libraries with one Aether " + "version, or with --shared-runtime\n", + (unsigned)AETHER_SANDBOX_RV_VERSION, (unsigned)sizeof(AetherSandboxRendezvous), + (unsigned)found->version, (unsigned)found->size); + found = NULL; + } + if (!found) found = &g_own_rv; + sandbox_publish(found); + const AetherSandboxRendezvous* expected = NULL; + if (!atomic_compare_exchange_strong_explicit(&g_rv, &expected, found, + memory_order_acq_rel, memory_order_acquire)) { + /* Another thread published first, the rendezvous it found: this + * copy's count is that one's. */ + sandbox_publish(expected); + return expected; + } + return found; +} + +/* Before this copy's first step (#3438): a step reads only its own copy's + * word, and the SANDBOX bit was set only when this copy's own count first + * held a level or its own rv() found another copy's. A copy that had looked + * nothing up when another copy's count held levels stepped an actor plainly + * inside them, and the actor's first check found levels it could not read + * and refused. Every step needs this copy's scheduler, so scheduler_init + * joins once. Only another copy's rendezvous of this layout is pinned here: + * this copy's own, none, or another layout is left to the first push or + * check, as before, so a copy loaded before the one that will own the count + * still finds it then, and another layout is reported where it matters. */ +static void sandbox_join_copies(void) { + if (atomic_load_explicit(&g_rv, memory_order_acquire)) return; + const AetherSandboxRendezvous* found = find_rendezvous(); + if (found && found != &g_own_rv && found->version == AETHER_SANDBOX_RV_VERSION && + found->size == (uint32_t)sizeof(AetherSandboxRendezvous)) + (void)rv(); /* pins and publishes it, and sets this copy's step bit */ +} + +extern AETHER_COPY_LOCAL void (*_Atomic aether_sandbox_join)(void); + +#if AETHER_GCC_COMPAT +/* A copy unloaded gives up its place among the hooks a panic and a step + * call, which may be another copy's (#3438). */ +AETHER_DESTRUCTOR static void sandbox_remove_hooks(void) { + aether_sandbox_hooks_remove(&g_hooks); +} + +/* The pointer is this copy's own (AETHER_COPY_LOCAL): under ELF + * interposition a later library's constructor, which runs whatever the + * interposition, reached the first copy's, whose scheduler then joined + * through this copy's rendezvous. The hooks a panic and a step call are a + * list each copy adds its own to (aether_panic.h), not a pointer the last to + * look set (#3438). Set from NULL, once. The runtime is built with GCC or + * clang only (the MSVC check compiles its headers); a compiler without + * constructors would leave the join to the first push or check. */ +AETHER_CONSTRUCTOR static void sandbox_install_join(void) { + void (*none)(void) = NULL; + atomic_compare_exchange_strong_explicit(&aether_sandbox_join, &none, sandbox_join_copies, + memory_order_release, memory_order_relaxed); +} +#endif -// Default: no sandbox checker installed = everything allowed -aether_sandbox_check_fn _aether_sandbox_checker = 0; +// --------------------------------------------------------------------------- +// This thread's levels +// --------------------------------------------------------------------------- -// The checker sees an fs path where it leads (aether_sandbox_path.h). A path +typedef struct { + void* grants; // borrowed from the pusher; NULL allows (or a barrier) + const void* image; // the enforce site's generated code, NULL for any other push + int site; + int skip_to; // a trusted call's barrier: the level it resumes below; -1 otherwise + int frame; // the try frames live at the push (aether_try_depth) +} SandboxLevel; + +typedef struct { + SandboxLevel level[AETHER_SANDBOX_MAX_LEVELS]; + int depth; + /* The levels below are the code's an actor step runs inside (#3393): + * put aside for the step, which is checked against its own alone. */ + int floor; + const void* pending_image; + int pending_site; +} SandboxThread; + +static AETHER_TLS_SHARED SandboxThread t_sbx; + +static void push_level(SandboxThread* t, void* grants, const void* image, int site, int skip_to) { + const AetherSandboxRendezvous* r = rv(); + if (atomic_load_explicit(&g_hooks_unserved, memory_order_acquire)) sandbox_register_hooks(); + if (t->depth >= AETHER_SANDBOX_MAX_LEVELS) { + /* Left out, the level's grants would not apply, and its pop would + * take an outer level off instead (a widening, both ways). */ + aether_panic("sandbox: more than 64 nested levels (enforce blocks, trusted calls, host run_sandboxed calls) on one thread"); + return; + } + SandboxLevel* v = &t->level[t->depth]; + v->grants = grants; + v->image = image; + v->site = site; + v->skip_to = skip_to; + v->frame = aether_try_depth(); + t->depth++; + r->levels(1); +} + +void aether_sandbox_push(void* grants) { + SandboxThread* t = &t_sbx; + const void* image = t->pending_image; + int site = t->pending_site; + t->pending_image = NULL; + t->pending_site = 0; + push_level(t, grants, image, site, -1); +} + +void aether_sandbox_pop(void) { + SandboxThread* t = &t_sbx; + if (t->depth <= t->floor) return; /* none of its own: an enclosing step's are not its to take */ + t->depth--; + rv()->levels(-1); +} + +int aether_sandbox_depth(void) { + return t_sbx.depth; +} + +/* This copy's levels down to `depth`: what the exported restore, this copy's + * unwind and its step's show do, through no name another copy could + * interpose (#3438). */ +static void restore_levels(SandboxThread* t, int depth) { + if (depth < t->floor) depth = t->floor; + if (depth >= t->depth) return; + int left = t->depth - depth; + t->depth = depth; + rv()->levels(-left); +} + +void aether_sandbox_restore(int depth) { + restore_levels(&t_sbx, depth); +} + +void aether_sandbox_tag_site(const void* image, int site) { + SandboxThread* t = &t_sbx; + t->pending_image = image; + t->pending_site = site; +} + +/* A trusted call runs with the authority of the code that wrote the + * enforce: a barrier level whose skip_to names the level that enforce + * pushed, which the check jumps from to below it, so the levels between + * (the block's grants, and any enforce inside it) do not apply. Never + * higher: the site is looked for only among the levels in force (not ones + * an earlier barrier hides), and one found nowhere (a block that escaped + * its enforce, a level a panic left) pushes nothing. A barrier only ever + * adds a level above the ones it hides, so nothing pushed inside reaches + * them. */ +int aether_sandbox_trust_enter(const void* image, int site) { + SandboxThread* t = &t_sbx; + int saved = t->depth; + if (!image) return saved; + for (int l = t->depth - 1; l >= t->floor; l--) { + if (t->level[l].skip_to >= 0) { l = t->level[l].skip_to; continue; } + if (t->level[l].image == image && t->level[l].site == site) { + push_level(t, NULL, NULL, 0, l); + break; + } + } + return saved; +} + +static void sandbox_unwind_hook(int frames) { + SandboxThread* t = &t_sbx; + t->pending_image = NULL; + t->pending_site = 0; + int d = t->depth; + while (d > t->floor && t->level[d - 1].frame >= frames) d--; + restore_levels(t, d); +} + +void aether_sandbox_unwind_to_frame(int frames) { + sandbox_unwind_hook(frames); +} + +/* A step begins (aether_step_barrier.h, #3393): the levels of the code it + * runs inside, in this copy and in every other on this thread, are put + * aside, so the step is checked as its actor is on a thread of its own, + * against nothing but the levels it pushes itself. 0 when the thread had + * none. A step that ends gives them back, its own levels gone first (its + * pops, or its panic's unwind, took them; any left are taken here). */ +static int sandbox_step_hide(int saved[2]) { + SandboxThread* t = &t_sbx; + const AetherSandboxRendezvous* r = rv(); + int total = r->levels(0); + if (total == 0 && t->depth == t->floor) return 0; + saved[0] = t->floor; + saved[1] = total; + t->floor = t->depth; + if (total) r->levels(-total); + return 1; +} + +static void sandbox_step_show(const int saved[2]) { + SandboxThread* t = &t_sbx; + restore_levels(t, t->floor); + t->floor = saved[0]; + const AetherSandboxRendezvous* r = rv(); + int now = r->levels(0); + if (saved[1] != now) r->levels(saved[1] - now); +} + +// --------------------------------------------------------------------------- +// The check +// --------------------------------------------------------------------------- + +static int grants_allow(void* grants, const char* category, const char* resource) { + if (!grants) return 1; + int n = list_size(grants); + if (n == 0) return 0; + for (int i = 0; i + 1 < n; i += 2) { + const void* cat_v = list_get_raw(grants, i); + const void* pat_v = list_get_raw(grants, i + 1); + if (!cat_v || !pat_v) continue; + const char* cat = aether_string_data(cat_v); + const char* pat = aether_string_data(pat_v); + if (!cat || !pat) continue; + if (cat[0] == '*' && pat[0] == '*') return 1; + if (strcmp(cat, category) == 0 && aether_grant_match(category, pat, resource)) return 1; + } + return 0; +} + +static atomic_int g_foreign_reported; + +/* 1 allowed, 0 refused, -1 nothing in force on this thread. */ +static int decide(const char* category, const char* resource) { + SandboxThread* t = &t_sbx; + int total = rv()->levels(0); + if (total == 0 && t->depth == t->floor) return -1; + if (atomic_load_explicit(&g_hooks_unserved, memory_order_acquire)) { + aether_sandbox_audit(category, resource, 0); /* unserved: refused more, never less */ + return 0; + } + if (total > t->depth - t->floor) { + /* Levels of another copy of the runtime are in force on this + * thread, and their grants cannot be read from here. */ + if (!atomic_exchange_explicit(&g_foreign_reported, 1, memory_order_relaxed)) + fprintf(stderr, + "aether: sandbox: this thread is inside an enforce block of another copy of " + "the runtime (a library built without --shared-runtime), whose grants this " + "copy cannot read; refusing %s %s. Build the libraries with --shared-runtime " + "to nest sandboxes across them. (reported once)\n", + category ? category : "", resource ? resource : ""); + aether_sandbox_audit(category, resource, 0); + return 0; + } + /* From the top down, a trusted call's barrier jumping over the levels + * it hides. With none left in force the check is the one an unsandboxed + * program makes: allowed, and not audited. */ + int allowed = 1; + int in_force = 0; + for (int l = t->depth - 1; l >= t->floor; l--) { + if (t->level[l].skip_to >= 0) { l = t->level[l].skip_to; continue; } + in_force = 1; + if (!grants_allow(t->level[l].grants, category, resource)) { allowed = 0; break; } + } + if (!in_force) return 1; + aether_sandbox_audit(category, resource, allowed); + return allowed; +} + +// The check sees an fs path where it leads (aether_sandbox_path.h). A path // that cannot be resolved is refused, not matched as written. int aether_sandbox_check_slow(const char* category, const char* resource) { - if (category && resource && category[0] == 'f' && category[1] == 's' && + const AetherSandboxRendezvous* r = rv(); + if (atomic_load_explicit(r->threads, memory_order_relaxed) == 0) return 1; + if (!category) category = ""; + if (resource && category[0] == 'f' && category[1] == 's' && (category[2] == '\0' || category[2] == '_')) { + if (r->levels(0) == 0 && t_sbx.depth == t_sbx.floor) return 1; char resolved[PATH_MAX]; if (!aether_sandbox_canon_path(resource, resolved, sizeof(resolved))) return 0; - return _aether_sandbox_checker(category, resolved); + return decide(category, resolved) != 0; } - return _aether_sandbox_checker(category, resource); + return decide(category, resource) != 0; +} + +int aether_sandbox_levels_here(void) { + int total = rv()->levels(0); + int own = t_sbx.depth - t_sbx.floor; + return total > own ? total : own; +} + +int aether_sandbox_check_resolved(const char* category, const char* resource) { + const AetherSandboxRendezvous* r = rv(); + if (atomic_load_explicit(r->threads, memory_order_relaxed) == 0) return -1; + return decide(category ? category : "", resource); } // A grant's pattern in the form the checks compare against: an fs pattern's diff --git a/runtime/aether_sandbox.h b/runtime/aether_sandbox.h index 1a1bcf909..3ec9a0685 100644 --- a/runtime/aether_sandbox.h +++ b/runtime/aether_sandbox.h @@ -1,11 +1,20 @@ -// aether_sandbox.h — Runtime containment checks -// The compiler-generated preamble registers a permission checker. -// Stdlib functions (tcp, fs, os) call aether_sandbox_check() before -// performing privileged operations. If no sandbox is active, everything -// is allowed. If a sandbox is active, only granted operations proceed. +// aether_sandbox.h: runtime containment checks +// +// Each thread has its own stack of grant levels, kept here in the runtime +// (#2955): an enforce block, a host bridge's run_sandboxed call or a +// sandbox_push() adds one, and leaving it takes it off. Stdlib functions +// (fs, net, os, udp) call aether_sandbox_check() before a privileged +// operation: with no level in force on the calling thread everything is +// allowed; with levels in force, the operation must match a grant of every +// one of them (nesting narrows, never widens). +// +// Generated code reaches all of this through the functions below, never by +// naming the per-thread state (a Windows DLL cannot export thread-locals), +// so every library sharing one runtime image (the shared runtime, or ELF +// interposition) shares each thread's levels. // // When AETHER_HAS_SANDBOX is not defined, aether_sandbox_check is a -// no-op macro that always returns 1 — zero overhead. +// no-op macro that always returns 1, zero overhead. #ifndef AETHER_SANDBOX_H #define AETHER_SANDBOX_H @@ -14,33 +23,71 @@ #ifdef AETHER_HAS_SANDBOX -// Permission check callback type. -// category: "tcp", "tcp_listen", "fs_read", "fs_write", "exec", "env" -// resource: host, path, command, var name -// Returns: 1 = allowed, 0 = denied -typedef int (*aether_sandbox_check_fn)(const char* category, const char* resource); +#include + +// The deepest a thread's levels go: an enforce block, a trusted call's +// barrier and a host bridge's run_sandboxed call each take one. A push past +// it panics, so a level is never silently left out. +#define AETHER_SANDBOX_MAX_LEVELS 64 + +// Adds a level holding `grants` (a grant list: (category, pattern) pairs; +// borrowed, the pusher keeps it alive until the matching pop). An empty +// list denies everything; NULL allows everything. +void aether_sandbox_push(void* grants); +// Takes the innermost level off. +void aether_sandbox_pop(void); +// This thread's levels, and back to `depth` of them (only ever fewer). +int aether_sandbox_depth(void); +void aether_sandbox_restore(int depth); -// Global: set by compiler-generated code when inside a sandbox block -extern aether_sandbox_check_fn _aether_sandbox_checker; +// Trusted names of `sandbox.enforce(perms, name) { ... }` (#1751): the +// enforce call tags the push it is about to make with its site, `image` +// being the address of a static of the generated code that wrote it (site +// numbers are per program or library), and a trusted call pushes a +// barrier that hides the levels from that enforce's up. Returns the depth +// to restore after the call. +void aether_sandbox_tag_site(const void* image, int site); +int aether_sandbox_trust_enter(const void* image, int site); -// The checker, with an fs path resolved to where it leads first (".." and -// symlinks), so a grant for "/box/*" cannot be left through "/box/../x" or a -// symlink in /box; an fs path that cannot be resolved is refused. +// A panic unwinding to the try frame at depth `frames` (aether_try_depth): +// the levels pushed inside that frame are left, as their pops are skipped. +// aether_panic calls this; nothing else needs to. +void aether_sandbox_unwind_to_frame(int frames); + +// The check proper, for aether_sandbox_check below: an fs path is resolved +// to where it leads first (".." and symlinks), so a grant for "/box/*" +// cannot be left through "/box/../x" or a symlink in /box; an fs path that +// cannot be resolved is refused. int aether_sandbox_check_slow(const char* category, const char* resource); +// For the LD_PRELOAD library, which resolves fs paths itself: 1 allowed, +// 0 refused, -1 when no level is in force on this thread (its file-based +// grants then apply). +int aether_sandbox_check_resolved(const char* category, const char* resource); +// The levels in force on this thread, in this runtime image and any other +// it shares the count with (0: none). +int aether_sandbox_levels_here(void); + // A grant pattern as the checks compare it: an fs pattern's directory // resolved the same way; malloc'd. char* aether_sandbox_grant_pattern(const char* category, const char* pattern); -// Check if an operation is allowed (returns 1 if no sandbox or if permitted) +// Threads with a level in force, in this runtime image and every other one +// it found in the process (aether_sandbox.c); NULL until the first check or +// push looks for them. Zero is the common case and costs two loads. +extern atomic_int* _Atomic aether_sandbox_in_force; + +// Check if an operation is allowed (returns 1 if no level is in force on +// this thread, or if every level in force grants it) static inline int aether_sandbox_check(const char* category, const char* resource) { - if (!_aether_sandbox_checker) return 1; // no sandbox = allow all + atomic_int* in_force = atomic_load_explicit(&aether_sandbox_in_force, memory_order_acquire); + if (in_force && atomic_load_explicit(in_force, memory_order_relaxed) == 0) return 1; return aether_sandbox_check_slow(category, resource); } #else -// No sandbox compiled in — always allow. The arguments are evaluated for +// No sandbox compiled in: always allow. The arguments are evaluated for // their side effects only, so a resource built in a local buffer // (aether_net_resource) does not leave that buffer unused. #define aether_sandbox_check(category, resource) ((void)(category), (void)(resource), 1) diff --git a/runtime/aether_slice.h b/runtime/aether_slice.h index 20bfe8054..c58f52b2e 100644 --- a/runtime/aether_slice.h +++ b/runtime/aether_slice.h @@ -120,7 +120,7 @@ static inline AetherSlice aether_slice_alloc(int64_t count, size_t elem_size) { aether_panic("make([]T, n): negative element count"); } void* p = calloc(count > 0 ? (size_t)count : 1, elem_size); - if (!p) aether_panic("make([]T, n): out of memory"); + if (!p) aether_out_of_memory("make([]T, n): out of memory"); AetherSlice s = aether_slice_make(p, (uint64_t)count); s.flags = AETHER_SLICE_MADE; return s; diff --git a/runtime/config/aether_optimization_config.c b/runtime/config/aether_optimization_config.c index 8216a014c..6b818dd71 100644 --- a/runtime/config/aether_optimization_config.c +++ b/runtime/config/aether_optimization_config.c @@ -17,8 +17,6 @@ AetherRuntimeConfig g_aether_config = { .actor_count = 0, .preempt_enabled = false, // Opt-in: AETHER_PREEMPT=1 .preempt_threshold_ns = 1000000, // 1ms default - .profile = AETHER_PROFILE_MEDIUM, - .msg_pool_size = AETHER_PROFILE_MEDIUM_MSG_POOL, .actors_malloced = 0, .direct_sends = 0, .queue_sends = 0, @@ -91,29 +89,20 @@ void aether_runtime_configure(AetherOptFlags flags) { atomic_store(&g_aether_config.use_message_dedup, (flags & AETHER_OPT_MESSAGE_DEDUP) != 0); atomic_store(&g_aether_config.verbose, (flags & AETHER_OPT_VERBOSE) != 0); - // Print config if verbose - if (flags & AETHER_OPT_VERBOSE) { - aether_print_config(); - } + if (flags & AETHER_OPT_VERBOSE) aether_print_config_once(); } // Initialize runtime config from environment variables // Called once at scheduler init before any actors spawn void aether_init_from_env(void) { #if AETHER_HAS_GETENV - // Profile from env or default - g_aether_config.profile = aether_profile_from_env(); - - // Pool size: env override > profile default - int env_msg = aether_env_int("AETHER_MSG_POOL_SIZE", 0); - - g_aether_config.msg_pool_size = env_msg > 0 - ? env_msg - : aether_profile_msg_pool_size(g_aether_config.profile); - - // Check verbose mode + // AETHER_VERBOSE: the configuration, printed when the scheduler starts + // (#3375), whatever the program was compiled with. It used to set a + // flag nothing read: only a program compiled with -DAETHER_VERBOSE + // printed it. if (getenv("AETHER_VERBOSE")) { atomic_store(&g_aether_config.verbose, true); + aether_print_config_once(); } // Cooperative preemption (opt-in) @@ -124,21 +113,14 @@ void aether_init_from_env(void) { g_aether_config.preempt_threshold_ns = (uint64_t)threshold * 1000000ULL; } } -#else - // No getenv: use defaults - g_aether_config.profile = AETHER_PROFILE_MEDIUM; - g_aether_config.msg_pool_size = aether_profile_msg_pool_size(AETHER_PROFILE_MEDIUM); #endif } -// Profile name helper -static const char* aether_profile_name(AetherProfile p) { - switch (p) { - case AETHER_PROFILE_MICRO: return "micro"; - case AETHER_PROFILE_SMALL: return "small"; - case AETHER_PROFILE_LARGE: return "large"; - default: return "medium"; - } +// Once per process: the environment, the program and the flags may all ask. +static atomic_int g_config_printed = 0; + +void aether_print_config_once(void) { + if (atomic_exchange(&g_config_printed, 1) == 0) aether_print_config(); } // Print current configuration @@ -157,15 +139,10 @@ void aether_print_config(void) { printf(" malloc: %s\n", AETHER_HAS_MALLOC ? "YES" : "NO"); printf(" shell: %s\n\n", AETHER_HAS_SHELL ? "YES" : "NO"); - printf("PROFILE: %s (msg_pool=%d)\n\n", - aether_profile_name(g_aether_config.profile), - g_aether_config.msg_pool_size); - printf("TIER 1 - ALWAYS ON:\n"); printf(" [ON] Direct Send - Same-core bypass\n"); printf(" [ON] Adaptive Batching - Dynamic 4-64 batch size\n"); - printf(" [ON] Message Coalescing - Amortises atomics across bursts\n"); - printf(" [ON] TLS Message Pools - Eliminates mutex contention\n\n"); + printf(" [ON] Message Coalescing - Amortises atomics across bursts\n\n"); printf("TIER 2 - AUTO-DETECTED:\n"); printf(" [%s] SIMD Processing - %d-bit vectors%s\n", diff --git a/runtime/config/aether_optimization_config.h b/runtime/config/aether_optimization_config.h index a3e2cfae4..881cac0f8 100644 --- a/runtime/config/aether_optimization_config.h +++ b/runtime/config/aether_optimization_config.h @@ -12,7 +12,6 @@ // - Direct Send (same-core bypass) // - Adaptive Batching (4-64 dynamic) // - Message Coalescing (amortises atomics across bursts) -// - TLS Message Pools (eliminates mutex contention on the payload allocator) // // Tier 2 - AUTO-DETECT (hardware-dependent): // - SIMD Batch Processing (requires AVX2/NEON) @@ -276,53 +275,10 @@ static inline AetherPlatformCaps aether_platform_caps(void) { }; } -// ============================================================================ -// MEMORY PROFILES (auto-detected or user-specified via AETHER_PROFILE) -// Controls pool sizes for different workload characteristics -// ============================================================================ - -typedef enum { - AETHER_PROFILE_MICRO, // <10 actors, <1M messages: pools=256 - AETHER_PROFILE_SMALL, // <100 actors: pools=1024 - AETHER_PROFILE_MEDIUM, // <1000 actors: pools=4096 (default) - AETHER_PROFILE_LARGE // 1000+ actors: pools=16384 -} AetherProfile; - -// Profile pool sizes -#define AETHER_PROFILE_MICRO_MSG_POOL 256 -#define AETHER_PROFILE_SMALL_MSG_POOL 1024 -#define AETHER_PROFILE_MEDIUM_MSG_POOL 4096 -#define AETHER_PROFILE_LARGE_MSG_POOL 16384 - -// Get pool sizes for a profile -static inline int aether_profile_msg_pool_size(AetherProfile p) { - switch (p) { - case AETHER_PROFILE_MICRO: return AETHER_PROFILE_MICRO_MSG_POOL; - case AETHER_PROFILE_SMALL: return AETHER_PROFILE_SMALL_MSG_POOL; - case AETHER_PROFILE_LARGE: return AETHER_PROFILE_LARGE_MSG_POOL; - default: return AETHER_PROFILE_MEDIUM_MSG_POOL; - } -} - // ============================================================================ // ENVIRONMENT VARIABLE HELPERS // ============================================================================ -// Parse AETHER_PROFILE env var -static inline AetherProfile aether_profile_from_env(void) { -#if AETHER_HAS_GETENV - const char* env = getenv("AETHER_PROFILE"); - if (!env) return AETHER_PROFILE_MEDIUM; - - if (env[0] == 'm' && env[1] == 'i') return AETHER_PROFILE_MICRO; // "micro" - if (env[0] == 's') return AETHER_PROFILE_SMALL; // "small" - if (env[0] == 'l') return AETHER_PROFILE_LARGE; // "large" - return AETHER_PROFILE_MEDIUM; // "medium" or unknown -#else - return AETHER_PROFILE_MEDIUM; -#endif -} - // Read integer from env var with default static inline int aether_env_int(const char* name, int default_val) { #if AETHER_HAS_GETENV @@ -409,10 +365,6 @@ typedef struct { // Live actors (spawned, not yet released) atomic_int actor_count; - // Memory profile (auto-detected or env override) - AetherProfile profile; - int msg_pool_size; // Actual pool size (env override or from profile) - // Cooperative preemption (opt-in, zero cost when disabled) // Scheduler-side: break out of drain loop if handler exceeds threshold atomic_bool preempt_enabled; // AETHER_PREEMPT=1 env var @@ -435,6 +387,11 @@ void aether_runtime_configure(AetherOptFlags flags); // Print current configuration void aether_print_config(void); +// Print it once per process, however many ways ask (#3375): AETHER_VERBOSE in +// the environment when the scheduler starts, a program compiled with +// -DAETHER_VERBOSE, AETHER_OPT_VERBOSE. +void aether_print_config_once(void); + // ============================================================================ // IMPLEMENTATION (inline for performance) // ============================================================================ @@ -474,7 +431,8 @@ static inline void aether_on_actor_terminate(void) { atomic_fetch_sub_explicit(&g_aether_config.actor_count, 1, memory_order_relaxed); } -// Initialize the profile from env vars +// Read the runtime settings from the environment (AETHER_VERBOSE, +// AETHER_PREEMPT, AETHER_PREEMPT_MS) // Called once at runtime init void aether_init_from_env(void); diff --git a/runtime/coop_runtime_sources.txt b/runtime/coop_runtime_sources.txt index c4c063cc8..a2dbad872 100644 --- a/runtime/coop_runtime_sources.txt +++ b/runtime/coop_runtime_sources.txt @@ -1,14 +1,19 @@ # The runtime a cooperative-scheduler, freestanding build links beside its -# generated C. ci-wasm (Makefile COOP_RUNTIME_SOURCES) and ci-esp32 -# (tests/esp32/project/main/CMakeLists.txt) both read this one list: each -# used to keep its own copy, the copies drifted, and neither had +# generated C. `ae build --target=wasm` (tools/ae.c, from the installed +# share/aether/runtime/ copy), ci-wasm (Makefile COOP_RUNTIME_SOURCES) and +# ci-esp32 (tests/esp32/project/main/CMakeLists.txt) all read this one list: +# each used to keep its own copy, the copies drifted, and none had # runtime/aether_resource_caps.c, which std/string's release path calls # (aether_caps_free), so the first program that freed a counted string did -# not link on either target. The list is closed under reference: every +# not link on any of them. The list is closed under reference: every # runtime function a listed file calls is defined by a listed file (std/string # calls std/collections/aether_stringseq.c, std/collections calls -# std/alloc), so a program links whichever of them it reaches. One path per -# line, from the repository root; a line that starts with # is a comment. +# std/alloc), so a program links whichever of them it reaches. The C of +# the modules a program reaches only from Aether (std.intarr, std.longarr, +# std.floatarr, std.strarr, the StringList of std.collections) is here for +# the same reason (#3383: ae listed floatarr and stringlist, this file did +# not, and strarr was in neither). One path per line, from the repository +# root; a line that starts with # is a comment. runtime/scheduler/aether_scheduler_coop.c runtime/scheduler/scheduler_optimizations.c runtime/config/aether_optimization_config.c @@ -26,6 +31,8 @@ runtime/aether_locale_num.c runtime/aether_runtime.c runtime/aether_numa.c runtime/aether_host.c +runtime/aether_exec.c +runtime/libaether_caps.c runtime/aether_resource_caps.c runtime/aether_callbacks.c runtime/actors/aether_send_buffer.c @@ -33,10 +40,12 @@ runtime/actors/aether_send_message.c runtime/actors/aether_unwind.c runtime/actors/aether_panic.c runtime/actors/aether_actor_thread.c +runtime/actors/aether_supervision.c runtime/sandbox/capsicum_autosandbox.c std/string/aether_string.c std/math/aether_math.c std/net/aether_http.c +std/net/aether_tls_trust.c std/net/aether_http_server.c std/net/aether_http_pool.c std/net/aether_http_park.c @@ -48,6 +57,9 @@ std/collections/aether_set.c std/collections/aether_pqueue.c std/collections/aether_intarr.c std/collections/aether_longarr.c +std/collections/aether_floatarr.c +std/collections/aether_strarr.c +std/collections/aether_stringlist.c std/collections/aether_bits.c std/collections/aether_stringseq.c std/alloc/aether_alloc.c diff --git a/runtime/examples/buffered_send_bench.c b/runtime/examples/buffered_send_bench.c index 81222f4fa..a0a17b0ea 100644 --- a/runtime/examples/buffered_send_bench.c +++ b/runtime/examples/buffered_send_bench.c @@ -20,20 +20,16 @@ typedef struct { uint64_t count; } CounterActor; +// One message a step: the scheduler counts each step as one message handled, +// and scheduler_shutdown() waits until every message sent has been. A step +// that took up to 128 left the count short, and the shutdown waited forever. void counter_step(void* self) { CounterActor* actor = (CounterActor*)self; Message msg; - - int batch_size = 128; - int received = 0; - while (received < batch_size && mailbox_receive(&actor->base.mailbox, &msg)) { + if (mailbox_receive(&actor->base.mailbox, &msg)) { actor->count++; - received++; - } - - if (received > 0) { - atomic_store_explicit(&actor->base.active, 1, memory_order_relaxed); // Keep processing } + atomic_store_explicit(&actor->base.active, actor->base.mailbox.count > 0, memory_order_relaxed); } // Benchmark unbuffered sends (current implementation) @@ -78,8 +74,10 @@ double bench_unbuffered(int num_actors, int messages_per_actor) { clock_gettime(CLOCK_MONOTONIC, &end); - scheduler_stop(); - scheduler_wait(); + // Joined and torn down: the next benchmark initialises the scheduler + // again, and scheduler_init() on one that is only stopped does nothing. + scheduler_shutdown(); + scheduler_cleanup(); double elapsed = (end.tv_sec - start.tv_sec) + (end.tv_nsec - start.tv_nsec) / 1e9; uint64_t total_msgs = 0; @@ -146,8 +144,9 @@ double bench_buffered(int num_actors, int messages_per_actor) { clock_gettime(CLOCK_MONOTONIC, &end); - scheduler_stop(); - scheduler_wait(); + // As in bench_unbuffered. + scheduler_shutdown(); + scheduler_cleanup(); double elapsed = (end.tv_sec - start.tv_sec) + (end.tv_nsec - start.tv_nsec) / 1e9; uint64_t total_msgs = 0; diff --git a/runtime/examples/multicore_bench.c b/runtime/examples/multicore_bench.c index ef7d110bc..a2387d217 100644 --- a/runtime/examples/multicore_bench.c +++ b/runtime/examples/multicore_bench.c @@ -170,6 +170,7 @@ int main(void) { clock_t end = clock(); scheduler_stop(); + current_core_id = -1; // main is no core now: a core waits for no one (#3463) scheduler_wait(); double elapsed = (double)(end - start) / CLOCKS_PER_SEC; double msg_per_sec = total_messages / elapsed; diff --git a/runtime/examples/runtime_config_example.c b/runtime/examples/runtime_config_example.c index f9e8a59d5..a4e219bc0 100644 --- a/runtime/examples/runtime_config_example.c +++ b/runtime/examples/runtime_config_example.c @@ -19,7 +19,6 @@ void print_examples(void) { printf("2. MAXIMUM PERFORMANCE:\n"); printf(" aether_runtime_init(8, \n"); printf(" AETHER_FLAG_LOCKFREE_MAILBOX |\n"); - printf(" AETHER_FLAG_LOCKFREE_POOLS |\n"); printf(" AETHER_FLAG_ENABLE_SIMD |\n"); printf(" AETHER_FLAG_ENABLE_MWAIT);\n"); printf(" - Explicit control, all optimizations ON\n"); @@ -32,9 +31,7 @@ void print_examples(void) { printf(" - Slower but guaranteed compatibility\n\n"); printf("4. SELECTIVE OPTIMIZATION:\n"); - printf(" aether_runtime_init(8, \n"); - printf(" AETHER_FLAG_LOCKFREE_MAILBOX |\n"); - printf(" AETHER_FLAG_LOCKFREE_POOLS);\n"); + printf(" aether_runtime_init(8, AETHER_FLAG_LOCKFREE_MAILBOX);\n"); printf(" - Lock-free only, no SIMD/MWAIT\n"); printf(" - Good for non-x86 platforms\n\n"); @@ -61,7 +58,7 @@ int main(void) { printf("Available Flags:\n"); printf(" AETHER_FLAG_AUTO_DETECT (0x%02x) - Auto-detect CPU features\n", AETHER_FLAG_AUTO_DETECT); printf(" AETHER_FLAG_LOCKFREE_MAILBOX (0x%02x) - Lock-free SPSC mailboxes\n", AETHER_FLAG_LOCKFREE_MAILBOX); - printf(" AETHER_FLAG_LOCKFREE_POOLS (0x%02x) - Lock-free TLS pools\n", AETHER_FLAG_LOCKFREE_POOLS); + printf(" AETHER_FLAG_LOCKFREE_POOLS (0x%02x) - Deprecated, accepted and ignored (no message pool)\n", AETHER_FLAG_LOCKFREE_POOLS); printf(" AETHER_FLAG_ENABLE_SIMD (0x%02x) - AVX2 vectorization\n", AETHER_FLAG_ENABLE_SIMD); printf(" AETHER_FLAG_ENABLE_MWAIT (0x%02x) - MWAIT idle strategy\n", AETHER_FLAG_ENABLE_MWAIT); printf(" AETHER_FLAG_VERBOSE (0x%02x) - Print config on init\n", AETHER_FLAG_VERBOSE); diff --git a/runtime/examples/workstealing_bench.c b/runtime/examples/workstealing_bench.c index cf097b8b6..6e16b2020 100644 --- a/runtime/examples/workstealing_bench.c +++ b/runtime/examples/workstealing_bench.c @@ -118,6 +118,7 @@ int main(void) { clock_t end = clock(); scheduler_stop(); + current_core_id = -1; // main is no core now: a core waits for no one (#3463) scheduler_wait(); double elapsed = (double)(end - start) / CLOCKS_PER_SEC; diff --git a/runtime/libaether_caps.c b/runtime/libaether_caps.c index 409cb1fee..150814b84 100644 --- a/runtime/libaether_caps.c +++ b/runtime/libaether_caps.c @@ -17,13 +17,17 @@ * pass the include set from `ae cflags` and never rely on the relative path. */ #include "libaether.h" #include "aether_resource_caps.h" +#include "aether_exec.h" AETHER_API void aether_set_memory_cap(uint64_t bytes) { aether_caps_set_memory_cap(bytes); } +/* Outside any execution the thread's deadline; set by a host service while + * an execution runs, the deadline of the executions it enters from there, + * the running one keeping its own (#2955). */ AETHER_API void aether_set_call_deadline(int64_t ms) { - aether_caps_set_deadline_ms(ms); + aether_exec_set_next_deadline(ms); } AETHER_API int aether_deadline_tripped(void) { diff --git a/runtime/libaether_sandbox_preload.c b/runtime/libaether_sandbox_preload.c index 9b3e6d6a5..5c646380f 100644 --- a/runtime/libaether_sandbox_preload.c +++ b/runtime/libaether_sandbox_preload.c @@ -60,10 +60,14 @@ static void* (*real_dlopen)(const char*, int) = NULL; // the in-process checker and the host bridges use. #include "aether_sandbox_match.h" -// Check the in-process Aether sandbox checker first (embedded mode), -// then fall back to file-based grants (LD_PRELOAD mode) -typedef int (*aether_sandbox_check_fn)(const char*, const char*); -extern aether_sandbox_check_fn _aether_sandbox_checker __attribute__((weak)); +// The calling thread's in-process grant levels first (embedded mode: an +// Aether enforce block, a host bridge's run_sandboxed), then the file-based +// grants (LD_PRELOAD mode). Weak: a process with no Aether runtime has only +// the file. 1 allowed, 0 refused, -1 no level in force on this thread +// (#2955; it used to read a process-wide checker pointer, which one thread +// leaving its enforce cleared under another). +extern int aether_sandbox_check_resolved(const char* category, const char* resource) __attribute__((weak)); +extern int aether_sandbox_levels_here(void) __attribute__((weak)); static void log_deny(const char* category, const char* resource) { if (log_mode == LOG_NONE) return; @@ -90,7 +94,8 @@ static int check_grant(const char* category, const char* resource) { const char* match_target = resource; if (category && strncmp(category, "fs_", 3) == 0 && resource) { if (!aether_sandbox_canon_path(resource, resolved, sizeof(resolved))) { - if ((&_aether_sandbox_checker && _aether_sandbox_checker) || sandbox_active) { + int in_process = aether_sandbox_levels_here ? aether_sandbox_levels_here() : 0; + if (in_process > 0 || sandbox_active) { log_deny(category, resource); return 0; } @@ -99,11 +104,13 @@ static int check_grant(const char* category, const char* resource) { match_target = resolved; } - // In-process checker takes priority (embedded Python mode) - if (&_aether_sandbox_checker && _aether_sandbox_checker) { - int result = _aether_sandbox_checker(category, match_target); - if (!result) log_deny(category, match_target); - return result; + // In-process levels take priority (embedded Python mode) + if (aether_sandbox_check_resolved) { + int result = aether_sandbox_check_resolved(category, match_target); + if (result >= 0) { + if (!result) log_deny(category, match_target); + return result; + } } // Fall back to file-based grants (LD_PRELOAD mode) if (!sandbox_active) return 1; diff --git a/runtime/scheduler/aether_scheduler_coop.c b/runtime/scheduler/aether_scheduler_coop.c index 715c9d820..dea9f95b0 100644 --- a/runtime/scheduler/aether_scheduler_coop.c +++ b/runtime/scheduler/aether_scheduler_coop.c @@ -8,10 +8,13 @@ // WebAssembly (Emscripten), embedded systems, bare-metal, etc. #include "multicore_scheduler.h" +#include "../utils/aether_fatal.h" /* aether_fatal_flush (#3435) */ #include "../config/aether_optimization_config.h" #include "../aether_numa.h" #include "../actors/aether_send_message.h" #include "../actors/aether_actor_inbox.h" +#include "../actors/aether_step_barrier.h" +#include "../aether_resource_caps.h" #include #include #include @@ -29,9 +32,9 @@ Scheduler schedulers[MAX_CORES]; // Only [0] is used int num_cores = 1; atomic_int next_actor_id = 0; AETHER_TLS int current_core_id = 0; // Always core 0 -AETHER_TLS void* g_pending_reply_slot = NULL; AETHER_TLS void* g_current_reply_slot = NULL; AETHER_TLS void* g_current_step_actor = NULL; +AETHER_TLS int aether_in_actor_thread = 0; // set by aether_actor_thread.c (#3463) // ============================================================================ // Released actors (#2509) @@ -70,6 +73,7 @@ static void coop_free_actor(ActorBase* actor) { } free(ib); } + aether_watch_free(actor); // its death notices (#2974) if (actor->spsc_queue) { free(actor->spsc_queue); actor->spsc_queue = NULL; @@ -158,6 +162,7 @@ void scheduler_send_ref(AetherActorRef ref, Message msg) { static int coop_send_to_dead_actor(ActorBase* actor, Message* msg) { int dead = actor ? atomic_load_explicit(&actor->dead, memory_order_relaxed) : 1; if (!dead) return 0; + aether_mailbox_untake(actor, msg->type); // #2974 aether_message_drop(actor, msg); /* An ask to it gets no reply: its asker learns so at once (#3151). */ if (msg->_reply_slot) ((ActorReplySlot*)msg->_reply_slot)->failed = AETHER_ASK_DEAD; @@ -168,6 +173,8 @@ static int coop_send_to_dead_actor(ActorBase* actor, Message* msg) { fprintf(stderr, "aether: message type %d sent to actor %d after its release; " "dropped (reported once)\n", msg->type, actor->id); } + } else if (actor) { + scheduler_count_dead_sends(1); // panicked, killed or stopped (#2974) } return 1; } @@ -189,6 +196,7 @@ static void coop_deliver(ActorBase* actor, Message msg) { if (!ib) { ib = actor_inbox_new(); if (!ib) { + aether_fatal_flush(); /* the program's lines first (#3435, aether_fatal.h) */ fprintf(stderr, "aether: out of memory queueing message type %d for actor %d\n", msg.type, actor->id); abort(); @@ -196,12 +204,58 @@ static void coop_deliver(ActorBase* actor, Message msg) { atomic_store_explicit(&actor->inbox, ib, memory_order_relaxed); } if (!actor_inbox_push(ib, msg)) { + aether_fatal_flush(); /* the program's lines first (#3435, aether_fatal.h) */ fprintf(stderr, "aether: out of memory queueing message type %d for actor %d\n", msg.type, actor->id); abort(); } } +/* A bounded mailbox (#2974), as the threaded scheduler has it: a send takes + * its place first, or is refused and released, never blocked on. A refused + * ask fails at once; a refused plain send is counted, reported once. */ +static uint64_t g_coop_full_drops = 0; +static int g_coop_full_drop_reported = 0; + +uint64_t scheduler_full_drops(void) { + return g_coop_full_drops; +} + +static void coop_send(ActorBase* actor, Message msg) { + if (actor && atomic_load_explicit(&actor->mailbox_bound, memory_order_relaxed) && + !aether_mailbox_admit(actor, msg.type)) { + aether_message_drop(actor, &msg); + if (msg._reply_slot) { + ((ActorReplySlot*)msg._reply_slot)->failed = AETHER_ASK_FULL; + return; + } + atomic_fetch_add_explicit(&actor->full_drops, 1, memory_order_relaxed); + g_coop_full_drops++; + if (!g_coop_full_drop_reported) { + g_coop_full_drop_reported = 1; + fprintf(stderr, "aether: message type %d to actor %d dropped: its mailbox is full " + "(bound %d); counted by actors.full_drops (reported once)\n", + msg.type, actor->id, + atomic_load_explicit(&actor->mailbox_bound, memory_order_relaxed)); + } + return; + } + coop_deliver(actor, msg); +} + +const char* scheduler_try_send(ActorBase* actor, Message msg) { + if (!actor || atomic_load_explicit(&actor->dead, memory_order_relaxed)) { + aether_message_drop(actor, &msg); + return "the actor is dead"; + } + if (!aether_mailbox_admit(actor, msg.type)) { + aether_message_drop(actor, &msg); + return "the mailbox is full"; + } + coop_deliver(actor, msg); + return ""; +} + // Moves what waits in the actor's inbox into its mailbox, as room allows. static void coop_refill(ActorBase* actor) { ActorInbox* ib = atomic_load_explicit(&actor->inbox, memory_order_relaxed); @@ -277,6 +331,11 @@ void scheduler_init(int cores) { g_coop_initialized = 1; num_cores = 1; + // Before this copy's first step, as the threaded scheduler does (#3438): + // a build without threads still runs the sandbox and the step barrier. + void (*join)(void) = atomic_load_explicit(&aether_sandbox_join, memory_order_acquire); + if (join) join(); + aether_detect_hardware(); aether_init_from_env(); @@ -292,6 +351,7 @@ void scheduler_init(int cores) { // The scheduler cannot run any actor without this table; leaving it NULL // would crash scheduler_register_actor on the first spawn. Fail loudly // at init instead of a delayed NULL deref. + aether_fatal_flush(); /* the program's lines first (#3435, aether_fatal.h) */ fprintf(stderr, "aether: failed to allocate cooperative scheduler actor table\n"); abort(); } @@ -366,6 +426,12 @@ int scheduler_register_actor(ActorBase* actor, int preferred_core) { actor->scheduler_owned = 0; actor->release_message = NULL; // its messages are the caller's (#3142) actor->step_payload = NULL; + atomic_store_explicit(&actor->death_watch, NULL, memory_order_relaxed); // #2974 + atomic_store_explicit(&actor->exit_flags, 0, memory_order_relaxed); + atomic_store_explicit(&actor->incarnation, 0, memory_order_relaxed); + atomic_store_explicit(&actor->mailbox_bound, 0, memory_order_relaxed); // #2974 + atomic_store_explicit(&actor->mailbox_pending, 0, memory_order_relaxed); + atomic_store_explicit(&actor->full_drops, 0, memory_order_relaxed); return register_actor(actor, preferred_core); } @@ -391,6 +457,10 @@ ActorBase* scheduler_spawn_actor(int preferred_core, void (*step)(void*), size_t scheduler_init(1); // no-op once initialized; see g_coop_initialized if (actor_size < sizeof(ActorBase)) actor_size = sizeof(ActorBase); + // A restart in place (#2974): the child's own block. + ActorBase* reborn = aether_rebirth_take(step, actor_size); + if (reborn) return reborn; + // A released actor's block of this size first (#2517). Generated actor // structs are aligned(64), which calloc does not honour (#2485); the // block goes back through the matching function in scheduler_cleanup. @@ -425,7 +495,13 @@ ActorBase* scheduler_spawn_actor(int preferred_core, void (*step)(void*), size_t void scheduler_release_actor(ActorBase* actor) { if (!actor) return; - atomic_store_explicit(&actor->dead, AETHER_ACTOR_RELEASED, memory_order_relaxed); + // Its watchers are told (#2974): a release is a normal end unless + // something ended it first, and one told already is told nothing more. + if (!(atomic_load_explicit(&actor->dead, memory_order_relaxed) & AETHER_ACTOR_RELEASED)) { + aether_actor_claim_reason(actor, "normal"); + aether_watch_notify(actor, aether_actor_death_reason(actor)); + } + atomic_fetch_or_explicit(&actor->dead, AETHER_ACTOR_RELEASED, memory_order_relaxed); // Remove from scheduler's actor list, clearing the vacated slot as the // threaded scheduler does. @@ -463,17 +539,17 @@ void scheduler_release_actor(ActorBase* actor) { // ============================================================================ void scheduler_send_local(ActorBase* actor, Message msg) { - coop_deliver(actor, msg); + coop_send(actor, msg); } void scheduler_send_remote(ActorBase* actor, Message msg, int from_core) { (void)from_core; // In cooperative mode there's only one core — all sends are local - coop_deliver(actor, msg); + coop_send(actor, msg); } void scheduler_send_many(ActorBase* actor, const Message* msgs, int n) { - for (int i = 0; i < n; i++) coop_deliver(actor, msgs[i]); + for (int i = 0; i < n; i++) coop_send(actor, msgs[i]); } // Batch send — trivial in single-threaded mode @@ -482,7 +558,7 @@ void scheduler_send_batch_start(void) { } void scheduler_send_batch_add(ActorBase* actor, Message msg) { - coop_deliver(actor, msg); + coop_send(actor, msg); } void scheduler_send_batch_flush(void) { @@ -518,8 +594,16 @@ static int coop_timeout_armed(void) { * down the poll does no work, and the wait sleeps a millisecond at a time, * bounded as the multicore scheduler bounds it (100 s); it used to spin on * the CPU, the poll counting each check of the deadline as work. The end of - * main does not wait for countdowns (scheduler_shutdown, #3219). */ + * main does not wait for countdowns (scheduler_shutdown, #3219). + * + * Inside an actor's step, or on an actor's own thread, it panics, as on the + * multicore scheduler (#3463): there it waits for a step that cannot end + * while it waits; here the poll ran the other actors nested and returned, + * and a handler's wait now behaves alike on both. A drop or a death hook + * this scheduler runs after a step still polls nested and returns: the one + * thread here steps every actor, and is in no step then. */ void scheduler_wait(void) { + if (g_aether_in_actor_step || aether_in_actor_thread) aether_panic(AETHER_WAIT_IN_STEP_REASON); uint64_t give_up = aether_now_ns() + 100ULL * 1000000000ULL; for (;;) { if (aether_scheduler_poll(0) > 0) continue; @@ -543,18 +627,99 @@ void scheduler_wait(void) { * polls until its reply comes, so a step runs inside the asking one, as on a * core's inline path: it starts with no ask in hand, fails one its handler * left unanswered, and gives the enclosing step its context back. */ -void scheduler_step_actor(ActorBase* actor) { - if (!actor || !actor->step) return; - void* outer_slot = g_current_reply_slot; - void* outer_actor = g_current_step_actor; - g_current_reply_slot = NULL; - actor->step(actor); - ActorReplySlot* held = (ActorReplySlot*)g_current_reply_slot; - if (held && !held->reply_ready && !held->failed) held->failed = AETHER_ASK_NO_REPLY; - g_current_reply_slot = outer_slot; - g_current_step_actor = outer_actor; +// ---------------------------------------------------------------------------- +// The end of an actor (#2974), as the threaded scheduler's notes describe it: +// one thread, so its death is finalized where it is seen, unless the actor is +// stepping (its step lock taken by the outermost step on the stack), when its +// step's end finalizes it. +// ---------------------------------------------------------------------------- + +static uint64_t g_coop_dead_sends = 0; + +uint64_t scheduler_dead_sends(void) { + return g_coop_dead_sends; } +void scheduler_count_dead_sends(int n) { + if (n > 0) g_coop_dead_sends += (uint64_t)n; +} + +/* What waits for it is dropped (an ask in it fails as dead), and its + * watchers are told. Once. */ +static void coop_finalize(ActorBase* actor) { + int flags = atomic_load_explicit(&actor->exit_flags, memory_order_relaxed); + if (flags & AETHER_EXIT_FINALIZED) return; + atomic_store_explicit(&actor->exit_flags, flags | AETHER_EXIT_FINALIZED, memory_order_relaxed); + int drained = 0; + Message discard; + for (;;) { + coop_refill(actor); + if (!mailbox_receive(&actor->mailbox, &discard)) break; + aether_mailbox_untake(actor, discard.type); + // A panic in its release is reported, and the end goes on (#3401). + aether_message_drop_at_end(actor, &discard); + if (discard._reply_slot) ((ActorReplySlot*)discard._reply_slot)->failed = AETHER_ASK_DEAD; + drained++; + } + scheduler_count_dead_sends(drained); + const char* reason = aether_actor_death_reason(actor); + aether_watch_notify(actor, reason ? reason : "normal"); +} + +void scheduler_actor_thread_finalize(ActorBase* actor) { + (void)actor; // no actor has a thread of its own here +} + +void scheduler_actor_revived(ActorBase* actor) { + (void)actor; // the next poll steps it +} + +/* An ask the step leaves without a reply: its asker, polling, sees why. */ +static void coop_fail_slot(void* slot, int why) { + ActorReplySlot* s = (ActorReplySlot*)slot; + if (!s->reply_ready && !s->failed) s->failed = why; +} + +int scheduler_actor_end(ActorBase* actor, const char* reason, int bit) { + if (!actor || !aether_actor_claim_end(actor, reason, bit)) return 0; + if (!atomic_flag_test_and_set_explicit(&actor->step_lock, memory_order_acquire)) { + coop_finalize(actor); + atomic_flag_clear_explicit(&actor->step_lock, memory_order_release); + } + return 1; +} + +/* One step of `actor`, or none: 1 when it stepped. Its step lock is held + * while it steps. An actor whose step is on the stack already (it asked, and + * this is the ask's poll) is not stepped again inside it (#3391): its next + * message waits for the handler to return, as with threads, where the + * waiting thread steps only its target and an actor stepping is left alone. + * A second step there ran the actor's next handler inside the first, its + * state changing under it, and a stop() or a panic in it finalized the + * actor (its Down sent, its queue dropped) while the first still ran. */ +static int coop_step(ActorBase* actor) { + if (!actor || !actor->step) return 0; + if (atomic_load_explicit(&actor->dead, memory_order_relaxed)) return 0; + if (atomic_flag_test_and_set_explicit(&actor->step_lock, memory_order_acquire)) return 0; + // Inside the panic barrier both schedulers share (#2974): a panic is a + // death, finalized here, with its Down, not the end of the program; a + // stop() or a kill that came while the handler ran is finalized after it. + // Where the target has no setjmp (wasi) a panic still ends the program. + aether_step_barrier(actor, coop_finalize, coop_fail_slot, NULL); + atomic_flag_clear_explicit(&actor->step_lock, memory_order_release); + return 1; +} + +int scheduler_step_actor(ActorBase* actor) { + return coop_step(actor); +} + +/* A firing countdown is counted for wait_for_idle() on the multicore + * scheduler (#3443). Here the thread that waits is the one that steps, so a + * wait never returns while an arm runs: nothing to count. */ +void scheduler_countdown_enter(void) {} +void scheduler_countdown_leave(void) {} + int aether_scheduler_poll(int max_per_actor) { int total = 0; int limit = (max_per_actor <= 0) ? 1024 : max_per_actor; @@ -575,7 +740,7 @@ int aether_scheduler_poll(int max_per_actor) { while (processed < limit) { coop_refill(actor); if (atomic_load_explicit(&actor->mailbox.count, memory_order_acquire) == 0) break; - scheduler_step_actor(actor); + if (!coop_step(actor)) break; /* stepping higher up the stack, or dead */ processed++; } } else if (has_timeout) { @@ -585,8 +750,7 @@ int aether_scheduler_poll(int max_per_actor) { * check counted as one kept wait_for_idle() and the end of main * spinning until the deadline (#3219). */ uint64_t armed_at = actor->last_activity_ns; - scheduler_step_actor(actor); - if (actor->last_activity_ns != armed_at) processed++; + if (coop_step(actor) && actor->last_activity_ns != armed_at) processed++; } total += processed; } @@ -599,9 +763,36 @@ int aether_scheduler_poll(int max_per_actor) { // Ask/Reply — simplified synchronous version // ============================================================================ +/* An ask that ends before its message is handled (#3330): the message, still + * in the target's mailbox or inbox, points at the asker's slot, which lives + * on the asker's stack and is gone once the ask returns. Its handler would + * write the reply, or the step's end its failure, into whatever is there + * then, another ask's slot as like as not. The message stays, as a plain + * send: it is handled, and its reply goes nowhere, as a late reply does + * with threads. One step takes one message, so a message the asker no + * longer waits for is either handled or in one of these two places. */ +static void coop_forget_slot(ActorBase* target, ActorReplySlot* slot) { + Mailbox* mb = &target->mailbox; + int n = atomic_load_explicit(&mb->count, memory_order_relaxed); + for (int i = 0; i < n; i++) { + Message* m = &mb->messages[(mb->head + i) & MAILBOX_MASK]; + if (m->_reply_slot == slot) { m->_reply_slot = NULL; return; } + } + ActorInbox* ib = atomic_load_explicit(&target->inbox, memory_order_relaxed); + if (!ib) return; + actor_inbox_lock(ib); + for (int i = 0; i < ib->ring_count; i++) { + Message* m = &ib->ring[(ib->head + i) % AETHER_INBOX_RING]; + if (m->_reply_slot == slot) { m->_reply_slot = NULL; actor_inbox_unlock(ib); return; } + } + for (ActorInboxNode* node = ib->first; node; node = node->next) { + if (node->msg._reply_slot == slot) { node->msg._reply_slot = NULL; break; } + } + actor_inbox_unlock(ib); +} + void* scheduler_ask_message_status(ActorBase* target, void* msg_data, size_t msg_size, int timeout_ms, int* status) { - (void)timeout_ms; int why_dummy; if (!status) status = &why_dummy; *status = AETHER_ASK_DEAD; @@ -616,33 +807,51 @@ void* scheduler_ask_message_status(ActorBase* target, void* msg_data, size_t msg slot.reply_ready = 0; slot.timed_out = 0; atomic_store(&slot.refcount, 1); - - // Set pending reply slot - g_pending_reply_slot = &slot; - - // Queued as any send is, and processed by the polls below - void* msg_copy = malloc(msg_size); - if (!msg_copy) { g_pending_reply_slot = NULL; *status = AETHER_ASK_NO_MEMORY; return NULL; } - memcpy(msg_copy, msg_data, msg_size); - + // The asker's timeout (#3330): one thread runs the handler inside the + // polls below, so a handler slower than the timeout cannot be left; its + // reply, past the deadline, is late, and the ask has timed out, as with + // threads, where the asker stops waiting at the deadline. + slot.deadline_ns = timeout_ms > 0 ? aether_now_ns() + (uint64_t)timeout_ms * 1000000ULL : 0; + + // Its message, with the slot in it (#3395), queued as any send is and + // processed by the polls below. No memory for the copy fails the ask, and + // what the message owns is released (#3387). Message msg; - msg.type = *(int*)msg_data; - msg.sender_id = 0; - msg.payload_int = 0; - msg.payload_ptr = msg_copy; - msg.zerocopy.data = NULL; - msg.zerocopy.size = 0; - msg.zerocopy.owned = 0; - msg._reply_slot = &slot; - - coop_deliver(target, msg); - g_pending_reply_slot = NULL; - - // Process messages until reply is ready, or none can come - int max_rounds = 100000; - while (!slot.reply_ready && !slot.failed && max_rounds-- > 0) { - aether_scheduler_poll(1); + if (!aether_ask_message(target, msg_data, msg_size, &slot, &msg)) { + aether_alloc_failed(AETHER_STOP_OUT_OF_MEMORY); /* a library call ends (#2956) */ + *status = AETHER_ASK_NO_MEMORY; + return NULL; + } + coop_send(target, msg); + + // Process messages until the reply is ready, the deadline passes, or none + // can come: a poll that handled nothing, with no countdown armed, leaves + // every mailbox as empty for the next one. The deadline is the only + // bound in time; a count of polls ended an ask behind a long queue + // before its timeout (#3330). With no timeout the wait is bounded as + // scheduler_wait's (100 s). An asker killed meanwhile still gets its + // answer, or its timeout, here: the slot is on this stack and the + // message in the target's mailbox points to it, and one thread runs + // both (#2974). + uint64_t give_up = slot.deadline_ns ? slot.deadline_ns + : aether_now_ns() + 100ULL * 1000000000ULL; + while (!slot.reply_ready && !slot.failed && !slot.timed_out) { + int handled = aether_scheduler_poll(1); + if (slot.reply_ready || slot.failed || slot.timed_out) break; + if (aether_now_ns() >= give_up) { slot.timed_out = 1; break; } + if (handled > 0) continue; + if (!coop_timeout_armed()) break; + // Nothing to handle while a countdown runs: wait for it as + // scheduler_wait does, a millisecond at a time. + #ifdef _WIN32 + Sleep(1); + #elif !defined(__EMSCRIPTEN__) + usleep(1000); + #endif } + // Ended with its message maybe still queued (the deadline passed behind + // a slower handler): the message must not keep pointing at this stack. + if (!slot.reply_ready && !slot.failed) coop_forget_slot(target, &slot); if (slot.reply_ready) *status = AETHER_ASK_REPLIED; else if (slot.failed) *status = slot.failed; @@ -661,6 +870,9 @@ void scheduler_reply_owned(ActorBase* self, void* data, size_t data_size, ActorReplySlot* slot = (ActorReplySlot*)g_current_reply_slot; g_current_reply_slot = NULL; /* answered: the step's end has nothing to fail */ int delivered = 0; + // A reply past the asker's deadline is late (#3330): the ask timed out. + if (slot && !slot->timed_out && slot->deadline_ns && aether_now_ns() > slot->deadline_ns) + slot->timed_out = 1; if (slot && !slot->timed_out) { slot->reply_data = malloc(data_size); if (slot->reply_data) { diff --git a/runtime/scheduler/multicore_scheduler.c b/runtime/scheduler/multicore_scheduler.c index fcfd97784..fa134becf 100644 --- a/runtime/scheduler/multicore_scheduler.c +++ b/runtime/scheduler/multicore_scheduler.c @@ -7,6 +7,7 @@ #endif #include +#include "../utils/aether_fatal.h" /* aether_fatal_flush (#3435) */ #include #include #include // LONG_MAX (least-loaded placement scan) @@ -29,6 +30,8 @@ #include "../actors/aether_send_buffer.h" #include "../actors/aether_panic.h" #include "../actors/aether_actor_inbox.h" +#include "../actors/aether_step_barrier.h" +#include "../aether_resource_caps.h" // Forward declaration to avoid header cycle with aether_send_message.h @@ -84,6 +87,10 @@ extern AETHER_TLS int current_core_id; static void reply_slot_decref(ActorReplySlot* slot); static void reply_slot_fail(ActorReplySlot* slot, int why); static void release_undelivered(ActorBase* actor, Message* msg); +static void release_undelivered_at_end(ActorBase* actor, Message* msg); +/* An ended actor's death, on its own thread while it steps nothing + * (#2974): defined with the lifecycle below. */ +static void actor_finalize(ActorBase* actor); /* The reply slot of the ask the stepping actor holds (defined with the * ask/reply machinery below), for a death that leaves it unanswered. */ extern AETHER_TLS void* g_current_reply_slot; @@ -123,6 +130,7 @@ static inline ActorInbox* actor_overflow_pending(ActorBase* actor) { * out-of-memory deferred send does (overflow_append). */ static void actor_overflow_push(ActorBase* actor, ActorInbox* ib, Message msg) { if (likely(actor_inbox_push(ib, msg))) return; + aether_fatal_flush(); /* the program's lines first (#3435, aether_fatal.h) */ fprintf(stderr, "aether: out of memory queueing a message for actor %d behind its " "overflow\n", actor->id); abort(); @@ -165,167 +173,63 @@ static int actor_overflow_drain(ActorBase* actor) { Message spill[AETHER_INBOX_RING]; int got; while ((got = actor_inbox_take(ib, spill, AETHER_INBOX_RING)) > 0) { - for (int k = 0; k < got; k++) release_undelivered(actor, &spill[k]); + for (int k = 0; k < got; k++) release_undelivered_at_end(actor, &spill[k]); drained += got; } return drained; } // -------------------------------------------------------------------------- -// aether_step_safe — call actor->step() inside a sigsetjmp barrier. +// aether_step_safe: one step of `actor` inside the panic barrier, the one +// both schedulers share (runtime/actors/aether_step_barrier.h, #2974), with +// this scheduler's parts: its death finalize, its reply slot's failure, and +// after a step the mailbox's refill from the overflow (#3241). // // If the handler calls aether_panic() or takes a caught signal, the barrier -// unwinds: we mark the actor dead, fire the death hook, and return normally. -// The scheduler loop keeps running; other actors are unaffected. +// unwinds: the actor ends, its death is finalized, the death hook fires, and +// the scheduler loop keeps running; other actors are unaffected. Dead actors +// are skipped on entry: a second attempt to step a crashed actor is a no-op. // -// Dead actors are skipped on entry: a second attempt to step a crashed -// actor is a no-op. -// -// sigsetjmp(buf, 1) saves the signal mask so siglongjmp from a signal -// handler (Phase 2) restores it. Overhead per call is a handful of -// instructions plus one sigprocmask on systems where glibc implements it -// as a syscall — measurable but small (~30ns on modern x86_64). -// -// We use a function rather than a macro so the 8 step() sites stay -// readable; `inline` lets the compiler devirtualize where it can. +// The step lock is NOT released by the barrier (#2163): all its call sites +// take it before calling and clear it after, and four of them step in a +// loop under one acquisition. Cleared on the panic path, as it once was, it +// was handed away while the caller still believed it held it, the caller +// cleared it again on its way out, and two threads were soon stepping one +// actor (heap corruption, `free(): invalid pointer` in the #2083 test). // -------------------------------------------------------------------------- -static inline void aether_step_safe(ActorBase* actor) { - if (!actor || !actor->step) return; - if (atomic_load_explicit(&actor->dead, memory_order_acquire)) return; - - /* The step context (the ask being handled, the actor stepping) is the - * thread's, and a step can run inside another: a send steps an idle - * actor of this core inline while the sender's handler is still - * running, its ask not yet answered. Each step starts with no ask in - * hand and gives the enclosing one its context back when it ends; the - * inner step used to leave its own in place, and the outer handler's - * reply then went nowhere. Neither variable changes between the - * sigsetjmp and a longjmp back to it, so both are intact on the panic - * path. */ - void* outer_slot = g_current_reply_slot; - void* outer_actor = g_current_step_actor; - g_current_reply_slot = NULL; +static void step_fail_slot(void* slot, int why) { + reply_slot_fail((ActorReplySlot*)slot, why); +} - AetherJmpFrame* f = aether_try_push(); - if (AETHER_SIGSETJMP(f->buf, 1) == 0) { - g_aether_in_actor_step = 1; - g_aether_current_actor_id = actor->id; - AETHER_TRACE_EVENT(AE_TRACE_STEP_BEGIN, actor->id, -1, -1); - actor->step(actor); - AETHER_TRACE_EVENT(AE_TRACE_STEP_END, actor->id, -1, -1); - g_aether_in_actor_step = 0; - g_aether_current_actor_id = -1; - aether_try_pop(); - /* A handler that took an ask and returned without replying: no - * reply can come, since a reply answers the ask its own step took. - * Its asker learns that now, not at its timeout, and the slot's - * reference goes with it. */ - if (g_current_reply_slot) { - ActorReplySlot* held = (ActorReplySlot*)g_current_reply_slot; - g_current_reply_slot = NULL; - reply_slot_fail(held, AETHER_ASK_NO_REPLY); - } - /* The mailbox refilled from the overflow (#3241). An actor with a - * thread of its own takes every message through its inbox, which - * its thread empties. */ - if (likely(!actor->auto_process)) { - ActorInbox* behind = atomic_load_explicit(&actor->inbox, memory_order_acquire); - if (unlikely(behind != NULL)) actor_overflow_refill(actor, behind); - } - g_current_reply_slot = outer_slot; - g_current_step_actor = outer_actor; - return; +static void step_after(ActorBase* actor) { + /* The mailbox refilled from the overflow (#3241). An actor with a + * thread of its own takes every message through its inbox, which its + * thread empties. */ + if (likely(!actor->auto_process)) { + ActorInbox* behind = atomic_load_explicit(&actor->inbox, memory_order_acquire); + if (unlikely(behind != NULL)) actor_overflow_refill(actor, behind); } +} - // Unwound via panic or signal. - const char* reason = f->reason; - void (*reason_release)(const void*) = f->reason_release; - g_aether_in_actor_step = 0; - g_aether_current_actor_id = -1; - aether_try_pop(); - - // A bit, not a store: a release may be setting its own bit meanwhile. - atomic_fetch_or_explicit(&actor->dead, AETHER_ACTOR_PANICKED, memory_order_release); - atomic_store_explicit(&actor->active, 0, memory_order_release); - - /* The message its handler was running on (#3142): the release that - * follows the handler did not run, so what the message owns and its - * payload go here, as a dropped message's do. */ - if (actor->step_payload) { - Message held = {0}; - held.type = actor->step_type; - held.payload_ptr = actor->step_payload; - actor->step_payload = NULL; - aether_message_drop(actor, &held); - } - - /* The ask it was handling, if it had not replied (#3151): its asker - * learns now that no reply is coming, instead of at its timeout. */ - if (g_current_reply_slot) { - ActorReplySlot* held = (ActorReplySlot*)g_current_reply_slot; - g_current_reply_slot = NULL; - reply_slot_fail(held, AETHER_ASK_DIED); - } - - // Drain any pending messages so scheduler_wait() sees counters converge. - // Without this the dead actor's mailbox keeps mailbox.count > 0 forever, - // scheduler threads skip the actor (dead check), and sent > processed - // indefinitely — scheduler_wait() would spin or time out. - int drained = 0; - Message _discard; - while (mailbox_receive(&actor->mailbox, &_discard)) { - release_undelivered(actor, &_discard); - drained++; - } - /* And what waits behind it in its overflow (#3241), counted as sent - * the same way. An actor with a thread of its own keeps its inbox - * until it is ended. */ - if (!actor->auto_process) drained += actor_overflow_drain(actor); - if (drained > 0) { - if (current_core_id >= 0 && current_core_id < num_cores) { - atomic_fetch_add_explicit( - &schedulers[current_core_id].messages_processed, - (uint64_t)drained, memory_order_relaxed); - } else { - // Main-thread death: attribute to core 0 so the global sent/processed - // accounting balances out. - atomic_fetch_add_explicit( - &schedulers[0].messages_processed, - (uint64_t)drained, memory_order_relaxed); - } - } +/* 1 when the step took a message, which its caller counts handled; 0 when it + * took none: a dead actor is not stepped (its finalize credits what waits), + * and a step of an empty mailbox fires a timeout's arm or nothing. Every + * caller holds the step lock, so no other step takes a message meanwhile, + * and a step of a mailbox with one takes it. Counted by the step call, a + * call that took nothing (an actor killed mid-drain, one stolen between a + * caller's look and its lock) credited a message never handled, and the + * handled count passed the sent count for good, hiding as many pending + * messages from wait_for_idle() (#3464). */ +static inline int aether_step_safe(ActorBase* actor) { + if (!actor || !actor->step) return 0; + if (atomic_load_explicit(&actor->dead, memory_order_acquire)) return 0; + int took = atomic_load_explicit(&actor->mailbox.count, memory_order_acquire) > 0; + aether_step_barrier(actor, actor_finalize, step_fail_slot, step_after); + return took; +} - /* step_lock is NOT released here (#2163). This function does not - * acquire it -- all seven call sites take it before calling and clear - * it after -- so clearing it on the panic path handed the lock away - * while the caller still believed it held it: - * - * 1. this clears the flag; - * 2. another thread acquires it and starts stepping the same actor; - * 3. the caller clears it again on its way out, releasing a lock it - * no longer owns; - * 4. a third thread enters, and two are now stepping one actor. - * - * Two threads in one actor's step is heap corruption -- it showed up - * as `free(): invalid pointer` in the #2083 regression test. Four of - * the call sites also step in a loop under a single acquisition, so - * the lock was being dropped mid-loop. - * - * The comment this replaces justified the clear as letting "the - * dead-actor check in future iterations proceed without contention". - * That check is the atomic_load(&actor->dead) at the top of this - * function, which returns before touching the lock at all, so it - * never needed the lock released to make progress. */ - aether_fire_death_hook(actor->id, reason); - /* A heap-built panic message (aether_panic_owned) belongs to whoever - * caught it -- here, this barrier. The hook has seen it; release it. */ - if (reason_release) reason_release(reason); - g_current_reply_slot = outer_slot; - g_current_step_actor = outer_actor; -} - -void scheduler_step_actor(ActorBase* actor) { - aether_step_safe(actor); +int scheduler_step_actor(ActorBase* actor) { + return aether_step_safe(actor); } /* Layout assertions to catch struct padding/size mismatches between @@ -670,11 +574,201 @@ void scheduler_actor_thread_deliver(ActorBase* actor, Message msg) { fprintf(stderr, "aether: out of memory queueing a message for actor %d; " "it is dropped\n", actor->id); release_undelivered(actor, &msg); - atomic_fetch_add_explicit(&schedulers[0].messages_processed, 1, memory_order_relaxed); + atomic_fetch_add_explicit(&schedulers[0].messages_processed, 1, memory_order_release); return; } atomic_store_explicit(&actor->active, 1, memory_order_relaxed); - actor_thread_wake(actor); + actor_thread_wake(actor); // its seq_cst fence orders the push before the load below + /* The actor ended (#3298): its thread drains its inbox as it finalizes + * the death (actor_finalize, which fences after the death bit), and a + * message pushed after that drain would wait there uncredited, holding + * scheduler_wait up. One of the two sees the other: this sender drops + * what is left. A released actor's inbox goes with its block. */ + int ended = atomic_load_explicit(&actor->dead, memory_order_acquire); + if (unlikely(ended & AETHER_ACTOR_ENDED) && !(ended & AETHER_ACTOR_RELEASED) && + (atomic_load_explicit(&actor->exit_flags, memory_order_acquire) & AETHER_EXIT_FINALIZED)) { + int dropped = actor_overflow_drain(actor); + if (dropped > 0) { + atomic_fetch_add_explicit(&schedulers[0].messages_processed, (uint64_t)dropped, + memory_order_release); + scheduler_count_dead_sends(dropped); + } + } +} + +// --------------------------------------------------------------------------- +// The end of an actor (#2974) +// +// An actor ends once, by the first of its death bits (aether_actor_claim_end): +// a panic in its step, a kill or a linked actor's death (KILLED), its own +// stop() (STOPPED), or a release. The death is finalized on the thread that +// owns the actor's mailbox, while the actor steps nothing: what waits for it +// is dropped and credited, and its watchers are told (aether_watch_notify), +// so no Down is sent while its handler still runs and each sender's last +// messages before it arrive first. A panic is finalized in the step barrier, +// a stop() and an end that came during a step after the step, an actor +// thread's death at the end of its thread's loop, and an end from elsewhere +// to an actor not stepping by its core, through the core's control requests. +// A release is its own path: its watchers are told at the release and its +// messages dropped when its block is reclaimed. +// --------------------------------------------------------------------------- + +static _Atomic uint64_t g_dead_sends = 0; + +uint64_t scheduler_dead_sends(void) { + return atomic_load_explicit(&g_dead_sends, memory_order_relaxed); +} + +void scheduler_count_dead_sends(int n) { + if (n > 0) atomic_fetch_add_explicit(&g_dead_sends, (uint64_t)n, memory_order_relaxed); +} + +static void actor_finalize(ActorBase* actor) { + if (atomic_fetch_or_explicit(&actor->exit_flags, AETHER_EXIT_FINALIZED, memory_order_acq_rel) & + AETHER_EXIT_FINALIZED) + return; + // Pairs with a sender's fence after its push into an actor thread's + // inbox (scheduler_actor_thread_deliver, #3298). + atomic_thread_fence(memory_order_seq_cst); + int drained = 0; + Message discard; + while (mailbox_receive(&actor->mailbox, &discard)) { + release_undelivered_at_end(actor, &discard); + drained++; + } + // Its overflow (#3241), or an actor thread's inbox (#3298). + drained += actor_overflow_drain(actor); + if (drained > 0) { + int c = current_core_id; + Scheduler* credit = (c >= 0 && c < num_cores) ? &schedulers[c] : &schedulers[0]; + atomic_fetch_add_explicit(&credit->messages_processed, (uint64_t)drained, + memory_order_release); + scheduler_count_dead_sends(drained); + } + const char* reason = aether_actor_death_reason(actor); + aether_watch_notify(actor, reason ? reason : "normal"); +} + +void scheduler_actor_thread_finalize(ActorBase* actor) { + int d = atomic_load_explicit(&actor->dead, memory_order_acquire); + if ((d & AETHER_ACTOR_ENDED) && !(d & AETHER_ACTOR_RELEASED)) actor_finalize(actor); +} + +/* A finalize request for `actor`, to core `core`'s loop. */ +static void control_post(int core, ActorBase* actor) { + Scheduler* s = &schedulers[core]; + while (atomic_flag_test_and_set_explicit(&s->control_lock, memory_order_acquire)) AETHER_PAUSE(); + if (s->control_count == s->control_cap) { + int cap = s->control_cap ? s->control_cap * 2 : 8; + ActorBase** grown = (ActorBase**)realloc(s->control, (size_t)cap * sizeof(ActorBase*)); + if (!grown) { + atomic_flag_clear_explicit(&s->control_lock, memory_order_release); + fprintf(stderr, "aether: out of memory ending actor %d; it ends at its next step\n", + actor->id); + return; + } + s->control = grown; + s->control_cap = cap; + } + s->control[s->control_count++] = actor; + // Counted until the request is done, for scheduler_wait: the Downs + // its finalize sends are not sent yet. + atomic_fetch_add_explicit(&s->control_pending, 1, memory_order_release); + atomic_flag_clear_explicit(&s->control_lock, memory_order_release); + sched_wake(s); +} + +/* The core's finalize requests, on its thread where it steps nothing, or + * from its relief: an actor stepping lower on this thread is finalized by + * its own step's end instead. One that moved is passed to its core. */ +static void control_run(Scheduler* sched) { + if (likely(!atomic_load_explicit(&sched->control_pending, memory_order_acquire))) return; + while (atomic_flag_test_and_set_explicit(&sched->control_lock, memory_order_acquire)) AETHER_PAUSE(); + ActorBase** list = sched->control; + int n = sched->control_count; + sched->control = NULL; + sched->control_count = 0; + sched->control_cap = 0; + atomic_flag_clear_explicit(&sched->control_lock, memory_order_release); + for (int i = 0; i < n; i++) { + ActorBase* actor = list[i]; + int d = atomic_load_explicit(&actor->dead, memory_order_acquire); + int core = atomic_load_explicit(&actor->assigned_core, memory_order_acquire); + if (!(d & AETHER_ACTOR_ENDED) || (d & AETHER_ACTOR_RELEASED) || + (atomic_load_explicit(&actor->exit_flags, memory_order_acquire) & AETHER_EXIT_FINALIZED)) { + // Nothing to do. + } else if (core != sched->core_id && core >= 0 && core < num_cores) { + control_post(core, actor); + } else if (!atomic_flag_test_and_set_explicit(&actor->step_lock, memory_order_acquire)) { + // Read again under the lock: a restart in place may have + // brought it alive meanwhile (#2974). + d = atomic_load_explicit(&actor->dead, memory_order_acquire); + if ((d & AETHER_ACTOR_ENDED) && !(d & AETHER_ACTOR_RELEASED)) actor_finalize(actor); + atomic_flag_clear_explicit(&actor->step_lock, memory_order_release); + } + // else stepping lower on this thread: its step's end finalizes it. + atomic_fetch_sub_explicit(&sched->control_pending, 1, memory_order_release); + } + free(list); +} + +/* An ask the ended actor is waiting in, as its core's innermost ask, ends at + * once (its wait checks the bit when woken). Read under the core's lock, + * under which the asker puts the slot back before it lets it go. */ +static void wake_ended_asker(ActorBase* actor) { + int core = atomic_load_explicit(&actor->assigned_core, memory_order_acquire); + if (core < 0 || core >= num_cores) return; + Scheduler* s = &schedulers[core]; + while (atomic_flag_test_and_set_explicit(&s->actor_lock.lock, memory_order_acquire)) AETHER_PAUSE(); + if (atomic_load_explicit(&s->asking, memory_order_acquire) > 0 && + atomic_load_explicit(&s->ask_asker, memory_order_acquire) == actor) { + ActorReplySlot* slot = atomic_load_explicit(&s->ask_slot, memory_order_acquire); + if (slot) { + pthread_mutex_lock(&slot->mutex); + pthread_cond_signal(&slot->cond); + pthread_mutex_unlock(&slot->mutex); + } + } + atomic_flag_clear_explicit(&s->actor_lock.lock, memory_order_release); +} + +/* A reborn actor is alive (#2974): its core is woken, which steps it if a + * receive timeout of its is armed. */ +void scheduler_actor_revived(ActorBase* actor) { + int core = atomic_load_explicit(&actor->assigned_core, memory_order_acquire); + if (core >= 0 && core < num_cores) sched_wake(&schedulers[core]); +} + +int scheduler_actor_end(ActorBase* actor, const char* reason, int bit) { + if (!actor || !aether_actor_claim_end(actor, reason, bit)) return 0; + if (actor->auto_process) { + // Its thread leaves its loop at the bit and finalizes the death + // there; asleep in its park, it is woken. Gone already, nothing else + // takes its mailbox or inbox, and the death is finalized here. + if (atomic_load_explicit(&actor->dead, memory_order_acquire) & AETHER_ACTOR_THREAD_GONE) + actor_finalize(actor); + else + actor_park_broadcast(actor_park_core(actor)); + return 1; + } + wake_ended_asker(actor); + int core = atomic_load_explicit(&actor->assigned_core, memory_order_acquire); + /* #3399: posted only where a core thread will take the request, once + * the scheduler is initialized and its threads started. Initialized + * is not running: scheduler_init sets the state before + * scheduler_start makes the threads, and a request posted between the + * two waited for a start that a C host may never make, so the death + * was never finalized, no Down went out and no link took it. */ + if (core >= 0 && core < num_cores && atomic_load(&g_sched_state) == 2 && + atomic_load_explicit(&g_threads_started, memory_order_acquire)) { + control_post(core, actor); + } else { + // No core runs it (the scheduler is not running, or its threads are + // not started): nothing steps it or delivers to it, so it is + // finalized here. + actor_finalize(actor); + } + return 1; } static void overflow_append(int target, ActorBase* actor, Message msg) { @@ -698,6 +792,7 @@ static void overflow_append(int target, ActorBase* actor, Message msg) { while (nc <= tail) nc *= 2; ActorBase** new_actors = realloc(b->actors, (size_t)nc * sizeof(ActorBase*)); if (unlikely(!new_actors)) { + aether_fatal_flush(); /* the program's lines first (#3435, aether_fatal.h) */ fprintf(stderr, "aether: OOM in overflow_append (target=%d count=%d cap=%d)\n", target, b->count, b->capacity); abort(); @@ -705,6 +800,7 @@ static void overflow_append(int target, ActorBase* actor, Message msg) { b->actors = new_actors; Message* new_msgs = realloc(b->msgs, (size_t)nc * sizeof(Message)); if (unlikely(!new_msgs)) { + aether_fatal_flush(); /* the program's lines first (#3435, aether_fatal.h) */ fprintf(stderr, "aether: OOM in overflow_append (target=%d count=%d cap=%d)\n", target, b->count, b->capacity); abort(); @@ -737,7 +833,9 @@ static void overflow_release(Scheduler* sched) { for (int i = b->head; i < b->head + b->count; i++) { ActorBase* actor = b->actors[i]; if (actor && !actor->auto_process) inflight_leave(actor, 1); - scheduler_drop_dead_message(sched, actor, &b->msgs[i]); + // Not a death's drop: the cores are stopping. + release_undelivered(actor, &b->msgs[i]); + atomic_fetch_add_explicit(&sched->messages_processed, 1, memory_order_release); dropped++; } free(b->actors); @@ -834,15 +932,15 @@ static void overflow_flush_ex(int from_core, int own) { // Immediately process if step_lock available if (actor->step && !atomic_flag_test_and_set_explicit(&actor->step_lock, memory_order_acquire)) { - int p = 0; + int p = 0, took = 0; while (atomic_load_explicit(&actor->mailbox.count, memory_order_relaxed) > 0 && p < 16 && atomic_load_explicit(&actor->assigned_core, memory_order_relaxed) == from_core) { - aether_step_safe(actor); + took += aether_step_safe(actor); p++; } atomic_flag_clear_explicit(&actor->step_lock, memory_order_release); - direct_processed += p; + direct_processed += took; } } else { // Mailbox full — keep in overflow @@ -1348,8 +1446,13 @@ static void reclaim_reader_exit(int slot) { // stays marked released, in the bucket for its size, and the next spawn of // that size takes it back. A late send to it is dropped, counted and reported // once. One made after the block has become another actor reaches that -// actor: a raw pointer cannot tell the two apart. The buckets never hold -// more than was once live at the same time, and are freed with the tables. +// actor: a raw pointer cannot tell the two apart. A block goes back to its +// bucket once it is reclaimed, and a spawn takes it once no message is on +// its way to it; a spawn before then takes a fresh block. So a bucket holds +// at most as many blocks as were once, at the same time, live, released and +// not yet reclaimed, or awaited by a message: how many depends on the cores' +// timing as well as on the program (#3385). The buckets are freed with the +// tables. // --------------------------------------------------------------------------- typedef struct { @@ -1469,10 +1572,20 @@ uint64_t scheduler_released_sends(void) { // actor is not counted yet, one taken from its queues is. A released target // means a send against the release contract. static void release_undelivered(ActorBase* actor, Message* msg) { + aether_mailbox_untake(actor, msg->type); // its place under a bound (#2974) aether_message_drop(actor, msg); if (msg->_reply_slot) reply_slot_fail((ActorReplySlot*)msg->_reply_slot, AETHER_ASK_DEAD); } +/* As release_undelivered, for what an ended actor leaves undelivered as its + * death is finalized (#3401): a panic in the release is reported and the + * drain goes on (aether_message_drop_at_end). */ +static void release_undelivered_at_end(ActorBase* actor, Message* msg) { + aether_mailbox_untake(actor, msg->type); + aether_message_drop_at_end(actor, msg); + if (msg->_reply_slot) reply_slot_fail((ActorReplySlot*)msg->_reply_slot, AETHER_ASK_DEAD); +} + static void send_to_dead_actor(ActorBase* actor, Message* msg) { AETHER_TRACE_EVENT(AE_TRACE_DROP_DEAD, actor ? actor->id : 0, msg->type, msg->sender_id); release_undelivered(actor, msg); @@ -1482,9 +1595,43 @@ static void send_to_dead_actor(ActorBase* actor, Message* msg) { fprintf(stderr, "aether: message type %d sent to actor %d after its release; " "dropped (reported once)\n", msg->type, actor->id); } + } else if (actor) { + scheduler_count_dead_sends(1); // a panicked, killed or stopped one (#2974) } } +// Plain sends dropped at a full bounded mailbox (#2974), and their one +// diagnostic. +static _Atomic uint64_t g_full_drops = 0; +static atomic_flag g_full_drop_reported = ATOMIC_FLAG_INIT; + +uint64_t scheduler_full_drops(void) { + return atomic_load_explicit(&g_full_drops, memory_order_relaxed); +} + +/* A send to a bounded actor (#2974): 1 when `msg` is accepted (or the + * actor has no bound). Refused, it is released here, never blocked on: + * an ask learns at once that the mailbox is full, a plain send is counted + * in the actor's full_drops and the total, reported once. It never took a + * place, so the release is not release_undelivered's. */ +static int bound_admits(ActorBase* actor, Message* msg) { + if (aether_mailbox_admit(actor, msg->type)) return 1; + aether_message_drop(actor, msg); + if (msg->_reply_slot) { + reply_slot_fail((ActorReplySlot*)msg->_reply_slot, AETHER_ASK_FULL); + return 0; + } + atomic_fetch_add_explicit(&actor->full_drops, 1, memory_order_relaxed); + atomic_fetch_add_explicit(&g_full_drops, 1, memory_order_relaxed); + if (!atomic_flag_test_and_set_explicit(&g_full_drop_reported, memory_order_relaxed)) { + fprintf(stderr, "aether: message type %d to actor %d dropped: its mailbox is full " + "(bound %d); counted by actors.full_drops (reported once)\n", + msg->type, actor->id, + atomic_load_explicit(&actor->mailbox_bound, memory_order_relaxed)); + } + return 0; +} + /* #2912: a thread the scheduler does not own (the main thread, a std.worker * job, an http pool worker, a C host's thread) reaches a core's queues and * lock only while the scheduler runs. It counts itself in g_foreign_senders, @@ -1557,7 +1704,7 @@ static void actor_free_now(ActorBase* actor) { } if (drained > 0) { atomic_fetch_add_explicit(&schedulers[0].messages_processed, - (uint64_t)drained, memory_order_relaxed); + (uint64_t)drained, memory_order_release); } // So is whatever still waits in an actor thread's inbox (#2598): it was // counted as sent and is released and credited the same way. The inbox @@ -1574,10 +1721,12 @@ static void actor_free_now(ActorBase* actor) { } if (left > 0) { atomic_fetch_add_explicit(&schedulers[0].messages_processed, - (uint64_t)left, memory_order_relaxed); + (uint64_t)left, memory_order_release); } free(ib); } + // Its death notices go with it (#2974). + aether_watch_free(actor); if (actor->spsc_queue) { free(actor->spsc_queue); actor->spsc_queue = NULL; @@ -1716,7 +1865,12 @@ static inline int has_pending_cross_core(Scheduler* sched) { * and parks wait_for_idle. */ static void scheduler_drop_dead_message(Scheduler* sched, ActorBase* actor, Message* msg) { release_undelivered(actor, msg); - atomic_fetch_add_explicit(&sched->messages_processed, 1, memory_order_relaxed); + atomic_fetch_add_explicit(&sched->messages_processed, 1, memory_order_release); + // On its way when its target died (#2974); a released target's are its + // own count's. Counted by the caller's finding, the target dead: a + // restart in place may have cleared the bit since. + if (actor && !(atomic_load_explicit(&actor->dead, memory_order_relaxed) & AETHER_ACTOR_RELEASED)) + scheduler_count_dead_sends(1); } // Whether nothing sent from core `core` is still on its way: no core's queue @@ -1820,15 +1974,17 @@ static int make_room(Scheduler* sched, ActorBase* actor) { int stepped = 0; while (atomic_load_explicit(&actor->mailbox.count, memory_order_acquire) > MAILBOX_SIZE / 2 && !atomic_load_explicit(&actor->dead, memory_order_acquire)) { - aether_step_safe(actor); - stepped++; + stepped += aether_step_safe(actor); } sched->messages_processed += stepped; atomic_flag_clear_explicit(&actor->step_lock, memory_order_release); } else { AETHER_PAUSE(); } - return 1; + // Dead by a step here (a panic, a stop): it takes no more. Its death + // drained the mailbox, and what the caller would put there next would + // wait, never handled nor credited, in a dead actor's mailbox. + return !atomic_load_explicit(&actor->dead, memory_order_acquire); } void* AETHER_HOT scheduler_thread(void* arg) { @@ -1875,6 +2031,9 @@ void* AETHER_HOT scheduler_thread(void* arg) { } } + // Deaths to finalize here, posted from elsewhere (#2974). + control_run(sched); + // Flush any deferred sends from the previous iteration before draining // from_queues. This ensures deferred messages land on every loop and // prevents them from indefinitely blocking behind a saturated queue. @@ -2004,7 +2163,12 @@ void* AETHER_HOT scheduler_thread(void* arg) { // Dead, or never stepped (no handler): it takes no more // messages, as a send to a dead actor is dropped. inflight_leave(actor, 1); - scheduler_drop_dead_message(sched, actor, &sched->coalesce_buffer.messages[k]); + if (actor->step) { + scheduler_drop_dead_message(sched, actor, &sched->coalesce_buffer.messages[k]); + } else { + release_undelivered(actor, &sched->coalesce_buffer.messages[k]); + atomic_fetch_add_explicit(&sched->messages_processed, 1, memory_order_release); + } } int delivered = placed - i; i = run_end - 1; @@ -2031,19 +2195,21 @@ void* AETHER_HOT scheduler_thread(void* arg) { if (likely(actor->step) && atomic_load_explicit(&actor->assigned_core, memory_order_relaxed) == sched->core_id && !atomic_flag_test_and_set_explicit(&actor->step_lock, memory_order_acquire)) { - int processed = 0; + int processed = 0, steps = 0; while (atomic_load_explicit(&actor->mailbox.count, memory_order_relaxed) > 0 && - processed < 64 && + steps < 64 && atomic_load_explicit(&actor->assigned_core, memory_order_relaxed) == sched->core_id) { - aether_step_safe(actor); - processed++; + int took = aether_step_safe(actor); + processed += took; + steps++; + if (!took) break; // dead: its finalize credits what waits // Yield to outer loop if cross-core messages arrived // (e.g. StopAnimation sent from main while actor sleeps). if (has_pending_cross_core(sched)) break; } atomic_flag_clear_explicit(&actor->step_lock, memory_order_release); sched->messages_processed += processed; - if (processed > 0 && + if (steps > 0 && atomic_load_explicit(&actor->mailbox.count, memory_order_relaxed) == 0) { atomic_store_explicit(&actor->active, 0, memory_order_relaxed); } @@ -2102,8 +2268,12 @@ void* AETHER_HOT scheduler_thread(void* arg) { !actor->step) continue; if (aether_now_ns() - actor->last_activity_ns < actor->timeout_ns) continue; if (!atomic_flag_test_and_set_explicit(&actor->step_lock, memory_order_acquire)) { - if (atomic_load_explicit(&actor->assigned_core, memory_order_relaxed) == sched->core_id) - aether_step_safe(actor); + if (atomic_load_explicit(&actor->assigned_core, memory_order_relaxed) == sched->core_id) { + scheduler_countdown_enter(); // until its arm returns (#3443) + /* A message that came first is what it takes: counted (#3464). */ + sched->messages_processed += aether_step_safe(actor); + scheduler_countdown_leave(); + } atomic_flag_clear_explicit(&actor->step_lock, memory_order_release); } work_done = 1; @@ -2115,13 +2285,15 @@ void* AETHER_HOT scheduler_thread(void* arg) { } if (likely(actor->step)) { - int processed = 0; + int processed = 0, steps = 0; if (!atomic_flag_test_and_set_explicit(&actor->step_lock, memory_order_acquire)) { while (atomic_load_explicit(&actor->mailbox.count, memory_order_relaxed) > 0 && - processed < 64 && + steps < 64 && atomic_load_explicit(&actor->assigned_core, memory_order_relaxed) == sched->core_id) { - aether_step_safe(actor); - processed++; + int took = aether_step_safe(actor); + processed += took; + steps++; + if (!took) break; // dead: its finalize credits what waits // Yield to outer loop if cross-core messages arrived // (e.g. StopAnimation sent from main while actor sleeps). if (has_pending_cross_core(sched)) break; @@ -2129,7 +2301,7 @@ void* AETHER_HOT scheduler_thread(void* arg) { atomic_flag_clear_explicit(&actor->step_lock, memory_order_release); } sched->messages_processed += processed; - if (processed > 0 && + if (steps > 0 && atomic_load_explicit(&actor->mailbox.count, memory_order_relaxed) == 0 && atomic_load_explicit(&actor->assigned_core, memory_order_relaxed) == sched->core_id) { atomic_store_explicit(&actor->active, 0, memory_order_relaxed); @@ -2328,6 +2500,12 @@ void scheduler_init(int cores) { return; } + // Before this copy's first step, its sandbox joins another copy's count + // holding levels this copy's steps may run inside (#3438). Through a + // pointer, so the scheduler links nothing of the sandbox (#3393). + void (*join)(void) = atomic_load_explicit(&aether_sandbox_join, memory_order_acquire); + if (join) join(); + // A scheduler stopped by scheduler_shutdown() keeps its per-core tables // (an executable exits right after). Starting it again -- a host running // an --emit=lib program's aether_main() a second time -- allocates new @@ -2424,6 +2602,12 @@ void scheduler_init(int cores) { atomic_store_explicit(&schedulers[i].asking, 0, memory_order_relaxed); atomic_store_explicit(&schedulers[i].ask_target, NULL, memory_order_relaxed); atomic_store_explicit(&schedulers[i].ask_slot, NULL, memory_order_relaxed); + atomic_store_explicit(&schedulers[i].ask_asker, NULL, memory_order_relaxed); + atomic_flag_clear_explicit(&schedulers[i].control_lock, memory_order_relaxed); + atomic_store_explicit(&schedulers[i].control_pending, 0, memory_order_relaxed); + schedulers[i].control = NULL; + schedulers[i].control_count = 0; + schedulers[i].control_cap = 0; schedulers[i].relief_cursor = 0; schedulers[i].coalesce_buffer.count = 0; schedulers[i].coalesce_buffer.next = 0; @@ -2515,16 +2699,28 @@ void scheduler_stop(void) { static inline int count_pending_messages(void) { int total = 0; uint64_t total_sent = 0, total_proc = 0; + /* Every core's handled count first, then every core's sent count: a + * message is counted sent before its receiver can count it handled (the + * count comes before the enqueue the receiver takes it from), so one + * counted handled here is counted sent too, and one pending all along + * counts. Read one core at a time, the two counts let a message sent on + * a core already read and handled on one read after cancel the reply it + * sent, which was still pending: the wait returned with it in flight + * (std.stream's credits under UBSan). */ for (int i = 0; i < num_cores; i++) { Scheduler* core = &schedulers[i]; + total_proc += atomic_load_explicit(&core->messages_processed, memory_order_seq_cst); for (int q = 0; q <= MAX_CORES; q++) { total += queue_size(&core->from_queues[q]); } - total_sent += core->messages_sent; - total_proc += core->messages_processed; + // A death still to be finalized here (#2974): its Downs are not + // sent yet. + total += atomic_load_explicit(&core->control_pending, memory_order_acquire); } + for (int i = 0; i < num_cores; i++) + total_sent += atomic_load_explicit(&schedulers[i].messages_sent, memory_order_seq_cst); // Include main thread sends - total_sent += atomic_load_explicit(&main_thread_sent, memory_order_relaxed); + total_sent += atomic_load_explicit(&main_thread_sent, memory_order_seq_cst); // Include messages deferred in scheduler threads' TLS overflow buffers. int overflow = atomic_load_explicit(&g_overflow_total, memory_order_relaxed); if (overflow > 0) total += overflow; @@ -2545,6 +2741,30 @@ static inline int count_pending_messages(void) { // process new messages sent after this function returns. // Safe to call multiple times in a program (e.g. between test phases). // Check if any actor has a pending timeout +/* Receive timeouts firing now (#3443): the generated step disarms its + * countdown before the arm runs (codegen_actor.c, #3162), so from then on + * the actor looks idle while its arm still runs, and may still send. A step + * that fires a countdown is counted from before it starts until it returns, + * and wait_for_idle() waits for it as for a message. */ +static _Atomic int g_countdowns_firing = 0; +/* How many such steps ever returned: an arm that sent its last message and + * returned inside a quiet window, after the window's counts, moves it on, + * so the wait starts its window again (has_pending_actor_timeout alone read + * the firing count once, at the window's end). */ +static _Atomic uint64_t g_countdowns_done = 0; + +void scheduler_countdown_enter(void) { + atomic_fetch_add_explicit(&g_countdowns_firing, 1, memory_order_relaxed); + /* Before the step's disarm, a plain store: a fence, which an RMW alone + * is not for the stores after it (AArch64 without LSE). */ + atomic_thread_fence(memory_order_seq_cst); +} + +void scheduler_countdown_leave(void) { + atomic_fetch_add_explicit(&g_countdowns_done, 1, memory_order_release); + atomic_fetch_sub_explicit(&g_countdowns_firing, 1, memory_order_release); +} + static int has_pending_actor_timeout(void) { int found = 0; int slot = reclaim_reader_enter(); // #2509 @@ -2564,6 +2784,13 @@ static int has_pending_actor_timeout(void) { } } reclaim_reader_exit(slot); + /* A countdown seen disarmed above may be firing: its step counted itself + * (a full barrier) before it disarmed. Acquire: a step seen done here + * (its decrement read) has its count of the done read after it too, in + * the caller's check, which a relaxed load let a weak memory read first + * (AArch64, POWER). */ + atomic_thread_fence(memory_order_acquire); + if (atomic_load_explicit(&g_countdowns_firing, memory_order_acquire) > 0) found = 1; return found; } @@ -2573,7 +2800,22 @@ static int has_pending_actor_timeout(void) { * both (#3102); the end of main for the messages alone (#3219). */ static void scheduler_wait_quiet(int countdowns); +/* wait_for_idle() waits for every actor, the calling one too, so inside an + * actor's step (a handler, a receive timeout's arm) it waited for a step + * that cannot end while it waits: forever in a handler, whose message stays + * counted, and in an arm until the bound (#3463). So it did on any thread + * that runs actors, after a step as much as in one: a core's, where a drop + * or a death hook runs once the in-step flag is off and the step's message + * not yet counted handled, and an actor's own. There it panics, as the + * actor's code: the step barrier, or the death hook's or a contained drop's + * own frame, takes it, and the actor's watchers are told; a drop run outside + * any frame (a message to a dead actor) ends the process with the reason, + * as any panic there does. */ +AETHER_TLS int aether_in_actor_thread = 0; /* set by aether_actor_thread.c */ + void scheduler_wait(void) { + if (g_aether_in_actor_step || current_core_id >= 0 || aether_in_actor_thread) + aether_panic(AETHER_WAIT_IN_STEP_REASON); scheduler_wait_quiet(1); } @@ -2600,18 +2842,24 @@ static void scheduler_wait_quiet(int countdowns) { uint64_t last_processed = 0; int stall_checks = 0; int timeout_rounds = 0; + uint64_t quiet_done = 0; // countdown steps done when the window began (#3443) while (stable_count < 5) { atomic_thread_fence(memory_order_acquire); int pending = count_pending_messages(); if (pending == 0) { + if (stable_count == 0) + quiet_done = atomic_load_explicit(&g_countdowns_done, memory_order_acquire); stable_count++; // Quiet, but a receive timeout is armed (#3102): its core fires // it once the deadline passes, and its arm may send more, so the // wait goes on, polling every millisecond (100000 times at most). + // So it does when an arm ended inside the window, after the + // counts that found it quiet (#3443). if (countdowns && stable_count == 5 && timeout_rounds < 100000 && - has_pending_actor_timeout()) { + (has_pending_actor_timeout() || + atomic_load_explicit(&g_countdowns_done, memory_order_acquire) != quiet_done)) { stable_count = 0; timeout_rounds++; aether_usleep(1000); @@ -2753,6 +3001,7 @@ static void scheduler_free_core_tables(void) { free(ib); } } + aether_watch_free(actor); // its death notices (#2974) continue; } if (actor->auto_process) { @@ -2794,6 +3043,12 @@ static void scheduler_free_core_tables(void) { pthread_mutex_destroy(&schedulers[i].park_mutex); pthread_cond_destroy(&schedulers[i].park_cond); pthread_mutex_destroy(&schedulers[i].foreign_lock); + // Finalize requests left at the stop: the actors end with the tables. + free(schedulers[i].control); + schedulers[i].control = NULL; + schedulers[i].control_count = 0; + schedulers[i].control_cap = 0; + atomic_store_explicit(&schedulers[i].control_pending, 0, memory_order_relaxed); // Clean up I/O event loop aether_io_poller_destroy(&schedulers[i].io_poller); @@ -2827,6 +3082,12 @@ int scheduler_register_actor(ActorBase* actor, int preferred_core) { actor->step_payload = NULL; atomic_init(&actor->inbox, NULL); atomic_init(&actor->thread_parked, 0); + atomic_init(&actor->death_watch, NULL); // #2974 + atomic_init(&actor->exit_flags, 0); + atomic_init(&actor->incarnation, 0); + atomic_init(&actor->mailbox_bound, 0); // the generated spawn sets a bound (#2974) + atomic_init(&actor->mailbox_pending, 0); + atomic_init(&actor->full_drops, 0); return register_actor(actor, preferred_core); } @@ -3040,7 +3301,44 @@ static int scheduler_io_poll(Scheduler* sched, int timeout_ms) { static AETHER_TLS int inline_depth = 0; #define MAX_INLINE_DEPTH 2 +static void send_local_admitted(ActorBase* actor, Message msg); +static void send_remote_admitted(ActorBase* actor, Message msg, int from_core); + +/* A send's way in (#2974): a bounded actor's place is taken first, so every + * drop on the way, a dead target's included, gives one back. The bound is + * on the line `dead` is on. */ void scheduler_send_local(ActorBase* actor, Message msg) { + if (unlikely(actor && atomic_load_explicit(&actor->mailbox_bound, memory_order_relaxed)) && + !bound_admits(actor, &msg)) + return; + send_local_admitted(actor, msg); +} + +void scheduler_send_remote(ActorBase* actor, Message msg, int from_core) { + if (unlikely(actor && atomic_load_explicit(&actor->mailbox_bound, memory_order_relaxed)) && + !bound_admits(actor, &msg)) + return; + send_remote_admitted(actor, msg, from_core); +} + +const char* scheduler_try_send(ActorBase* actor, Message msg) { + if (!actor || atomic_load_explicit(&actor->dead, memory_order_acquire)) { + aether_message_drop(actor, &msg); + return "the actor is dead"; + } + if (!aether_mailbox_admit(actor, msg.type)) { + aether_message_drop(actor, &msg); + return "the mailbox is full"; + } + int me = current_core_id; + if (me >= 0 && me == atomic_load_explicit(&actor->assigned_core, memory_order_relaxed)) + send_local_admitted(actor, msg); + else + send_remote_admitted(actor, msg, me); + return ""; +} + +static void send_local_admitted(ActorBase* actor, Message msg) { AETHER_TRACE_EVENT(AE_TRACE_SEND_LOCAL, actor ? actor->id : 0, msg.type, msg.sender_id); // Drop messages to dead actors: they can't process them and we don't @@ -3123,8 +3421,7 @@ void scheduler_send_local(ActorBase* actor, Message msg) { atomic_load_explicit(&actor->mailbox.count, memory_order_relaxed) == 1 && !atomic_flag_test_and_set_explicit(&actor->step_lock, memory_order_acquire)) { inline_depth++; - aether_step_safe(actor); - schedulers[current_core_id].messages_processed++; + schedulers[current_core_id].messages_processed += aether_step_safe(actor); if (atomic_load_explicit(&actor->mailbox.count, memory_order_relaxed) == 0) { atomic_store_explicit(&actor->active, 0, memory_order_relaxed); } @@ -3140,7 +3437,7 @@ void scheduler_send_local(ActorBase* actor, Message msg) { static void send_through_core(ActorBase* actor, Message msg, int from_core); -void scheduler_send_remote(ActorBase* actor, Message msg, int from_core) { +static void send_remote_admitted(ActorBase* actor, Message msg, int from_core) { AETHER_TRACE_EVENT(AE_TRACE_SEND_REMOTE, actor ? actor->id : 0, msg.type, msg.sender_id); // Drop messages to dead actors (see scheduler_send_local). @@ -3215,6 +3512,18 @@ static void send_through_core(ActorBase* actor, Message msg, int from_core) { // routed to before the message reaches its mailbox there. if (!actor->auto_process) { inflight_enter(actor, 1); + /* Its target's death is read again now that it is counted (#2974): + * a send takes effect at this read. One counted before a death is + * dropped where it arrives, and an actor with a message counted is + * not reborn (scheduler restart), so none reaches a later + * incarnation; one counted after it drops itself here. */ + if (unlikely(atomic_load_explicit(&actor->dead, memory_order_acquire) != 0)) { + inflight_leave(actor, 1); + send_to_dead_actor(actor, &msg); + Scheduler* credit = current_core_id >= 0 ? &schedulers[current_core_id] : &schedulers[0]; + atomic_fetch_add_explicit(&credit->messages_processed, 1, memory_order_release); + return; + } target_core = atomic_load_explicit(&actor->assigned_core, memory_order_acquire); if (unlikely(target_core < 0 || target_core >= num_cores)) { target_core = actor->id % num_cores; @@ -3231,7 +3540,7 @@ static void send_through_core(ActorBase* actor, Message msg, int from_core) { // asking core runs nothing until the reply comes, so its target moved // there could not run to give it. if (from_core >= 0 && from_core == current_core_id && !actor->auto_process && - from_core != target_core && !g_pending_reply_slot) { + from_core != target_core && !msg._reply_slot) { atomic_store_explicit(&actor->migrate_to, from_core, memory_order_relaxed); } @@ -3296,8 +3605,10 @@ void scheduler_send_many(ActorBase* actor, const Message* msgs, int n) { // Not a core's thread, an actor with a thread of its own, or no actor: // each message by the path an unbuffered send of it takes (the shared // channel under its lock, refused while the scheduler is stopped, the - // inbox, the drop). - if (!actor || me < 0 || me >= num_cores || actor->auto_process) { + // inbox, the drop). So with a mailbox bound (#2974): each message takes + // its own place, or is refused, as it goes. + if (!actor || me < 0 || me >= num_cores || actor->auto_process || + atomic_load_explicit(&actor->mailbox_bound, memory_order_relaxed)) { for (int i = 0; i < n; i++) scheduler_send_remote(actor, msgs[i], me); return; } @@ -3323,7 +3634,8 @@ void scheduler_send_many(ActorBase* actor, const Message* msgs, int n) { int k = n - i < SEND_MANY_CHUNK ? n - i : SEND_MANY_CHUNK; inflight_enter(actor, k); int tc = atomic_load_explicit(&actor->assigned_core, memory_order_acquire); - if (tc == me || tc < 0 || tc >= num_cores || tls_overflow[tc].count > 0) { + if (tc == me || tc < 0 || tc >= num_cores || tls_overflow[tc].count > 0 || + atomic_load_explicit(&actor->dead, memory_order_acquire) != 0) { inflight_leave(actor, k); scheduler_send_remote(actor, msgs[i], me); i++; @@ -3331,19 +3643,21 @@ void scheduler_send_many(ActorBase* actor, const Message* msgs, int n) { } void* actors[SEND_MANY_CHUNK]; for (int j = 0; j < k; j++) actors[j] = actor; + // Counted before the receiver can take them, as a single send counts + // (count_pending_messages relies on it). + schedulers[me].messages_sent += k; int enq = queue_enqueue_batch(&schedulers[tc].from_queues[me], actors, (Message*)(msgs + i), k); // What the queue had no room for waits in this thread's deferred // sends, in order and still counted, as a single send's would. for (int j = enq; j < k; j++) overflow_append(tc, actor, msgs[i + j]); - schedulers[me].messages_sent += k; if (enq > 0) { atomic_fetch_add_explicit(&schedulers[tc].work_count, enq, memory_order_relaxed); sched_wake(&schedulers[tc]); } - // The gather a single send asks for (#3090), not for an ask (#3196). - if (!g_pending_reply_slot) - atomic_store_explicit(&actor->migrate_to, me, memory_order_relaxed); + // The gather a single send asks for (#3090). An ask is never batched + // (#3196: its target is not gathered). + atomic_store_explicit(&actor->migrate_to, me, memory_order_relaxed); i += k; } } @@ -3387,6 +3701,11 @@ void scheduler_send_batch_start(void) { } void scheduler_send_batch_add(ActorBase* actor, Message msg) { + // A bounded actor's message takes its place as it joins the batch, or + // is refused (#2974); every way on from here routes it as admitted. + if (unlikely(actor && atomic_load_explicit(&actor->mailbox_bound, memory_order_relaxed)) && + !bound_admits(actor, &msg)) + return; // Drop messages to dead actors (see scheduler_send_local). if (unlikely(!actor || atomic_load_explicit(&actor->dead, memory_order_acquire))) { send_to_dead_actor(actor, &msg); @@ -3397,7 +3716,7 @@ void scheduler_send_batch_add(ActorBase* actor, Message msg) { // batched on a scheduler thread (#3117): its sends keep its own core's // channel and overflow order. if (unlikely(actor->auto_process) || current_core_id >= 0) { - scheduler_send_remote(actor, msg, current_core_id); + send_remote_admitted(actor, msg, current_core_id); return; } // BATCH PATH: Multi-actor fan-out optimization @@ -3405,7 +3724,7 @@ void scheduler_send_batch_add(ActorBase* actor, Message msg) { scheduler_send_batch_start(); if (!g_batch_buffer) { // No buffer: the send goes out on its own, as an unbatched one. - scheduler_send_remote(actor, msg, current_core_id); + send_remote_admitted(actor, msg, current_core_id); return; } } @@ -3474,6 +3793,21 @@ void scheduler_send_batch_flush(void) { } for (int i = 0; i < count; i++) { + // Its target's death read again now that it is counted (#2974), as + // in send_through_core, when a restart claim is live: with none, a + // later claim sees this message in flight and waits for it, and a + // dead target drops it where it arrives. The death is on a line its + // core writes at every step, read here a whole batch after the add + // read it, so reading it for every message cost fork_join 2%. Not + // counted as sent yet, so no credit. + if (unlikely((atomic_load_explicit(&aether_caps_slow, memory_order_seq_cst) & + AETHER_CAPS_SPAWN_MASK) != 0) && + atomic_load_explicit(&g_batch_buffer->actors[i]->dead, memory_order_acquire) != 0) { + inflight_leave(g_batch_buffer->actors[i], 1); + send_to_dead_actor(g_batch_buffer->actors[i], &g_batch_buffer->messages[i]); + targets[i] = -1; + continue; + } int tc = atomic_load_explicit(&g_batch_buffer->actors[i]->assigned_core, memory_order_acquire); if (tc < 0 || tc >= num_cores) tc = g_batch_buffer->actors[i]->id % num_cores; targets[i] = tc; @@ -3495,6 +3829,7 @@ void scheduler_send_batch_flush(void) { memcpy(positions, offsets, num_cores * sizeof(int)); for (int i = 0; i < count; i++) { + if (targets[i] < 0) continue; // dropped above int pos = positions[targets[i]]++; sorted_actors[pos] = g_batch_buffer->actors[i]; sorted_msgs[pos] = g_batch_buffer->messages[i]; @@ -3505,12 +3840,17 @@ void scheduler_send_batch_flush(void) { memset(g_batch_buffer->by_core, 0, sizeof(g_batch_buffer->by_core)); // === PHASE 4: Batch enqueue to each core (ONE atomic per core!) === - uint64_t total_sent = 0; for (int c = 0; c < num_cores; c++) { int start = offsets[c]; int cnt = by_core[c]; if (cnt == 0) continue; + // Counted before the core can take them, as every send is + // (count_pending_messages relies on it, #3464): counted after, a + // core that handled the batch while this thread waited on another + // core's lock made the handled count pass the sent count. + atomic_fetch_add_explicit(&main_thread_sent, (uint64_t)cnt, memory_order_relaxed); + // Batch send is always called from main thread (current_core_id = -1), // so use the non-scheduler SPSC channel (from_queues[MAX_CORES]) — // under its lock, since a foreign thread may be writing it too @@ -3525,14 +3865,13 @@ void scheduler_send_batch_flush(void) { pthread_mutex_unlock(&schedulers[c].foreign_lock); // Fallback for overflow (rare) - scheduler_send_remote handles its own counting, - // the message in flight included, so this batch's count is given back first. + // the message in flight included, so this batch's counts are given back first. for (int j = enqueued; j < cnt; j++) { inflight_leave((ActorBase*)sorted_actors[start + j], 1); - scheduler_send_remote(sorted_actors[start + j], sorted_msgs[start + j], -1); + atomic_fetch_sub_explicit(&main_thread_sent, 1, memory_order_relaxed); + send_remote_admitted(sorted_actors[start + j], sorted_msgs[start + j], -1); } - // Only count batch-enqueued messages here (fallback already counted by send_remote) - total_sent += enqueued; atomic_fetch_add_explicit(&schedulers[c].work_count, enqueued, memory_order_relaxed); // A core that went idle and parked between two batches is told, as // every other enqueue tells it (#3087). It was not: it slept on the @@ -3542,8 +3881,6 @@ void scheduler_send_batch_flush(void) { if (enqueued > 0) sched_wake(&schedulers[c]); } - // Single atomic update for batch-sent messages - atomic_fetch_add_explicit(&main_thread_sent, total_sent, memory_order_relaxed); foreign_send_leave(); // Reset buffer @@ -3563,6 +3900,11 @@ ActorBase* scheduler_spawn_actor(int preferred_core, void (*step)(void*), size_t } if (actor_size < sizeof(ActorBase)) actor_size = sizeof(ActorBase); + // A restart in place (#2974): the factory's spawn of the child's type + // takes the child's own block, already registered on its core. + ActorBase* reborn = aether_rebirth_take(step, actor_size); + if (reborn) return reborn; + // A released actor's block of this size first (#2517), then the allocator. ActorBase* actor = tombstone_take(actor_size); if (!actor) { @@ -3591,6 +3933,12 @@ ActorBase* scheduler_spawn_actor(int preferred_core, void (*step)(void*), size_t atomic_init(&actor->inbox, NULL); atomic_init(&actor->thread_parked, 0); atomic_init(&actor->inflight, 0); + atomic_init(&actor->death_watch, NULL); // #2974 + atomic_init(&actor->exit_flags, 0); + atomic_init(&actor->incarnation, 0); + atomic_init(&actor->mailbox_bound, 0); // the generated spawn sets a bound (#2974) + atomic_init(&actor->mailbox_pending, 0); + atomic_init(&actor->full_drops, 0); atomic_init(&actor->assigned_core, preferred_core); atomic_init(&actor->migrate_to, -1); atomic_init(&actor->reply_slot, NULL); @@ -3628,6 +3976,15 @@ void scheduler_release_actor(ActorBase* actor) { int own_thread = actor->auto_process; int park_core = own_thread ? actor_park_core(actor) : -1; + // Its watchers are told now (#2974), before the mark: once it is set the + // actor may be ended at any moment, its watch list with it. A release is + // a normal end unless something ended the actor first; one told already + // (a death finalized) is told nothing more. + if (!(atomic_load_explicit(&actor->dead, memory_order_acquire) & AETHER_ACTOR_RELEASED)) { + aether_actor_claim_reason(actor, "normal"); + aether_watch_notify(actor, aether_actor_death_reason(actor)); + } + // A message that still reaches the actor is dropped, not delivered. int was = atomic_fetch_or_explicit(&actor->dead, AETHER_ACTOR_RELEASED, memory_order_acq_rel); if (was & AETHER_ACTOR_RELEASED) return; // released twice: ended once @@ -3650,6 +4007,10 @@ void scheduler_release_actor(ActorBase* actor) { void scheduler_actor_thread_exit(ActorBase* actor) { int was = atomic_fetch_or_explicit(&actor->dead, AETHER_ACTOR_THREAD_GONE, memory_order_acq_rel); + // Ended after the thread's last look at its bits (#2974): a kill that + // came then finds the thread gone, or this finds the kill; whichever + // is second finalizes, once. + if ((was & AETHER_ACTOR_ENDED) && !(was & AETHER_ACTOR_RELEASED)) actor_finalize(actor); if (was & AETHER_ACTOR_RELEASED) actor_end(actor); } @@ -3658,11 +4019,9 @@ void scheduler_actor_thread_exit(ActorBase* actor) { // Ask/Reply support // ============================================================================ -// Thread-locals: the sender stashes a reply slot here before calling -// aether_send_message so the slot rides inside the Message._reply_slot field. +// The ask's reply slot rides in its Message._reply_slot (aether_ask_message). // The receiver's step function copies Message._reply_slot into // g_current_reply_slot after mailbox_receive, so scheduler_reply can find it. -AETHER_TLS void* g_pending_reply_slot = NULL; AETHER_TLS void* g_current_reply_slot = NULL; static void reply_slot_decref(ActorReplySlot* slot) { @@ -3894,6 +4253,7 @@ static int relief_evacuate(Scheduler* sched) { /* One relief. Returns whether it did anything (placed a message, moved an * actor), when the next comes ASK_RELIEF_BUSY_NS later. */ static int ask_relieve(Scheduler* sched) { + control_run(sched); // deaths to finalize (#2974); one stepping here waits for its step overflow_flush_ex(sched->core_id, 0); int placed = relief_deliver(sched); return placed + relief_evacuate(sched) > 0; @@ -3915,7 +4275,26 @@ void* scheduler_ask_message_status(ActorBase* target, void* msg_data, size_t msg if (atomic_load_explicit(&target->dead, memory_order_acquire)) return NULL; ActorReplySlot* slot = (ActorReplySlot*)calloc(1, sizeof(ActorReplySlot)); - if (!slot) { *status = AETHER_ASK_NO_MEMORY; return NULL; } + if (!slot) { + /* The script gets the error; a library call ends at its next safe + * point with AETHER_OUT_OF_MEMORY (#2956). */ + aether_alloc_failed(AETHER_STOP_OUT_OF_MEMORY); + *status = AETHER_ASK_NO_MEMORY; + return NULL; + } + + /* Its message, with the slot in it (#3395), before anything of this + * thread's or its core's is marked: a copy that finds no memory fails + * the ask, as the slot's own allocation does, with nothing to restore + * (#3387). An unwind from here once left the core marked as asking and + * the slot set for every later send. */ + Message ask_msg; + if (!aether_ask_message(target, msg_data, msg_size, slot, &ask_msg)) { + free(slot); + aether_alloc_failed(AETHER_STOP_OUT_OF_MEMORY); + *status = AETHER_ASK_NO_MEMORY; + return NULL; + } pthread_mutex_init(&slot->mutex, NULL); pthread_cond_init(&slot->cond, NULL); @@ -3938,6 +4317,11 @@ void* scheduler_ask_message_status(ActorBase* target, void* msg_data, size_t msg Scheduler* asking_core = NULL; ActorBase* outer_target = NULL; ActorReplySlot* outer_slot = NULL; + ActorBase* outer_asker = NULL; + /* The actor whose handler asks, if any: killed while it waits, its ask + * ends at once (#2974). */ + ActorBase* asker = (ActorBase*)g_current_step_actor; + int killed = 0; /* Only a core's own scheduler thread (#3246): it alone may step for the * core, deliver its queues and move its actors. */ if (t_reclaim_core_thread && current_core_id >= 0 && current_core_id < num_cores) { @@ -3949,15 +4333,14 @@ void* scheduler_ask_message_status(ActorBase* target, void* msg_data, size_t msg outer_slot = atomic_load_explicit(&asking_core->ask_slot, memory_order_relaxed); atomic_store_explicit(&asking_core->ask_target, target, memory_order_release); atomic_store_explicit(&asking_core->ask_slot, slot, memory_order_release); + outer_asker = atomic_load_explicit(&asking_core->ask_asker, memory_order_relaxed); + atomic_store_explicit(&asking_core->ask_asker, asker, memory_order_release); atomic_flag_clear_explicit(&asking_core->actor_lock.lock, memory_order_release); } - // Stash the slot in the thread-local so aether_send_message propagates it - // into Message._reply_slot. This supports concurrent asks to the same actor - // because each Message carries its own slot instead of a single per-actor field. - g_pending_reply_slot = slot; - aether_send_message((void*)target, msg_data, msg_size); - g_pending_reply_slot = NULL; + // Each Message carries its own slot, so concurrent asks to one actor + // each get their own reply. + aether_send_built(target, ask_msg); struct timespec ts; clock_gettime(CLOCK_REALTIME, &ts); @@ -3975,6 +4358,10 @@ void* scheduler_ask_message_status(ActorBase* target, void* msg_data, size_t msg pthread_mutex_lock(&slot->mutex); while (!slot->reply_ready && !slot->failed) { + if (asker && (atomic_load_explicit(&asker->dead, memory_order_acquire) & AETHER_ACTOR_KILLED)) { + killed = 1; + break; + } /* The target is this core's, with a message still in the batch the * loop was delivering when the ask began: the ask's own message * waits behind it, and nothing would place either (#3197). The @@ -4002,9 +4389,9 @@ void* scheduler_ask_message_status(ActorBase* target, void* msg_data, size_t msg !atomic_flag_test_and_set_explicit(&target->step_lock, memory_order_acquire)) { pthread_mutex_unlock(&slot->mutex); /* the reply takes it */ ask_help_depth++; - aether_step_safe(target); + int took = aether_step_safe(target); ask_help_depth--; - asking_core->messages_processed++; + asking_core->messages_processed += took; if (atomic_load_explicit(&target->mailbox.count, memory_order_relaxed) == 0) atomic_store_explicit(&target->active, 0, memory_order_relaxed); atomic_flag_clear_explicit(&target->step_lock, memory_order_release); @@ -4046,8 +4433,10 @@ void* scheduler_ask_message_status(ActorBase* target, void* msg_data, size_t msg } else if (slot->failed) { *status = slot->failed; } else { + // Given up, at the timeout or killed (#2974): a reply that comes + // later frees the slot itself. slot->timed_out = 1; - *status = AETHER_ASK_TIMED_OUT; + *status = killed ? AETHER_ASK_KILLED : AETHER_ASK_TIMED_OUT; } pthread_mutex_unlock(&slot->mutex); if (asking_core) { @@ -4057,6 +4446,7 @@ void* scheduler_ask_message_status(ActorBase* target, void* msg_data, size_t msg AETHER_PAUSE(); atomic_store_explicit(&asking_core->ask_target, outer_target, memory_order_release); atomic_store_explicit(&asking_core->ask_slot, outer_slot, memory_order_release); + atomic_store_explicit(&asking_core->ask_asker, outer_asker, memory_order_release); atomic_fetch_sub_explicit(&asking_core->asking, 1, memory_order_acq_rel); atomic_flag_clear_explicit(&asking_core->actor_lock.lock, memory_order_release); } @@ -4065,8 +4455,9 @@ void* scheduler_ask_message_status(ActorBase* target, void* msg_data, size_t msg * its own thread. Answered or failed, the ask waits for that, a few * instructions, and frees the slot here: what an ask allocated is gone * when it returns, whatever the other thread's timing (#3245). A timed - * out ask does not wait; its replier frees the slot when it comes. */ - if (*status != AETHER_ASK_TIMED_OUT) { + * out ask does not wait; its replier frees the slot when it comes, and + * neither does one its killed asker gave up. */ + if (*status != AETHER_ASK_TIMED_OUT && *status != AETHER_ASK_KILLED) { int spins = 0; while (atomic_load_explicit(&slot->refcount, memory_order_acquire) > 1) { if (++spins < 256) AETHER_PAUSE(); diff --git a/runtime/scheduler/multicore_scheduler.h b/runtime/scheduler/multicore_scheduler.h index 035b5887f..12679a8b1 100644 --- a/runtime/scheduler/multicore_scheduler.h +++ b/runtime/scheduler/multicore_scheduler.h @@ -46,6 +46,7 @@ typedef struct { pthread_mutex_t mutex; // protects reply_ready / timed_out / cond pthread_cond_t cond; // signalled by scheduler_reply() atomic_int refcount; // starts at 2 (asker + actor); freed when hits 0 + uint64_t deadline_ns; // the cooperative ask's: a reply after it is late, 0 = none (#3330) } ActorReplySlot; // I/O readiness message — delivered to actor when the poller reports fd ready. @@ -54,6 +55,19 @@ typedef struct { // message registry allocates user message IDs starting at 0 and never hands out 255. #define MSG_IO_READY 255 +/* The death notice (#2974): `message Down { ref: actor_ref, reason: string }`, + * declared by the compiler for every program that names it, with this + * reserved id. The runtime writes its payload, so the compiler checks its + * generated struct against this layout. `reason` is a counted string the + * message owns. A monitor's Down carries the monitor id in + * Message.payload_int (0 for a link's). */ +#define AETHER_MSG_DOWN 254 +typedef struct { + int _message_id; + void* ref; + const char* reason; +} AetherDownMsg; + // TLS pointer to the actor whose step() is currently running on this // thread. Set by generated *_step() functions at entry; read by the // reactor bridge (std/net/aether_actor_bridge.c) so await_io() can @@ -140,6 +154,13 @@ struct ActorInbox; /* runtime/actors/aether_actor_inbox.h */ * Set (TAS) by the thread about to call step(); cleared (release) after. \ * Work-stealing threads that find this set retry next outer iteration. */ \ atomic_flag step_lock; \ + /* AETHER_EXIT_* bits (#2974). In the padding after step_lock. */ \ + _Atomic(unsigned char) exit_flags; \ + /* The mailbox bound (#2974, `@mailbox(bound: N)`): at most this many \ + * messages accepted for the actor and not yet taken by its step, 0 for \ + * none. Set by the generated spawn; read by every send. In the padding \ + * after step_lock, on the line `dead` is on: no field moves for it. */ \ + atomic_int mailbox_bound; \ /* Receive timeout (#3162): the interval a `receive ... after` names, \ * in ns, set at spawn and kept; 0 = the actor has none. */ \ uint64_t timeout_ns; \ @@ -157,9 +178,12 @@ struct ActorInbox; /* runtime/actors/aether_actor_inbox.h */ /* Bits (#2517): AETHER_ACTOR_PANICKED once the actor's step() unwound \ * via aether_panic() or a caught signal, AETHER_ACTOR_RELEASED once it \ * was released, AETHER_ACTOR_THREAD_GONE once its own thread (an \ - * auto_process actor's) has left its loop. Non-zero actors are skipped \ - * by the scheduler and incoming messages are dropped. Bits are only \ - * ever set. */ \ + * auto_process actor's) has left its loop, AETHER_ACTOR_KILLED once a \ + * kill or a linked actor's death ended it, AETHER_ACTOR_STOPPED once \ + * it stopped itself (#2974). Non-zero actors are skipped by the \ + * scheduler and incoming messages are dropped. Bits are only ever \ + * set, but by a restart in place (#2974), which clears the death \ + * bits (never RELEASED) once the new incarnation is ready. */ \ atomic_int dead; \ /* Full allocation size passed to scheduler_spawn_actor. numa_free() unmaps \ * exactly [ptr, ptr+size), so freeing a derived actor with \ @@ -183,6 +207,10 @@ struct ActorInbox; /* runtime/actors/aether_actor_inbox.h */ * whose memory stays the caller's. Written before the actor is \ * published, never after. */ \ int scheduler_owned; \ + /* How many times the actor was restarted in place, under the same ref \ + * (#2974): 0 for the actor spawned, one more at each rebirth. In the \ + * padding before inbox. */ \ + atomic_int incarnation; \ /* An auto_process actor's inbox (#2598): every sender puts its \ * messages here and only the actor's thread takes them. Made by the \ * first send, NULL until then. For an actor a core runs, its overflow \ @@ -202,16 +230,46 @@ struct ActorInbox; /* runtime/actors/aether_actor_inbox.h */ * another core only while this is \ * 0, with AETHER_INFLIGHT_MOVING set for the move, so no message is \ * left behind on the core it leaves. */ \ - atomic_int inflight; + atomic_int inflight; \ + /* Its death notices (#2974): the actors that monitor it or are linked \ + * to it, the ones it monitors, and why it ended \ + * (runtime/actors/aether_supervision.c, aether_actor_death_reason). \ + * Made by the first monitor, link or death, freed with the actor. \ + * \ + * The fields #2974 added take 16 bytes past `inflight` and none of \ + * the earlier ones moved: an actor's state starts on the cache line it \ + * started on, which the step of a small actor touches (thread_ring \ + * and skynet lost 6% when it moved to the next one). */ \ + _Atomic(struct AetherWatch*) death_watch; \ + /* With a mailbox bound (#2974): the messages accepted for it and not \ + * yet taken by its step or dropped, wherever they are (a core's queue, \ + * a sender's deferred sends, its overflow, its mailbox). System \ + * messages (Down, IoReady) are neither counted nor refused. */ \ + atomic_int mailbox_pending; \ + /* Plain sends dropped because its mailbox was full (#2974). */ \ + atomic_uint full_drops; typedef struct { AETHER_ACTOR_BASE_FIELDS } ActorBase; +struct AetherWatch; /* runtime/actors/aether_supervision.c */ + /* The bits of ActorBase.dead; a send checks only that it is non-zero. */ #define AETHER_ACTOR_PANICKED 1 #define AETHER_ACTOR_RELEASED 2 #define AETHER_ACTOR_THREAD_GONE 4 +#define AETHER_ACTOR_KILLED 8 +#define AETHER_ACTOR_STOPPED 16 +/* The bits that end an actor: the first one set decides its reason. */ +#define AETHER_ACTOR_ENDED (AETHER_ACTOR_PANICKED | AETHER_ACTOR_RELEASED | \ + AETHER_ACTOR_KILLED | AETHER_ACTOR_STOPPED) + +/* ActorBase.exit_flags (#2974): linked deaths reach it as Down messages + * (trap_exits), and its death has been finalized (drained, its watchers + * told), which happens once. */ +#define AETHER_EXIT_TRAP 1 +#define AETHER_EXIT_FINALIZED 2 /* ActorBase.inflight (#3090, #3093): set while the actor's core moves it to * another; the counts of messages in flight sit below it. */ @@ -222,7 +280,7 @@ typedef struct { * actor stays on its core, so the messages reach it there. During a move it * waits for the move to end, and then reads the new core. */ static inline void inflight_enter(ActorBase* actor, int n) { - int v = atomic_fetch_add_explicit(&actor->inflight, n, memory_order_acq_rel); + int v = atomic_fetch_add_explicit(&actor->inflight, n, memory_order_seq_cst); while (unlikely(v & AETHER_INFLIGHT_MOVING)) { AETHER_CPU_PAUSE(); v = atomic_load_explicit(&actor->inflight, memory_order_acquire); @@ -313,9 +371,19 @@ typedef struct { // The innermost of those asks: the actor it waits on and its reply // slot, set and put back under actor_lock with `asking`. Another core's // relief moves that actor here, where the waiting thread steps it, and - // wakes the wait through the slot (#3197). + // wakes the wait through the slot (#3197). And the actor that asked, + // whose kill wakes the wait through the slot too (#2974). _Atomic(ActorBase*) ask_target; _Atomic(ActorReplySlot*) ask_slot; + _Atomic(ActorBase*) ask_asker; + // Actors of this core whose death is to be finalized here (#2974): a + // kill or a link's from another thread posts them, and the core's loop + // (or its relief) finalizes each once it is not stepping. + atomic_flag control_lock; + atomic_int control_pending; + ActorBase** control; + int control_count; + int control_cap; // Where this core's next relief scan of its table starts (#3197). int relief_cursor; @@ -346,6 +414,14 @@ int scheduler_core_cpu(int core_id); void scheduler_stop(void); void scheduler_wait(void); // Wait for quiescence (all pending messages processed). Non-destructive. +/* The panic scheduler_wait() raises inside an actor's step, on either + * scheduler, and on the threaded one on any thread that runs actors (a + * core's, an actor's own), where a drop or a death hook runs too (#3463): it + * waits for every actor, those this thread runs too. */ +#define AETHER_WAIT_IN_STEP_REASON \ + "wait_for_idle() inside an actor, or on a thread that runs actors: it waits " \ + "for every actor, those this thread runs too, which cannot step while it " \ + "waits; call it outside the actors (main, a host's thread)" void scheduler_shutdown(void); // Wait + stop + join threads. Call once at program exit. void scheduler_cleanup(void); @@ -401,6 +477,166 @@ uint64_t scheduler_released_sends(void); // and reported once on stderr (#2912). uint64_t scheduler_stopped_sends(void); +/* ------------------------------------------------------------------------ + * Death notices (#2974): runtime/actors/aether_supervision.c and the + * schedulers' finalize. An actor ends once, by its first death bit: a + * panic, a kill (or a linked actor's death), a stop, a release. Its death + * is finalized on its own thread once it is not stepping (drained, its + * watchers sent Down), so no Down comes for an actor whose handler still + * runs. + * ------------------------------------------------------------------------ */ + +/* Messages a panicked, killed or stopped actor did not handle: queued at its + * death or sent to it after. Each was released through aether_message_drop. */ +uint64_t scheduler_dead_sends(void); +void scheduler_count_dead_sends(int n); + +/* ------------------------------------------------------------------------ + * A bounded mailbox (#2974): `@mailbox(bound: N)` on an actor. A send + * (scheduler_send_local, scheduler_send_remote and the paths built on + * them) is accepted while fewer than N messages are pending for the actor + * and refused otherwise, never blocked: a plain send is dropped, released + * and counted (full_drops, reported once on stderr), an ask fails at once + * with AETHER_ASK_FULL, and scheduler_try_send says so to its caller. The + * generated step of a bounded actor gives a message back as it takes it + * (aether_mailbox_took), and every drop of one not taken gives it back + * (aether_mailbox_untake), so `mailbox_pending` is exact. System messages + * are never refused nor counted. + * ------------------------------------------------------------------------ */ + +static inline int aether_message_is_system(int type) { + return type == AETHER_MSG_DOWN || type == MSG_IO_READY; +} + +/* Takes a place for one message of `type`: 1 when accepted. Exact: a send + * is refused only while `bound` messages are pending. */ +static inline int aether_mailbox_admit(ActorBase* a, int type) { + int bound = atomic_load_explicit(&a->mailbox_bound, memory_order_relaxed); + if (bound == 0 || aether_message_is_system(type)) return 1; + int p = atomic_load_explicit(&a->mailbox_pending, memory_order_relaxed); + while (p < bound) { + if (atomic_compare_exchange_weak_explicit(&a->mailbox_pending, &p, p + 1, + memory_order_relaxed, memory_order_relaxed)) + return 1; + } + return 0; +} + +/* A message of `type` that was accepted is dropped before its step took it. */ +static inline void aether_mailbox_untake(ActorBase* a, int type) { + if (a && atomic_load_explicit(&a->mailbox_bound, memory_order_relaxed) != 0 && + !aether_message_is_system(type)) + atomic_fetch_sub_explicit(&a->mailbox_pending, 1, memory_order_relaxed); +} + +/* The generated step of a bounded actor, after it takes a message. */ +static inline void aether_mailbox_took(ActorBase* a, int type) { + if (!aether_message_is_system(type)) + atomic_fetch_sub_explicit(&a->mailbox_pending, 1, memory_order_relaxed); +} + +/* Sends `msg` unless its target is dead or its mailbox is full, and says + * which: "" when sent, "the mailbox is full" or "the actor is dead" when + * not, the message released (and counted nowhere: the caller knows). + * `try_send(ref, Msg {...})` (aether_try_send_message for a payload). */ +const char* scheduler_try_send(ActorBase* actor, Message msg); +const char* aether_try_send_message(void* actor, void* message_data, size_t message_size); + +/* Plain sends dropped because their target's mailbox was full. */ +uint64_t scheduler_full_drops(void); + +/* Ends `actor` with `reason` by death bit `bit` (KILLED, STOPPED), unless it + * ended already; returns 1 when this call ended it. Its finalize runs on its + * own thread once its current step (if any) returns. From any thread. */ +int scheduler_actor_end(ActorBase* actor, const char* reason, int bit); + +/* Claims `actor`'s death for `reason` and sets `bit`: 1 when nothing ended + * it before, else 0 and nothing changes but the bit. */ +int aether_actor_claim_end(ActorBase* actor, const char* reason, int bit); +/* Keeps `reason` as the actor's death reason unless one is kept already. */ +void aether_actor_claim_reason(ActorBase* actor, const char* reason); +/* Why the actor ended, kept with its watch list: "normal", "killed", the + * panic's reason or a linked actor's; NULL while it lives. Set by whatever + * ended it first, before its death bit. */ +const char* aether_actor_death_reason(ActorBase* actor); +/* An ended actor's death, finalized on the thread that owns it (an actor + * thread's own, at its loop's end). */ +void scheduler_actor_thread_finalize(ActorBase* actor); +/* Tells the actor's watchers of its death (once), and lets go of the + * actors it watches. Any thread. */ +void aether_watch_notify(ActorBase* actor, const char* reason); +/* Frees the actor's watch list and death reason (its block is ending). */ +void aether_watch_free(ActorBase* actor); +/* A Down for `self` from a monitor `self` has since dropped (demonitor) is + * not handled: 0 for it, 1 for every other. Called by the generated step + * before a Down arm. */ +int aether_down_accept(ActorBase* self, Message* msg); + +/* The std.actors surface, from a handler (the actor stepping on this + * thread is `self`). On wasi a panic ends the program (it has no setjmp), + * so a death a monitor would be told of cannot be caught there: a call to + * these is refused at compile time on that target. */ +#if defined(__wasi__) && defined(__clang__) +#define AETHER_NEEDS_PANIC_BARRIER \ + __attribute__((unavailable("watching a death needs the step's panic barrier, and on wasi " \ + "a panic ends the program (no setjmp)"))) +#else +#define AETHER_NEEDS_PANIC_BARRIER +#endif +int aether_actor_monitor(void* ref) AETHER_NEEDS_PANIC_BARRIER; +void aether_actor_demonitor(int id); +void aether_actor_link(void* ref) AETHER_NEEDS_PANIC_BARRIER; +void aether_actor_unlink(void* ref); +void aether_actor_trap_exits(int on) AETHER_NEEDS_PANIC_BARRIER; +void aether_actor_stop(void); +void aether_actor_kill(void* ref); +int64_t aether_actor_dead_sends(void); +int64_t aether_actor_full_drops(void* ref); + +/* Restart in place (#2974): a supervisor reruns a child's factory, and the + * factory's spawn of the child's type takes the child's own block, so the + * ref its clients hold reaches the new incarnation. rebirth_begin claims an + * ended, finalized child with no message of it in flight (1; 0: not yet, try + * again; -1: alive, released, or not the scheduler's); the spawn then resets + * the block (its old state destroyed, its fields zeroed, its watch lists + * gone, incarnation + 1) and the generated spawn's state initializers run; + * scheduler_spawn_ready, the generated spawn's last line, brings it alive. + * rebirth_end says whether it is (1), or undoes a rebirth the factory did + * not finish (0: it spawned no actor of the type, or panicked), the child + * left ended. A send while it is dead is dropped and counted; one after the + * spawn returns reaches the new incarnation. */ +int aether_actor_rebirth_begin(void* ref); +int aether_actor_rebirth_end(void* ref); +int aether_actor_incarnation(void* ref); +/* The block scheduler_spawn_actor reuses for a rebirth on this thread, or + * NULL. */ +ActorBase* aether_rebirth_take(void (*step)(void*), size_t size); +void scheduler_spawn_ready(ActorBase* actor); +/* A panic left a rebirth half done on this thread: undone. */ +void aether_rebirth_abandon(void); +/* The step barrier's part (#3389, aether_step_barrier.h): a step nested in + * another puts the thread's restart claim, reborn block and spawn brackets + * aside (1 when it held any), and gets them back when it ends; a step that + * panicked undoes the claim and closes the brackets it made itself. */ +typedef struct { + ActorBase* target; + ActorBase* reborn; + int capture_floor; +} AetherSpawnAside; +int aether_spawn_put_aside(AetherSpawnAside* s); +void aether_spawn_give_back(const AetherSpawnAside* s); +void aether_spawn_step_panicked(void); +/* The scheduler's part once a reborn actor is alive (wake its core). */ +void scheduler_actor_revived(ActorBase* actor); +/* Live restart claims and spawn captures, on any thread, are counted in + * aether_caps_slow (aether_spawn_watched, aether_resource_caps.h). */ +/* The child a supervisor's factory spawned (#2974): the first actor that + * comes alive on this thread between begin and end (a fresh spawn or a + * rebirth), or NULL. Brackets nest. */ +void aether_actor_capture_begin(void); +void* aether_actor_capture_end(void); +void aether_actor_capture_note(ActorBase* actor); + // #2527: a reference that knows which actor it was taken from. The block a // released actor leaves goes to the next spawn of its size, which gets an id // of its own; a send through a reference taken from the released actor is @@ -422,6 +658,18 @@ void scheduler_send_ref(AetherActorRef ref, Message msg); // NULL (a reference whose block is another actor's now): the payload alone // is freed. void aether_message_drop(ActorBase* target, Message* msg); +/* #3401: aether_message_drop of a message an ending actor leaves + * undelivered, as its finalize drains it on either scheduler: the release + * runs in a frame of its own, so a panic in it (a field's drop function is + * the program's code) is reported through the death hook, as the step + * barrier reports one in a release after a handler, and the end goes on. */ +void aether_message_drop_at_end(ActorBase* target, Message* msg); +/* The drop alone, in its containment frame (#3403): 1 when the release + * panicked (what it left is freed; *reason and *reason_release are the + * panic's, the caller's to report and release), else 0. The step barrier + * drops the message a panicking handler ran on through it. */ +int aether_message_drop_contained(ActorBase* target, Message* msg, const char** reason, + void (**reason_release)(const void*)); /* A thread that steps actors without being a core's scheduler thread * (aether_actor_thread, which inlines sends to actors of its core) takes * part in reclamation like a core (#2517): it registers before its first @@ -448,8 +696,13 @@ int scheduler_actor_thread_park(ActorBase* actor); * step context of its own (the ask it handles, answered or failed when the * step ends), and the caller's given back after. What an auto_process * actor's thread steps it with. The caller holds what a core holds to step - * it. */ -void scheduler_step_actor(ActorBase* actor); + * it. 1 when the step took a message, which the caller counts handled (#3464). */ +int scheduler_step_actor(ActorBase* actor); +/* Around a step that fires a receive timeout (#3443): counted until its arm + * returns, so wait_for_idle() waits for the arm, which runs after the step + * disarmed the countdown. */ +void scheduler_countdown_enter(void); +void scheduler_countdown_leave(void); /* Puts `msg` in an auto_process actor's inbox and wakes its thread if it * sleeps (#2598, #2592). From any thread; the message is counted as sent by * the caller. Every send to such an actor ends here. */ @@ -477,6 +730,8 @@ void* scheduler_ask_message(ActorBase* target, void* msg_data, size_t msg_size, #define AETHER_ASK_DIED 3 /* the actor died handling the ask, before it replied */ #define AETHER_ASK_NO_MEMORY 4 /* the ask could not be made */ #define AETHER_ASK_NO_REPLY 5 /* the handler returned without replying */ +#define AETHER_ASK_KILLED 6 /* the asking actor was killed while it waited (#2974) */ +#define AETHER_ASK_FULL 7 /* the actor's mailbox bound was reached (#2974) */ void* scheduler_ask_message_status(ActorBase* target, void* msg_data, size_t msg_size, int timeout_ms, int* status); @@ -503,10 +758,18 @@ int aether_scheduler_poll(int max_per_actor); int scheduler_io_register(int core_id, int fd, void* actor, uint32_t events); void scheduler_io_unregister(int core_id, int fd); -// Thread-local reply slot set by the send path (sender) and step function (receiver). -// g_pending_reply_slot: set before aether_send_message so the slot rides inside the Message. -// g_current_reply_slot: set by the generated step function after mailbox_receive. -extern AETHER_TLS void* g_pending_reply_slot; +// The ask in hand: set by the generated step function after mailbox_receive +// from the message's _reply_slot, read by the reply. extern AETHER_TLS void* g_current_reply_slot; +// An ask's message (aether_send_message.c, #3387): the payload copied and +// `reply_slot` in it, built by the ask itself, never handed over through a +// thread-local (a send can step its target inline, and that handler's own +// sends read it too, #3395). 0 when there was no memory for the copy: what +// the message owns is released and nothing is sent. aether_send_built sends +// a message built so, as aether_send_message routes one. +int aether_ask_message(void* actor, void* message_data, size_t message_size, + void* reply_slot, Message* out); +void aether_send_built(ActorBase* actor, Message msg); + #endif diff --git a/runtime/utils/aether_bounds_check.c b/runtime/utils/aether_bounds_check.c index cca915ca8..7a67e3b68 100644 --- a/runtime/utils/aether_bounds_check.c +++ b/runtime/utils/aether_bounds_check.c @@ -1,11 +1,18 @@ #include "aether_bounds_check.h" +#include "aether_fatal.h" #include #include +// Each check that fails flushes stdout before its message (#3435, +// aether_fatal.h): stdout is fully buffered when it is a file or a pipe, and +// abort() flushes no stdio stream on glibc, so the program's last lines were +// lost behind the error. + // Array bounds checking void aether_check_array_access(int index, int length, const char* array_name, const char* file, int line) { if (index < 0 || index >= length) { + aether_fatal_flush(); fprintf(stderr, "\n%s:%d: ", file, line); fprintf(stderr, "\033[31m\033[1merror:\033[0m Array index out of bounds\n"); fprintf(stderr, " Array: %s\n", array_name); @@ -20,6 +27,7 @@ void aether_check_array_access(int index, int length, const char* array_name, void aether_check_null_pointer(const void* ptr, const char* ptr_name, const char* file, int line) { if (!ptr) { + aether_fatal_flush(); fprintf(stderr, "\n%s:%d: ", file, line); fprintf(stderr, "\033[31m\033[1merror:\033[0m Null pointer dereference\n"); fprintf(stderr, " Pointer: %s\n\n", ptr_name); @@ -30,6 +38,7 @@ void aether_check_null_pointer(const void* ptr, const char* ptr_name, // Division by zero checking void aether_check_div_by_zero(int divisor, const char* file, int line) { if (divisor == 0) { + aether_fatal_flush(); fprintf(stderr, "\n%s:%d: ", file, line); fprintf(stderr, "\033[31m\033[1merror:\033[0m Division by zero\n\n"); abort(); @@ -38,6 +47,7 @@ void aether_check_div_by_zero(int divisor, const char* file, int line) { void aether_check_div_by_zero_float(float divisor, const char* file, int line) { if (divisor == 0.0f) { + aether_fatal_flush(); fprintf(stderr, "\n%s:%d: ", file, line); fprintf(stderr, "\033[31m\033[1merror:\033[0m Division by zero (float)\n\n"); abort(); @@ -48,6 +58,7 @@ void aether_check_div_by_zero_float(float divisor, const char* file, int line) { void aether_assert_impl(int condition, const char* condition_str, const char* file, int line, const char* message) { if (!condition) { + aether_fatal_flush(); fprintf(stderr, "\n%s:%d: ", file, line); fprintf(stderr, "\033[31m\033[1massertion failed:\033[0m %s\n", condition_str); if (message && message[0] != '\0') { diff --git a/runtime/utils/aether_compiler.h b/runtime/utils/aether_compiler.h index 828d95fc5..d12dd5c7c 100644 --- a/runtime/utils/aether_compiler.h +++ b/runtime/utils/aether_compiler.h @@ -139,6 +139,22 @@ # define AETHER_DESTRUCTOR #endif +// A global each runtime copy keeps to itself (#3438). Under ELF +// interposition a library's reference to a runtime global binds to the first +// copy in the global scope, so a library's constructor wrote another copy's +// pointer and that copy's scheduler called this one's functions. Hidden, every +// reference binds inside the image that defines it. Only for globals that +// generated code never names and that code running whatever the interposition +// sets (a constructor): a global the interposed panic or step barrier reads +// binds as they do, or a library that runs the program's panic sets a copy +// nobody reads. A PE image (Windows, Cygwin) exports nothing it does not say, +// so it needs nothing. +#if AETHER_GCC_COMPAT && !defined(_WIN32) && !defined(__CYGWIN__) +# define AETHER_COPY_LOCAL __attribute__((visibility("hidden"))) +#else +# define AETHER_COPY_LOCAL +#endif + // ============================================================================ // PREFETCH // ============================================================================ diff --git a/runtime/utils/aether_fatal.h b/runtime/utils/aether_fatal.h new file mode 100644 index 000000000..ce98f9429 --- /dev/null +++ b/runtime/utils/aether_fatal.h @@ -0,0 +1,48 @@ +#ifndef AETHER_FATAL_H +#define AETHER_FATAL_H + +/* Before the runtime ends the process on an error it cannot return (#3435): + * the program's own lines first. stdout is fully buffered when it is a file + * or a pipe, and abort() flushes no stdio stream on glibc, musl or bionic, + * so what the program printed last was lost behind the error's message. + * + * The flush waits for another thread that holds stdout, a little: a thread + * in the middle of a println holds its lock for microseconds, and is waited + * for; one blocked writing to a pipe that nobody reads holds it for as long + * as that lasts, and the abort must not wait on it, so after a quarter of a + * second the flush is given up. POSIX tries the lock (flockfile's lock is + * recursive, so the flush inside it takes it again). Windows' C runtime has + * no such try, and a build without threads (wasi and Emscripten among them) + * has no other thread, so they flush. The bound is on the lock only: the + * flush's own write to a pipe nobody reads waits as any write there does. + * Where abort() flushes the streams itself (macOS, FreeBSD), that flush is + * the C library's, and waits as it waits. Not for a signal handler: none of + * this is async-signal-safe. */ + +#include +#if !(defined(_WIN32) || defined(AETHER_NO_THREADING) || defined(__wasi__) || defined(__EMSCRIPTEN__)) +# include +#endif + +static inline void aether_fatal_flush(void) { +#if defined(_WIN32) || defined(AETHER_NO_THREADING) || defined(__wasi__) || defined(__EMSCRIPTEN__) + fflush(stdout); +#else + struct timespec start, now; + clock_gettime(CLOCK_MONOTONIC, &start); + for (;;) { + if (ftrylockfile(stdout) == 0) { + fflush(stdout); + funlockfile(stdout); + return; + } + clock_gettime(CLOCK_MONOTONIC, &now); + long long waited_ns = (long long)(now.tv_sec - start.tv_sec) * 1000000000LL + (now.tv_nsec - start.tv_nsec); + if (waited_ns >= 250000000LL) return; + struct timespec ms = { 0, 1000000 }; + nanosleep(&ms, NULL); + } +#endif +} + +#endif /* AETHER_FATAL_H */ diff --git a/runtime/utils/aether_thread.h b/runtime/utils/aether_thread.h index 0f6106899..7c5e1f732 100644 --- a/runtime/utils/aether_thread.h +++ b/runtime/utils/aether_thread.h @@ -345,9 +345,14 @@ static inline int pthread_detach(pthread_t t) { // ---- Mutex --------------------------------------------------------------- +// Without a debug record (#3346). InitializeCriticalSection attaches one the +// first time a thread waits on the lock: 48 bytes from the heap at an +// arbitrary later point, which an exact leak check (heap_in_use counts every +// block on Windows) reads as growth. The record only serves a debugger's +// lock listing. static inline int pthread_mutex_init(pthread_mutex_t* m, pthread_mutexattr_t* attr) { (void)attr; - InitializeCriticalSection(AETHER_WIN32_CS(m)); + InitializeCriticalSectionEx(AETHER_WIN32_CS(m), 0, CRITICAL_SECTION_NO_DEBUG_INFO); return 0; } diff --git a/runtime/utils/aether_wipe.h b/runtime/utils/aether_wipe.h new file mode 100644 index 000000000..5b688a048 --- /dev/null +++ b/runtime/utils/aether_wipe.h @@ -0,0 +1,20 @@ +#ifndef AETHER_WIPE_H +#define AETHER_WIPE_H + +#include +#include + +/* Zero n bytes at p so that the writes happen, even right before the memory + * is freed (#3376). A plain memset there is a dead store the compiler may + * drop. This one is called through a volatile function pointer: the + * compiler must load the pointer when the call runs, so it cannot know the + * callee is memset or prove its writes dead, whatever it inlines or links + * together. OpenSSL's OPENSSL_cleanse uses the same technique, and it needs + * nothing from the platform (no explicit_bzero, memset_s or + * SecureZeroMemory), so it works for every target the runtime builds for. */ +static inline void aether_wipe(void* p, size_t n) { + static void* (*const volatile wipe_memset)(void*, int, size_t) = memset; + if (p && n) wipe_memset(p, 0, n); +} + +#endif diff --git a/std/actors/README.md b/std/actors/README.md index 03a993263..0606e3429 100644 --- a/std/actors/README.md +++ b/std/actors/README.md @@ -49,7 +49,106 @@ between "I cleaned up" and "someone else already did". `whereis` on an unbound name returns null, so check before sending. +## Ending actors, and death notices (#2974) + +An actor ends once: its handler panics, `actors.kill(ref)` ends it (from +anywhere), or its own handler calls `actors.stop()`. A kill or a stop takes +effect when the running handler returns. What was queued for it is dropped, +and so is every send to it after, each counted by `actors.dead_sends()`. + +`actors.monitor(ref)`, from a handler, asks for one `Down { ref, reason }` +when `ref` ends: `"normal"` for a stop, `"killed"` for a kill, the panic's +message for a panic, and at once for an actor that has ended already. The +compiler declares `Down`; an actor handles it in a `Down(ref, reason)` arm. +`actors.demonitor(id)` drops a monitor, and no `Down` of it is handled after. + +```aether,run +import std.actors + +message Watch { who: actor_ref } +message Halt {} + +actor Job { + receive { Halt() -> { actors.stop() } } +} + +actor Boss { + receive { + Watch(who) -> { _id = actors.monitor(who) } + Down(ref, reason) -> { println("job ended: ${reason}") } + } +} + +main() { + j = spawn(Job()) + b = spawn(Boss()) + b ! Watch { who: j } + wait_for_idle() + j ! Halt {} + wait_for_idle() +} +``` +```output +job ended: normal +``` + +`actors.link(ref)` ties two actors both ways: when either ends, the other +ends too, with the same reason, unless it is `"normal"`; an actor that called +`actors.trap_exits(true)` gets a `Down` instead and lives on. +`actors.unlink(ref)` takes the tie away. + +A supervisor restarts a child in place, under the ref its clients hold: +`actors.rebirth_begin(child)`, then the child's factory (whose spawn of the +child's actor type takes the child's block), then `actors.rebirth_end(child)`. +`std.supervise` does this for you; `actors.incarnation(ref)` counts the +restarts. + +On wasi a panic ends the program, so `monitor`, `link` and `trap_exits` are +refused when compiling for it. See docs/actor-concurrency.md, "The end of an +actor". + +## A bounded mailbox (#2974) + +A mailbox is unbounded by default: a send never blocks, and what an actor has +not handled yet waits in memory. `@mailbox(bound: N)` on an actor takes at +most N messages it has not handled yet. Past that a send is refused, never +blocked: `try_send(ref, Msg { ... })` says `"the mailbox is full"` (or +`"the actor is dead"`, and `""` when it sent), an ask fails at once with +`"the mailbox is full"`, and a plain `!` is dropped and counted by +`actors.full_drops(ref)`. A `Down` always gets in. + +```aether,run +import std.actors + +message Item { k: int } + +@mailbox(bound: 2) +actor Sink { + state n = 0 + receive { Item(k) -> { n = n + 1 } } +} + +main() { + s = spawn(Sink()) + k = 0 + full = 0 + while k < 1000 { + if try_send(s, Item { k: k }) != "" { + full = full + 1 + } + k = k + 1 + } + wait_for_idle() + println("handled + refused = ${s.n + full}") +} +``` +```output +handled + refused = 1000 +``` + ## Exports `register`, `whereis`, `unregister`, `is_registered`, `registry_size`, -`registry_clear`. +`registry_clear`, `monitor`, `demonitor`, `kill`, `stop`, `dead_sends`, +`full_drops`, `link`, `unlink`, `trap_exits`, `rebirth_begin`, `rebirth_end`, +`incarnation`. diff --git a/std/actors/module.ae b/std/actors/module.ae index 8a116fb7b..552c5f9d5 100644 --- a/std/actors/module.ae +++ b/std/actors/module.ae @@ -59,7 +59,10 @@ exports( register, whereis, unregister, is_registered, registry_size, - registry_clear + registry_clear, + monitor, demonitor, kill, stop, dead_sends, full_drops, + link, unlink, trap_exits, + rebirth_begin, rebirth_end, incarnation ) // ---- Raw externs ---- @@ -73,6 +76,20 @@ extern aether_actor_is_registered(name: string) -> int extern aether_actor_registry_size() -> int extern aether_actor_registry_clear() +// Death notices (#2974), runtime/actors/aether_supervision.c. +extern aether_actor_monitor(ref: actor_ref) -> int +extern aether_actor_demonitor(id: int) +extern aether_actor_kill(ref: actor_ref) +extern aether_actor_stop() +extern aether_actor_dead_sends() -> long +extern aether_actor_full_drops(ref: actor_ref) -> long +extern aether_actor_link(ref: actor_ref) +extern aether_actor_unlink(ref: actor_ref) +extern aether_actor_trap_exits(on: int) +extern aether_actor_rebirth_begin(ref: actor_ref) -> int +extern aether_actor_rebirth_end(ref: actor_ref) -> int +extern aether_actor_incarnation(ref: actor_ref) -> int + // ---- Aether-side wrappers ---- // Bind `name` → `ref`. Overwrites any prior binding of the same @@ -111,3 +128,106 @@ registry_size() -> int { registry_clear() { aether_actor_registry_clear() } + +// ---- Death notices (#2974) ---- +// +// A watched actor's death reaches its watcher as the system message +// `Down { ref: actor_ref, reason: string }`, which the compiler declares: +// an actor handles it in a `Down(ref, reason) -> { ... }` arm. The reason +// is "normal" (it stopped, or was released), "killed" (actors.kill), or +// the panic's reason. A Down is sent once its death is final: never while +// its handler still runs, and after the last messages it sent the watcher. + +// From a handler: one Down to this actor when `ref` ends, at once if it has +// ended already. Returns the monitor's id, for demonitor. +monitor(ref: actor_ref) -> int { + return aether_actor_monitor(ref) +} + +// From a handler: no Down of monitor `id` is handled after this, one +// already on its way included. +demonitor(id: int) { + aether_actor_demonitor(id) +} + +// Ends `ref` with "killed" once its current handler, if one is running, +// returns. Its queued messages are dropped and counted (dead_sends), an +// ask it is waiting in ends at once, and its watchers get Down. From +// anywhere; a dead actor is not killed twice. +kill(ref: actor_ref) { + aether_actor_kill(ref) +} + +// From a handler: this actor ends with "normal" when the handler returns. +stop() { + aether_actor_stop() +} + +// Messages a panicked, killed or stopped actor did not handle: queued at +// its death or sent to it after, each released and counted once. +dead_sends() -> long { + return aether_actor_dead_sends() +} + +// Plain sends (`!`) to `ref` dropped because its mailbox was full: an actor +// declared `@mailbox(bound: N)` takes at most N messages it has not handled +// yet. try_send(ref, Msg {...}) says "the mailbox is full" instead, and an +// ask fails with it; neither is counted here. +full_drops(ref: actor_ref) -> long { + return aether_actor_full_drops(ref) +} + +// From a handler: this actor and `ref` are linked, both ways. When either +// ends, the other ends too, with the same reason (a panic's message, or +// "killed"), unless that reason is "normal", which ends nothing; one that +// traps exits gets a Down instead and lives on. Linked to an actor that has +// ended already, this actor takes its death at once. +link(ref: actor_ref) { + aether_actor_link(ref) +} + +// From a handler: the link with `ref` goes, both ways. A death already +// taken by it stays taken. +unlink(ref: actor_ref) { + aether_actor_unlink(ref) +} + +// From a handler: a linked actor's death reaches this actor as a Down (its +// id 0, never dropped by demonitor) instead of ending it, while `on`. +trap_exits(on: bool) { + if on { + aether_actor_trap_exits(1) + } else { + aether_actor_trap_exits(0) + } +} + +// ---- Restart in place (#2974) ---- +// +// What a supervisor (std.supervise) restarts a child with, under the ref its +// siblings and clients already hold: rebirth_begin(child), then the child's +// factory, whose spawn of the child's actor type takes the child's own block +// and runs its state initializers, then rebirth_end(child). Messages queued +// at the death, and sends while it is dead, are dropped and counted +// (dead_sends); sends once the factory's spawn returns reach the new +// incarnation. + +// 1 when `ref` is claimed for a restart on this thread: it has ended, its +// death is finalized and no message of it is in flight. 0 when not yet (try +// again soon), -1 when it cannot be (it is alive, released, or one a C host +// registered). +rebirth_begin(ref: actor_ref) -> int { + return aether_actor_rebirth_begin(ref) +} + +// 1 when `ref` is alive again. 0 when the factory spawned no actor of its +// type, or panicked first: the restart is undone and `ref` stays ended, +// "the restart failed". +rebirth_end(ref: actor_ref) -> int { + return aether_actor_rebirth_end(ref) +} + +// How many times `ref` was restarted in place: 0 for the actor spawned. +incarnation(ref: actor_ref) -> int { + return aether_actor_incarnation(ref) +} diff --git a/std/bytes/README.md b/std/bytes/README.md index 5b5524755..d80dedcf5 100644 --- a/std/bytes/README.md +++ b/std/bytes/README.md @@ -109,13 +109,27 @@ over any bounded `byte[]` (a sub-slice of a view, a fixed array), and `cursor.read_view(c, n)` returns the next `n` bytes as a borrowed sub-slice without copying, where `read_slice` copies them into a new buffer. +## Secrets (#3376) + +A buffer that held a secret (key material, a nonce, the text of a private +key) is given back with `bytes.wipe_free(b)`, or zeroed in place with +`bytes.wipe(b)`. Both zero the whole capacity, past the length too, and +their writes cannot be dropped: a loop of `bytes.set(b, i, 0)` before a free +is a dead store the C compiler may remove once it inlines the calls. A +buffer that grew moved its contents, and the memory it moved out of was +freed as it was, so a secret is best given a buffer of its final size +(`bytes.new(n)`, then `set` or `capacity_view`). A `string` cannot be wiped: +it is immutable and may be shared, so a secret that must be wiped is kept in +a buffer, not a string. + ## Exports `new`, `from_ptr`, `string_from_ptr`, `set`, `get`, `length`, `set_length`, -`capacity`, `data`, `finish`, `to_string`, `free`, `copy_from_string`, -`copy_from_bytes`, `copy_within`, the slice forms `view`, `capacity_view`, -`from_slice`, `copy_from_slice`, `string_from_slice`, and the fixed-width -endian accessors `set_le16`/`get_le16` through `set_be64`/`get_be64`. +`capacity`, `data`, `finish`, `to_string`, `free`, `wipe`, `wipe_free`, +`copy_from_string`, `copy_from_bytes`, `copy_within`, the slice forms `view`, +`capacity_view`, `from_slice`, `copy_from_slice`, `string_from_slice`, and the +fixed-width endian accessors `set_le16`/`get_le16` through +`set_be64`/`get_be64`. The endian accessors are the reason to reach for a `bytes` buffer over a string when building a wire format: `set_be32` writes the four bytes in diff --git a/std/bytes/aether_bytes.c b/std/bytes/aether_bytes.c index 142701f79..37202620b 100644 --- a/std/bytes/aether_bytes.c +++ b/std/bytes/aether_bytes.c @@ -1,6 +1,7 @@ #include "aether_bytes.h" #include "../string/aether_string.h" #include "../../runtime/aether_resource_caps.h" +#include "../../runtime/utils/aether_wipe.h" #include #include @@ -465,3 +466,16 @@ void aether_bytes_free(AetherBytes* b) { aether_caps_free(b->data, b->capacity); aether_caps_free(b, sizeof(AetherBytes)); } + +void aether_bytes_wipe(AetherBytes* b) { + if (is_aether_string(b)) return; + if (!b) return; + aether_wipe(b->data, b->capacity); +} + +void aether_bytes_wipe_free(AetherBytes* b) { + if (is_aether_string(b)) return; + if (!b) return; + aether_wipe(b->data, b->capacity); + aether_bytes_free(b); +} diff --git a/std/bytes/aether_bytes.h b/std/bytes/aether_bytes.h index e7225422d..de25da961 100644 --- a/std/bytes/aether_bytes.h +++ b/std/bytes/aether_bytes.h @@ -183,4 +183,16 @@ void* aether_bytes_to_string(AetherBytes* b, int length); * isn't needed (error path, etc.). */ void aether_bytes_free(AetherBytes* b); +/* Zero every byte the buffer holds, its whole capacity, in a way the + * compiler cannot drop as dead before a free (#3376). The buffer stays + * valid, its length unchanged. For a secret: key material, nonces, the text + * of a private key. Nothing on NULL or on an AetherString, which is + * immutable and may be shared. A buffer that grew moved its contents, and + * the memory it moved out of was freed as it was, so a secret is best given + * a buffer of its final size. */ +void aether_bytes_wipe(AetherBytes* b); + +/* Wipe the buffer, then free it. Idempotent on NULL. */ +void aether_bytes_wipe_free(AetherBytes* b); + #endif diff --git a/std/bytes/module.ae b/std/bytes/module.ae index 5330ec8bc..ff1cb9458 100644 --- a/std/bytes/module.ae +++ b/std/bytes/module.ae @@ -21,6 +21,7 @@ exports( set_le16, get_le16, set_le32, get_le32, set_le64, get_le64, set_be16, get_be16, set_be32, get_be32, set_be64, get_be64, copy_from_string, copy_from_bytes, copy_within, finish, to_string, free, + wipe, wipe_free, data, capacity, set_length, from_ptr, string_from_ptr, view, capacity_view, from_slice, copy_from_slice, string_from_slice ) @@ -69,6 +70,8 @@ extern aether_bytes_finish(b: @frees ptr, length: int) -> string @heap // owner can keep it and mint again. See to_string wrapper below. extern aether_bytes_to_string(b: ptr, length: int) -> string @heap extern aether_bytes_free(b: @frees ptr) +extern aether_bytes_wipe(b: ptr) +extern aether_bytes_wipe_free(b: @frees ptr) // ---- Aether-side wrappers ---- // Allocate a new mutable byte buffer with at least `initial_capacity` @@ -320,3 +323,20 @@ to_string(b: ptr, length: int) -> string { free(b: @frees ptr) { aether_bytes_free(b) } + +// Zero every byte the buffer holds, its whole capacity, so that the writes +// happen even right before a free: a loop of `set` calls is a dead store the +// C compiler may drop once it inlines them (link-time optimization does) +// (#3376). The buffer stays valid and keeps its length. +// Use it on secrets: key material, nonces, the text of a private key. +// Nothing on null, or on a string, which is immutable and may be shared. A +// buffer that grew moved its contents, and the memory it moved out of was +// freed as it was, so give a secret a buffer of its final size. +wipe(b: ptr) { + aether_bytes_wipe(b) +} + +// wipe, then free. Idempotent on null. +wipe_free(b: @frees ptr) { + aether_bytes_wipe_free(b) +} diff --git a/std/bytes/test_bytes.ae b/std/bytes/test_bytes.ae index 644d0e411..0252ba906 100644 --- a/std/bytes/test_bytes.ae +++ b/std/bytes/test_bytes.ae @@ -17,6 +17,8 @@ // 5. copy_within non-overlapping — dst <= src, full source must // already exist; fails if it doesn't. // 6. free without finish — discard path. +// 7. wipe zeroes the whole capacity, past the length too, and keeps the +// buffer and its length; wipe_free and both on null (#3376). import std.bytes import std.string @@ -102,5 +104,45 @@ main() { bytes.free(b6) println(" PASS: free without finish") + // Case 7: wipe zeroes every byte of the capacity, not only the live + // ones: a secret written by data() and trimmed with set_length is still + // in the storage past the length (#3376). + b7 = bytes.new(32) + cap7 = bytes.capacity(b7) + all7 = bytes.capacity_view(b7) + k = 0 + while k < cap7 { + all7[k] = 165 + k = k + 1 + } + bytes.set_length(b7, 8) + bytes.wipe(b7) + if bytes.length(b7) != 8 || bytes.capacity(b7) != cap7 { + println(" FAIL 7: wipe changed the buffer: length ${bytes.length(b7)}, capacity ${bytes.capacity(b7)}") + exit(1) + } + left7 = 0 + seen7 = bytes.capacity_view(b7) + k = 0 + while k < cap7 { + if seen7[k] != 0 { + left7 = left7 + 1 + } + k = k + 1 + } + if left7 != 0 { + println(" FAIL 7: ${left7} of ${cap7} bytes not zero after wipe") + exit(1) + } + bytes.set(b7, 0, 7) + if bytes.get(b7, 0) != 7 { + println(" FAIL 7: the buffer is not usable after wipe") + exit(1) + } + bytes.wipe_free(b7) + bytes.wipe(null) + bytes.wipe_free(null) + println(" PASS: wipe zeroes the whole capacity and keeps the buffer") + println("=== All cases passed ===") } diff --git a/std/cryptography/aes/aether_aes.c b/std/cryptography/aes/aether_aes.c index efffaa62f..5ad415e84 100644 --- a/std/cryptography/aes/aether_aes.c +++ b/std/cryptography/aes/aether_aes.c @@ -1,140 +1,497 @@ +/* MIT License (https://opensource.org/licenses/MIT) + * + * Portions copyright (c) 2016 Thomas Pornin (BearSSL, https://bearssl.org) + * + * Portions copyright (c) 2026 Aether Developers. + */ + #include "aether_aes.h" #include "../../../runtime/aether_resource_caps.h" +#include "../../../runtime/utils/aether_wipe.h" #include #include #include -/* FIPS-197 over a raw pointer. Correctness is pinned by the same KATs the - * Aether implementation is pinned by (tests/regression/test_aes.ae). */ - -static const uint8_t SBOX[256] = { -0x63,0x7c,0x77,0x7b,0xf2,0x6b,0x6f,0xc5,0x30,0x01,0x67,0x2b,0xfe,0xd7,0xab,0x76, -0xca,0x82,0xc9,0x7d,0xfa,0x59,0x47,0xf0,0xad,0xd4,0xa2,0xaf,0x9c,0xa4,0x72,0xc0, -0xb7,0xfd,0x93,0x26,0x36,0x3f,0xf7,0xcc,0x34,0xa5,0xe5,0xf1,0x71,0xd8,0x31,0x15, -0x04,0xc7,0x23,0xc3,0x18,0x96,0x05,0x9a,0x07,0x12,0x80,0xe2,0xeb,0x27,0xb2,0x75, -0x09,0x83,0x2c,0x1a,0x1b,0x6e,0x5a,0xa0,0x52,0x3b,0xd6,0xb3,0x29,0xe3,0x2f,0x84, -0x53,0xd1,0x00,0xed,0x20,0xfc,0xb1,0x5b,0x6a,0xcb,0xbe,0x39,0x4a,0x4c,0x58,0xcf, -0xd0,0xef,0xaa,0xfb,0x43,0x4d,0x33,0x85,0x45,0xf9,0x02,0x7f,0x50,0x3c,0x9f,0xa8, -0x51,0xa3,0x40,0x8f,0x92,0x9d,0x38,0xf5,0xbc,0xb6,0xda,0x21,0x10,0xff,0xf3,0xd2, -0xcd,0x0c,0x13,0xec,0x5f,0x97,0x44,0x17,0xc4,0xa7,0x7e,0x3d,0x64,0x5d,0x19,0x73, -0x60,0x81,0x4f,0xdc,0x22,0x2a,0x90,0x88,0x46,0xee,0xb8,0x14,0xde,0x5e,0x0b,0xdb, -0xe0,0x32,0x3a,0x0a,0x49,0x06,0x24,0x5c,0xc2,0xd3,0xac,0x62,0x91,0x95,0xe4,0x79, -0xe7,0xc8,0x37,0x6d,0x8d,0xd5,0x4e,0xa9,0x6c,0x56,0xf4,0xea,0x65,0x7a,0xae,0x08, -0xba,0x78,0x25,0x2e,0x1c,0xa6,0xb4,0xc6,0xe8,0xdd,0x74,0x1f,0x4b,0xbd,0x8b,0x8a, -0x70,0x3e,0xb5,0x66,0x48,0x03,0xf6,0x0e,0x61,0x35,0x57,0xb9,0x86,0xc1,0x1d,0x9e, -0xe1,0xf8,0x98,0x11,0x69,0xd9,0x8e,0x94,0x9b,0x1e,0x87,0xe9,0xce,0x55,0x28,0xdf, -0x8c,0xa1,0x89,0x0d,0xbf,0xe6,0x42,0x68,0x41,0x99,0x2d,0x0f,0xb0,0x54,0xbb,0x16}; - -static uint8_t ISBOX[256]; -static int g_isbox_ready = 0; - -static void build_isbox(void) { - if (g_isbox_ready) return; - for (int i = 0; i < 256; i++) ISBOX[SBOX[i]] = (uint8_t)i; - g_isbox_ready = 1; +/* FIPS-197 AES in constant time (#2600). + * + * The cipher used to index a 256-byte S-box with key- and data-dependent + * bytes, which leaks the key through the cache (Bernstein 2005; Osvik, + * Shamir and Tromer 2006). This is the 64-bit bitsliced form of Thomas + * Pornin's BearSSL "ct64" implementation (MIT licence): four blocks are + * spread over eight 64-bit words, bit i of every state byte in word i, and + * every step is a fixed sequence of AND, XOR, shifts and rotations: + * + * SubBytes the Boyar-Peralta circuit of 113 gates (eprint 2009/191), + * and its inverse via the affine maps around it; + * ShiftRows masks and shifts within each word; + * MixColumns XORs and 16/32-bit rotations; + * key schedule the same S-box circuit on one word. + * + * No branch and no memory index depends on the key or the data, which + * tests/integration/crypto_ct_valgrind checks under Valgrind. Correctness + * is pinned by the FIPS-197 KATs (tests/regression/test_aes.ae) and the + * Wycheproof AES-GCM and AES-CMAC vectors. */ + +/* ---- the bitsliced S-box (BearSSL br_aes_ct64_bitslice_Sbox) ---- */ + +static void sbox_bits(uint64_t* q) { + /* x0 is the high bit, x7 the low bit, as in Boyar and Peralta. */ + uint64_t x0, x1, x2, x3, x4, x5, x6, x7; + uint64_t y1, y2, y3, y4, y5, y6, y7, y8, y9; + uint64_t y10, y11, y12, y13, y14, y15, y16, y17, y18, y19; + uint64_t y20, y21; + uint64_t z0, z1, z2, z3, z4, z5, z6, z7, z8, z9; + uint64_t z10, z11, z12, z13, z14, z15, z16, z17; + uint64_t t0, t1, t2, t3, t4, t5, t6, t7, t8, t9; + uint64_t t10, t11, t12, t13, t14, t15, t16, t17, t18, t19; + uint64_t t20, t21, t22, t23, t24, t25, t26, t27, t28, t29; + uint64_t t30, t31, t32, t33, t34, t35, t36, t37, t38, t39; + uint64_t t40, t41, t42, t43, t44, t45, t46, t47, t48, t49; + uint64_t t50, t51, t52, t53, t54, t55, t56, t57, t58, t59; + uint64_t t60, t61, t62, t63, t64, t65, t66, t67; + uint64_t s0, s1, s2, s3, s4, s5, s6, s7; + + x0 = q[7]; + x1 = q[6]; + x2 = q[5]; + x3 = q[4]; + x4 = q[3]; + x5 = q[2]; + x6 = q[1]; + x7 = q[0]; + + /* Top linear transformation. */ + y14 = x3 ^ x5; + y13 = x0 ^ x6; + y9 = x0 ^ x3; + y8 = x0 ^ x5; + t0 = x1 ^ x2; + y1 = t0 ^ x7; + y4 = y1 ^ x3; + y12 = y13 ^ y14; + y2 = y1 ^ x0; + y5 = y1 ^ x6; + y3 = y5 ^ y8; + t1 = x4 ^ y12; + y15 = t1 ^ x5; + y20 = t1 ^ x1; + y6 = y15 ^ x7; + y10 = y15 ^ t0; + y11 = y20 ^ y9; + y7 = x7 ^ y11; + y17 = y10 ^ y11; + y19 = y10 ^ y8; + y16 = t0 ^ y11; + y21 = y13 ^ y16; + y18 = x0 ^ y16; + + /* Non-linear section. */ + t2 = y12 & y15; + t3 = y3 & y6; + t4 = t3 ^ t2; + t5 = y4 & x7; + t6 = t5 ^ t2; + t7 = y13 & y16; + t8 = y5 & y1; + t9 = t8 ^ t7; + t10 = y2 & y7; + t11 = t10 ^ t7; + t12 = y9 & y11; + t13 = y14 & y17; + t14 = t13 ^ t12; + t15 = y8 & y10; + t16 = t15 ^ t12; + t17 = t4 ^ t14; + t18 = t6 ^ t16; + t19 = t9 ^ t14; + t20 = t11 ^ t16; + t21 = t17 ^ y20; + t22 = t18 ^ y19; + t23 = t19 ^ y21; + t24 = t20 ^ y18; + + t25 = t21 ^ t22; + t26 = t21 & t23; + t27 = t24 ^ t26; + t28 = t25 & t27; + t29 = t28 ^ t22; + t30 = t23 ^ t24; + t31 = t22 ^ t26; + t32 = t31 & t30; + t33 = t32 ^ t24; + t34 = t23 ^ t33; + t35 = t27 ^ t33; + t36 = t24 & t35; + t37 = t36 ^ t34; + t38 = t27 ^ t36; + t39 = t29 & t38; + t40 = t25 ^ t39; + + t41 = t40 ^ t37; + t42 = t29 ^ t33; + t43 = t29 ^ t40; + t44 = t33 ^ t37; + t45 = t42 ^ t41; + z0 = t44 & y15; + z1 = t37 & y6; + z2 = t33 & x7; + z3 = t43 & y16; + z4 = t40 & y1; + z5 = t29 & y7; + z6 = t42 & y11; + z7 = t45 & y17; + z8 = t41 & y10; + z9 = t44 & y12; + z10 = t37 & y3; + z11 = t33 & y4; + z12 = t43 & y13; + z13 = t40 & y5; + z14 = t29 & y2; + z15 = t42 & y9; + z16 = t45 & y14; + z17 = t41 & y8; + + /* Bottom linear transformation. */ + t46 = z15 ^ z16; + t47 = z10 ^ z11; + t48 = z5 ^ z13; + t49 = z9 ^ z10; + t50 = z2 ^ z12; + t51 = z2 ^ z5; + t52 = z7 ^ z8; + t53 = z0 ^ z3; + t54 = z6 ^ z7; + t55 = z16 ^ z17; + t56 = z12 ^ t48; + t57 = t50 ^ t53; + t58 = z4 ^ t46; + t59 = z3 ^ t54; + t60 = t46 ^ t57; + t61 = z14 ^ t57; + t62 = t52 ^ t58; + t63 = t49 ^ t58; + t64 = z4 ^ t59; + t65 = t61 ^ t62; + t66 = z1 ^ t63; + s0 = t59 ^ t63; + s6 = t56 ^ ~t62; + s7 = t48 ^ ~t60; + t67 = t64 ^ t65; + s3 = t53 ^ t66; + s4 = t51 ^ t66; + s5 = t47 ^ t65; + s1 = t64 ^ ~s3; + s2 = t55 ^ ~t67; + + q[7] = s0; + q[6] = s1; + q[5] = s2; + q[4] = s3; + q[3] = s4; + q[2] = s5; + q[1] = s6; + q[0] = s7; +} + +/* The inverse affine map of the S-box, A^-1(x) = (x <<< 1) ^ (x <<< 3) ^ + * (x <<< 6) ^ 0x05 in bit-planes: InvSubBytes(x) = A^-1(S(A^-1(x))), since + * S(x) = A(x^-1). */ +static void inv_affine_bits(uint64_t* q) { + uint64_t q0 = ~q[0], q1 = ~q[1], q2 = q[2], q3 = q[3]; + uint64_t q4 = q[4], q5 = ~q[5], q6 = ~q[6], q7 = q[7]; + q[7] = q1 ^ q4 ^ q6; + q[6] = q0 ^ q3 ^ q5; + q[5] = q7 ^ q2 ^ q4; + q[4] = q6 ^ q1 ^ q3; + q[3] = q5 ^ q0 ^ q2; + q[2] = q4 ^ q7 ^ q1; + q[1] = q3 ^ q6 ^ q0; + q[0] = q2 ^ q5 ^ q7; +} + +static void inv_sbox_bits(uint64_t* q) { + inv_affine_bits(q); + sbox_bits(q); + inv_affine_bits(q); +} + +/* ---- packing (BearSSL br_aes_ct64_ortho / interleave_in / _out) ---- */ + +#define AE_SWAPN(cl, ch, s, x, y) do { \ + uint64_t a_ = (x), b_ = (y); \ + (x) = (a_ & (uint64_t)(cl)) | ((b_ & (uint64_t)(cl)) << (s)); \ + (y) = ((a_ & (uint64_t)(ch)) >> (s)) | (b_ & (uint64_t)(ch)); \ + } while (0) +#define AE_SWAP2(x, y) AE_SWAPN(0x5555555555555555ULL, 0xAAAAAAAAAAAAAAAAULL, 1, x, y) +#define AE_SWAP4(x, y) AE_SWAPN(0x3333333333333333ULL, 0xCCCCCCCCCCCCCCCCULL, 2, x, y) +#define AE_SWAP8(x, y) AE_SWAPN(0x0F0F0F0F0F0F0F0FULL, 0xF0F0F0F0F0F0F0F0ULL, 4, x, y) + +static void ortho(uint64_t* q) { + AE_SWAP2(q[0], q[1]); + AE_SWAP2(q[2], q[3]); + AE_SWAP2(q[4], q[5]); + AE_SWAP2(q[6], q[7]); + AE_SWAP4(q[0], q[2]); + AE_SWAP4(q[1], q[3]); + AE_SWAP4(q[4], q[6]); + AE_SWAP4(q[5], q[7]); + AE_SWAP8(q[0], q[4]); + AE_SWAP8(q[1], q[5]); + AE_SWAP8(q[2], q[6]); + AE_SWAP8(q[3], q[7]); +} + +static void interleave_in(uint64_t* q0, uint64_t* q1, const uint32_t* w) { + uint64_t x0 = w[0], x1 = w[1], x2 = w[2], x3 = w[3]; + x0 |= (x0 << 16); + x1 |= (x1 << 16); + x2 |= (x2 << 16); + x3 |= (x3 << 16); + x0 &= 0x0000FFFF0000FFFFULL; + x1 &= 0x0000FFFF0000FFFFULL; + x2 &= 0x0000FFFF0000FFFFULL; + x3 &= 0x0000FFFF0000FFFFULL; + x0 |= (x0 << 8); + x1 |= (x1 << 8); + x2 |= (x2 << 8); + x3 |= (x3 << 8); + x0 &= 0x00FF00FF00FF00FFULL; + x1 &= 0x00FF00FF00FF00FFULL; + x2 &= 0x00FF00FF00FF00FFULL; + x3 &= 0x00FF00FF00FF00FFULL; + *q0 = x0 | (x2 << 8); + *q1 = x1 | (x3 << 8); +} + +static void interleave_out(uint32_t* w, uint64_t q0, uint64_t q1) { + uint64_t x0 = q0 & 0x00FF00FF00FF00FFULL; + uint64_t x1 = q1 & 0x00FF00FF00FF00FFULL; + uint64_t x2 = (q0 >> 8) & 0x00FF00FF00FF00FFULL; + uint64_t x3 = (q1 >> 8) & 0x00FF00FF00FF00FFULL; + x0 |= (x0 >> 8); + x1 |= (x1 >> 8); + x2 |= (x2 >> 8); + x3 |= (x3 >> 8); + x0 &= 0x0000FFFF0000FFFFULL; + x1 &= 0x0000FFFF0000FFFFULL; + x2 &= 0x0000FFFF0000FFFFULL; + x3 &= 0x0000FFFF0000FFFFULL; + w[0] = (uint32_t)x0 | (uint32_t)(x0 >> 16); + w[1] = (uint32_t)x1 | (uint32_t)(x1 >> 16); + w[2] = (uint32_t)x2 | (uint32_t)(x2 >> 16); + w[3] = (uint32_t)x3 | (uint32_t)(x3 >> 16); +} + +/* ---- rounds (BearSSL ct64 encrypt and decrypt) ---- */ + +static void add_round_key(uint64_t* q, const uint64_t* sk) { + for (int i = 0; i < 8; i++) q[i] ^= sk[i]; +} + +static void shift_rows(uint64_t* q) { + for (int i = 0; i < 8; i++) { + uint64_t x = q[i]; + q[i] = (x & 0x000000000000FFFFULL) + | ((x & 0x00000000FFF00000ULL) >> 4) + | ((x & 0x00000000000F0000ULL) << 12) + | ((x & 0x0000FF0000000000ULL) >> 8) + | ((x & 0x000000FF00000000ULL) << 8) + | ((x & 0xF000000000000000ULL) >> 12) + | ((x & 0x0FFF000000000000ULL) << 4); + } +} + +static void inv_shift_rows(uint64_t* q) { + for (int i = 0; i < 8; i++) { + uint64_t x = q[i]; + q[i] = (x & 0x000000000000FFFFULL) + | ((x & 0x000000000FFF0000ULL) << 4) + | ((x & 0x00000000F0000000ULL) >> 12) + | ((x & 0x000000FF00000000ULL) << 8) + | ((x & 0x0000FF0000000000ULL) >> 8) + | ((x & 0x000F000000000000ULL) << 12) + | ((x & 0xFFF0000000000000ULL) >> 4); + } +} + +static uint64_t rotr32(uint64_t x) { + return (x << 32) | (x >> 32); +} + +static void mix_columns(uint64_t* q) { + uint64_t q0 = q[0], q1 = q[1], q2 = q[2], q3 = q[3]; + uint64_t q4 = q[4], q5 = q[5], q6 = q[6], q7 = q[7]; + uint64_t r0 = (q0 >> 16) | (q0 << 48); + uint64_t r1 = (q1 >> 16) | (q1 << 48); + uint64_t r2 = (q2 >> 16) | (q2 << 48); + uint64_t r3 = (q3 >> 16) | (q3 << 48); + uint64_t r4 = (q4 >> 16) | (q4 << 48); + uint64_t r5 = (q5 >> 16) | (q5 << 48); + uint64_t r6 = (q6 >> 16) | (q6 << 48); + uint64_t r7 = (q7 >> 16) | (q7 << 48); + q[0] = q7 ^ r7 ^ r0 ^ rotr32(q0 ^ r0); + q[1] = q0 ^ r0 ^ q7 ^ r7 ^ r1 ^ rotr32(q1 ^ r1); + q[2] = q1 ^ r1 ^ r2 ^ rotr32(q2 ^ r2); + q[3] = q2 ^ r2 ^ q7 ^ r7 ^ r3 ^ rotr32(q3 ^ r3); + q[4] = q3 ^ r3 ^ q7 ^ r7 ^ r4 ^ rotr32(q4 ^ r4); + q[5] = q4 ^ r4 ^ r5 ^ rotr32(q5 ^ r5); + q[6] = q5 ^ r5 ^ r6 ^ rotr32(q6 ^ r6); + q[7] = q6 ^ r6 ^ r7 ^ rotr32(q7 ^ r7); +} + +/* InvMixColumns as the standard pre-step, each column times + * {04}x^2 + {05}, then MixColumns: in bit-planes, multiplying a byte by + * {04} is a linear map, and the pre-step pairs row i with row i + 2, + * which is a 32-bit rotation of each word. */ +static void inv_mix_columns(uint64_t* q) { + /* u = xtime(xtime(a ^ (a >>> 2 rows))) for every byte. */ + uint64_t a[8], u[8]; + for (int i = 0; i < 8; i++) a[i] = q[i] ^ rotr32(q[i]); + /* xtime twice: bit i of 4a is bit i-2 of a, with the reduction + * polynomial 0x1b folded in for the two bits that shift out. */ + u[0] = a[6]; + u[1] = a[7] ^ a[6]; + u[2] = a[0] ^ a[7]; + u[3] = a[1] ^ a[6]; + u[4] = a[2] ^ a[7] ^ a[6]; + u[5] = a[3] ^ a[7]; + u[6] = a[4]; + u[7] = a[5]; + for (int i = 0; i < 8; i++) q[i] ^= u[i]; + mix_columns(q); +} + +static void encrypt_bits(int rounds, const uint64_t* sk, uint64_t* q) { + add_round_key(q, sk); + for (int u = 1; u < rounds; u++) { + sbox_bits(q); + shift_rows(q); + mix_columns(q); + add_round_key(q, sk + (u << 3)); + } + sbox_bits(q); + shift_rows(q); + add_round_key(q, sk + (rounds << 3)); +} + +static void decrypt_bits(int rounds, const uint64_t* sk, uint64_t* q) { + add_round_key(q, sk + (rounds << 3)); + for (int u = rounds - 1; u > 0; u--) { + inv_shift_rows(q); + inv_sbox_bits(q); + add_round_key(q, sk + (u << 3)); + inv_mix_columns(q); + } + inv_shift_rows(q); + inv_sbox_bits(q); + add_round_key(q, sk); +} + +/* ---- key schedule (BearSSL br_aes_ct64_keysched / skey_expand) ---- */ + +static uint32_t sub_word(uint32_t x) { + uint64_t q[8]; + memset(q, 0, sizeof q); + q[0] = x; + ortho(q); + sbox_bits(q); + ortho(q); + return (uint32_t)q[0]; +} + +static uint32_t dec32le(const uint8_t* p) { + return (uint32_t)p[0] | ((uint32_t)p[1] << 8) + | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24); +} + +static void enc32le(uint8_t* p, uint32_t x) { + p[0] = (uint8_t)x; + p[1] = (uint8_t)(x >> 8); + p[2] = (uint8_t)(x >> 16); + p[3] = (uint8_t)(x >> 24); } typedef struct { - uint8_t rk[240]; /* expanded key, up to 15 rounds x 16 bytes */ + uint64_t sk[120]; /* expanded round keys, bitsliced: 8 words per round */ int rounds; int encrypt; } AeAes; -static uint8_t xtime(uint8_t x) { - return (uint8_t)((x << 1) ^ ((x >> 7) * 0x1b)); -} - static void expand_key(AeAes* c, const uint8_t* key, int keylen) { + static const uint8_t rcon[10] = { 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1B, 0x36 }; int nk = keylen / 4; - int nr = nk + 6; - c->rounds = nr; - memcpy(c->rk, key, (size_t)keylen); - uint8_t rcon = 1; - for (int i = nk; i < 4 * (nr + 1); i++) { - uint8_t t[4]; - memcpy(t, c->rk + (i - 1) * 4, 4); - if (i % nk == 0) { - uint8_t tmp = t[0]; - t[0] = (uint8_t)(SBOX[t[1]] ^ rcon); - t[1] = SBOX[t[2]]; - t[2] = SBOX[t[3]]; - t[3] = SBOX[tmp]; - rcon = xtime(rcon); - } else if (nk > 6 && i % nk == 4) { - for (int j = 0; j < 4; j++) t[j] = SBOX[t[j]]; + int rounds = nk + 6; + int nkf = (rounds + 1) * 4; + uint32_t skey[60]; + uint64_t comp[30]; + c->rounds = rounds; + for (int i = 0; i < nk; i++) skey[i] = dec32le(key + 4 * i); + uint32_t tmp = skey[nk - 1]; + for (int i = nk, j = 0, k = 0; i < nkf; i++) { + if (j == 0) { + tmp = (tmp << 24) | (tmp >> 8); + tmp = sub_word(tmp) ^ rcon[k]; + } else if (nk > 6 && j == 4) { + tmp = sub_word(tmp); } - for (int j = 0; j < 4; j++) { - c->rk[i * 4 + j] = (uint8_t)(c->rk[(i - nk) * 4 + j] ^ t[j]); + tmp ^= skey[i - nk]; + skey[i] = tmp; + if (++j == nk) { + j = 0; + k++; } } -} - -static void add_round_key(uint8_t* s, const uint8_t* rk, int rnd) { - for (int i = 0; i < 16; i++) s[i] ^= rk[rnd * 16 + i]; -} - -static void encrypt_block(const AeAes* c, const uint8_t* in, uint8_t* out) { - uint8_t s[16]; - memcpy(s, in, 16); - add_round_key(s, c->rk, 0); - for (int rnd = 1; rnd <= c->rounds; rnd++) { - for (int i = 0; i < 16; i++) s[i] = SBOX[s[i]]; - /* ShiftRows, column-major state as in FIPS-197 */ - uint8_t t; - t = s[1]; s[1] = s[5]; s[5] = s[9]; s[9] = s[13]; s[13] = t; - t = s[2]; s[2] = s[10]; s[10] = t; t = s[6]; s[6] = s[14]; s[14] = t; - t = s[15]; s[15] = s[11]; s[11] = s[7]; s[7] = s[3]; s[3] = t; - if (rnd != c->rounds) { - /* MixColumns via xtime only. The gmul form costs a bit-loop per - coefficient, which is ~29 million calls per megabyte. */ - for (int col = 0; col < 4; col++) { - uint8_t* p = s + col * 4; - uint8_t a0 = p[0], a1 = p[1], a2 = p[2], a3 = p[3]; - uint8_t t = (uint8_t)(a0 ^ a1 ^ a2 ^ a3); - p[0] = (uint8_t)(a0 ^ t ^ xtime((uint8_t)(a0 ^ a1))); - p[1] = (uint8_t)(a1 ^ t ^ xtime((uint8_t)(a1 ^ a2))); - p[2] = (uint8_t)(a2 ^ t ^ xtime((uint8_t)(a2 ^ a3))); - p[3] = (uint8_t)(a3 ^ t ^ xtime((uint8_t)(a3 ^ a0))); - } - } - add_round_key(s, c->rk, rnd); + for (int i = 0, j = 0; i < nkf; i += 4, j += 2) { + uint64_t q[8]; + interleave_in(&q[0], &q[4], skey + i); + q[1] = q[0]; + q[2] = q[0]; + q[3] = q[0]; + q[5] = q[4]; + q[6] = q[4]; + q[7] = q[4]; + ortho(q); + comp[j + 0] = (q[0] & 0x1111111111111111ULL) | (q[1] & 0x2222222222222222ULL) + | (q[2] & 0x4444444444444444ULL) | (q[3] & 0x8888888888888888ULL); + comp[j + 1] = (q[4] & 0x1111111111111111ULL) | (q[5] & 0x2222222222222222ULL) + | (q[6] & 0x4444444444444444ULL) | (q[7] & 0x8888888888888888ULL); } - memcpy(out, s, 16); -} - -static void decrypt_block(const AeAes* c, const uint8_t* in, uint8_t* out) { - build_isbox(); - uint8_t s[16]; - memcpy(s, in, 16); - add_round_key(s, c->rk, c->rounds); - for (int rnd = c->rounds - 1; rnd >= 0; rnd--) { - uint8_t t; - /* InvShiftRows */ - t = s[13]; s[13] = s[9]; s[9] = s[5]; s[5] = s[1]; s[1] = t; - t = s[2]; s[2] = s[10]; s[10] = t; t = s[6]; s[6] = s[14]; s[14] = t; - t = s[3]; s[3] = s[7]; s[7] = s[11]; s[11] = s[15]; s[15] = t; - for (int i = 0; i < 16; i++) s[i] = ISBOX[s[i]]; - add_round_key(s, c->rk, rnd); - if (rnd != 0) { - /* InvMixColumns as the standard pre-step plus forward MixColumns, - so it is xtime-only too. */ - for (int col = 0; col < 4; col++) { - uint8_t* p = s + col * 4; - uint8_t a0 = p[0], a1 = p[1], a2 = p[2], a3 = p[3]; - uint8_t u = xtime(xtime((uint8_t)(a0 ^ a2))); - uint8_t v = xtime(xtime((uint8_t)(a1 ^ a3))); - a0 = (uint8_t)(a0 ^ u); a1 = (uint8_t)(a1 ^ v); - a2 = (uint8_t)(a2 ^ u); a3 = (uint8_t)(a3 ^ v); - uint8_t t = (uint8_t)(a0 ^ a1 ^ a2 ^ a3); - p[0] = (uint8_t)(a0 ^ t ^ xtime((uint8_t)(a0 ^ a1))); - p[1] = (uint8_t)(a1 ^ t ^ xtime((uint8_t)(a1 ^ a2))); - p[2] = (uint8_t)(a2 ^ t ^ xtime((uint8_t)(a2 ^ a3))); - p[3] = (uint8_t)(a3 ^ t ^ xtime((uint8_t)(a3 ^ a0))); - } - } + int n = (rounds + 1) << 1; + for (int u = 0, v = 0; u < n; u++, v += 4) { + uint64_t x0 = comp[u], x1 = comp[u], x2 = comp[u], x3 = comp[u]; + x0 &= 0x1111111111111111ULL; + x1 &= 0x2222222222222222ULL; + x2 &= 0x4444444444444444ULL; + x3 &= 0x8888888888888888ULL; + x1 >>= 1; + x2 >>= 2; + x3 >>= 3; + c->sk[v + 0] = (x0 << 4) - x0; + c->sk[v + 1] = (x1 << 4) - x1; + c->sk[v + 2] = (x2 << 4) - x2; + c->sk[v + 3] = (x3 << 4) - x3; } - memcpy(out, s, 16); + aether_wipe(skey, sizeof skey); + aether_wipe(comp, sizeof comp); +} + +/* Run 1 to 4 blocks (16 * nb bytes at in) through the cipher into out; + * in and out may alias. */ +static void run_blocks(const AeAes* c, int encrypt, const uint8_t* in, uint8_t* out, int nb) { + uint32_t w[16]; + uint64_t q[8]; + memset(w, 0, sizeof w); + for (int i = 0; i < 4 * nb; i++) w[i] = dec32le(in + 4 * i); + for (int i = 0; i < 4; i++) interleave_in(&q[i], &q[i + 4], w + (i << 2)); + ortho(q); + if (encrypt) encrypt_bits(c->rounds, c->sk, q); + else decrypt_bits(c->rounds, c->sk, q); + ortho(q); + for (int i = 0; i < 4; i++) interleave_out(w + (i << 2), q[i], q[i + 4]); + for (int i = 0; i < 4 * nb; i++) enc32le(out + 4 * i, w[i]); + aether_wipe(w, sizeof w); + aether_wipe(q, sizeof q); } int aether_aes_available(void) { return 1; } @@ -152,16 +509,16 @@ void* aether_aes_new(const void* key, int keylen, int encrypt) { void aether_aes_free(void* ctx) { if (!ctx) return; - /* Key material must not outlive the context. */ - memset(ctx, 0, sizeof(AeAes)); + /* Key material must not outlive the context: aether_wipe, as a memset + * before a free is a dead store the compiler may drop (#3402). */ + aether_wipe(ctx, sizeof(AeAes)); aether_caps_free(ctx, sizeof(AeAes)); } int aether_aes_block(void* ctx, const void* in, void* out) { AeAes* c = (AeAes*)ctx; if (!c || !in || !out) return 0; - if (c->encrypt) encrypt_block(c, (const uint8_t*)in, (uint8_t*)out); - else decrypt_block(c, (const uint8_t*)in, (uint8_t*)out); + run_blocks(c, c->encrypt, (const uint8_t*)in, (uint8_t*)out, 1); return 1; } @@ -177,7 +534,7 @@ int aether_aes_cbc_encrypt(void* ctx, const void* iv, const void* in, for (int off = 0; off < n; off += 16) { uint8_t blk[16]; for (int i = 0; i < 16; i++) blk[i] = (uint8_t)(ip[off + i] ^ prev[i]); - encrypt_block(c, blk, op + off); + run_blocks(c, 1, blk, op + off, 1); memcpy(prev, op + off, 16); } return 1; @@ -188,15 +545,20 @@ int aether_aes_cbc_decrypt(void* ctx, const void* iv, const void* in, AeAes* c = (AeAes*)ctx; if (!c || c->encrypt || !iv || !in || !out) return 0; if (n < 0 || n % 16 != 0) return 0; - uint8_t prev[16], cur[16]; + uint8_t prev[16], cur[64], dec[64]; memcpy(prev, iv, 16); const uint8_t* ip = (const uint8_t*)in; uint8_t* op = (uint8_t*)out; - for (int off = 0; off < n; off += 16) { - memcpy(cur, ip + off, 16); /* in and out may alias */ - decrypt_block(c, cur, op + off); - for (int i = 0; i < 16; i++) op[off + i] ^= prev[i]; - memcpy(prev, cur, 16); + for (int off = 0; off < n; off += 64) { + int nb = (n - off) / 16; + if (nb > 4) nb = 4; + memcpy(cur, ip + off, (size_t)(16 * nb)); /* in and out may alias */ + run_blocks(c, 0, cur, dec, nb); + for (int b = 0; b < nb; b++) { + const uint8_t* chain = b == 0 ? prev : cur + 16 * (b - 1); + for (int i = 0; i < 16; i++) op[off + 16 * b + i] = (uint8_t)(dec[16 * b + i] ^ chain[i]); + } + memcpy(prev, cur + 16 * (nb - 1), 16); } return 1; } @@ -206,15 +568,24 @@ int aether_aes_ctr_xor(void* ctx, const void* iv, const void* in, AeAes* c = (AeAes*)ctx; if (!c || !c->encrypt || !iv || !in || !out) return 0; if (n < 0) return 0; - uint8_t ctr[16], ks[16]; + uint8_t ctr[16], blocks[64]; memcpy(ctr, iv, 16); const uint8_t* ip = (const uint8_t*)in; uint8_t* op = (uint8_t*)out; - for (int off = 0; off < n; off += 16) { - encrypt_block(c, ctr, ks); - int chunk = (n - off) < 16 ? (n - off) : 16; - for (int i = 0; i < chunk; i++) op[off + i] = (uint8_t)(ip[off + i] ^ ks[i]); - for (int i = 15; i >= 0; i--) { if (++ctr[i]) break; } + for (int off = 0; off < n; off += 64) { + int left = n - off; + int nb = (left + 15) / 16; + if (nb > 4) nb = 4; + for (int b = 0; b < nb; b++) { + memcpy(blocks + 16 * b, ctr, 16); + /* The counter is public (the IV and a block count): a branch + * on its carry leaks nothing. */ + for (int i = 15; i >= 0; i--) { if (++ctr[i]) break; } + } + run_blocks(c, 1, blocks, blocks, nb); + int chunk = left < 64 ? left : 64; + for (int i = 0; i < chunk; i++) op[off + i] = (uint8_t)(ip[off + i] ^ blocks[i]); } + aether_wipe(blocks, sizeof blocks); return 1; } diff --git a/std/cryptography/aes/aether_aes.h b/std/cryptography/aes/aether_aes.h index daa4e12c5..70d61b15c 100644 --- a/std/cryptography/aes/aether_aes.h +++ b/std/cryptography/aes/aether_aes.h @@ -3,12 +3,10 @@ #include -/* Native AES core (#1394). The Aether implementation in module.ae reaches - * every byte of state through bytes.get/bytes.set, which is about 15 million - * calls per megabyte for AES-256 and measured 4.4 MB/s. This is the same - * cipher over a raw pointer, used by the bulk modes when available; the - * Aether path stays as the portable fallback and the reference the tests - * compare against. +/* Native AES core (#1394), constant time since #2600: the 64-bit + * bitsliced form of BearSSL's aes_ct64 (see aether_aes.c), with no branch + * and no table index on the key or the data. Every mode in module.ae runs + * through it. * * Returns 0 on failure (bad key length, allocation, unsupported length). */ diff --git a/std/cryptography/aes/module.ae b/std/cryptography/aes/module.ae index fd63f71ed..05c17531b 100644 --- a/std/cryptography/aes/module.ae +++ b/std/cryptography/aes/module.ae @@ -10,13 +10,14 @@ // OCB / AES-key-wrap / AES-CMAC / AES-CTR-DRBG all sit on exactly this // primitive and nothing else. // -// Byte-oriented FIPS-197 reference form (one 256-byte S-box + inverse, -// GF(2^8) xtime for MixColumns) rather than Bouncy Castle's T-box -// `AesEngine.cs`. Smaller, obviously correct against the FIPS-197 / NIST KAT -// vectors, and not cache-timing-leaky the way big T-tables are. A T-box / -// AES-NI fast path is a later perf slice; the mode layers don't care which -// sits underneath. Algorithm + S-box from FIPS-197; structure follows -// Bouncy Castle's AesEngine. +// The cipher is aether_aes.c: the 64-bit bitsliced AES of BearSSL's ct64 +// (Boyar-Peralta S-box circuit, four blocks per pass), constant time in the +// key and the data. It replaced a byte-oriented form that indexed a 256-byte +// S-box with secret bytes, which leaks the key through the cache (#2600). +// GHASH (gf_mul) is a masked shift-and-add, constant time in H and the +// state; every tag check compares all bytes and declassifies one verdict. +// About 185 MB/s in CTR mode with the default build. Structure follows +// Bouncy Castle's AesEngine; algorithm from FIPS-197. // // Block primitive (what modes call): // ctx = aes.new_encryptor(key_bytes, key_len) // key_len = 16/24/32 @@ -35,9 +36,11 @@ // All buffers are std.bytes (caller frees results). import std.bytes +import std.mem import std.bits import std.intarr import std.string +import std.cryptography.ct as ctime exports( new_encryptor, new_decryptor, process_block, free, @@ -101,6 +104,7 @@ free(ctx: @frees ptr) { if ctx == null { return } c = ctx as *AesCtx if c.native != null { aether_aes_free(c.native) } + mem.wipe(ctx, sizeof(AesCtx)) libc_free(ctx) } @@ -129,7 +133,7 @@ ecb_run(ctx: ptr, data: ptr) -> ptr { eb = process_block(ctx, blk) k = 0 while k < 16 { bytes.set(out, off + k, bytes.get(eb, k)); k = k + 1 } - bytes.free(blk); bytes.free(eb) + bytes.wipe_free(blk); bytes.wipe_free(eb) off = off + 16 } return out @@ -254,7 +258,7 @@ pkcs7_unpad(data: ptr) -> { cbc_encrypt_pkcs7(ctx: ptr, iv: ptr, data: ptr) -> ptr { padded = pkcs7_pad(data) out = cbc_encrypt(ctx, iv, padded) - bytes.free(padded) + bytes.wipe_free(padded) return out } @@ -270,7 +274,7 @@ cbc_decrypt_pkcs7(ctx: ptr, iv: ptr, data: ptr) -> { } raw = cbc_decrypt(ctx, iv, data) out, err = pkcs7_unpad(raw) - bytes.free(raw) + bytes.wipe_free(raw) return out, err } @@ -293,11 +297,11 @@ cmac(key: byte[], data: byte[]) -> ptr { z = 0 while z < 16 { bytes.set(zero, z, 0); z = z + 1 } L = process_block(enc, zero) - bytes.free(zero) + bytes.wipe_free(zero) k1 = cmac_dbl(L) k2 = cmac_dbl(k1) - bytes.free(L) + bytes.wipe_free(L) // Number of 16-byte blocks; whether the last is complete. n = (len + 15) / 16 @@ -331,8 +335,8 @@ cmac(key: byte[], data: byte[]) -> ptr { j = j + 1 } } - bytes.free(k1) - bytes.free(k2) + bytes.wipe_free(k1) + bytes.wipe_free(k2) // CBC-MAC: X = 0; for each block except the last, X = E(X XOR M_i); // then tag = E(X XOR M_last). @@ -350,8 +354,8 @@ cmac(key: byte[], data: byte[]) -> ptr { j = j + 1 } eb = process_block(enc, blk) - bytes.free(blk) - bytes.free(x) + bytes.wipe_free(blk) + bytes.wipe_free(x) x = eb i = i + 1 } @@ -363,10 +367,10 @@ cmac(key: byte[], data: byte[]) -> ptr { bytes.set(fin, j, (bytes.get(x, j) ^ bytes.get(last, j)) & 0xFF) j = j + 1 } - bytes.free(x) - bytes.free(last) + bytes.wipe_free(x) + bytes.wipe_free(last) tag = process_block(enc, fin) - bytes.free(fin) + bytes.wipe_free(fin) free(enc) return tag } @@ -433,18 +437,18 @@ key_wrap(key: byte[], data: byte[]) -> ptr { // R[i] = LSB64(B) k = 0 while k < 8 { bytes.set(r, i * 8 + k, bytes.get(b, 8 + k)); k = k + 1 } - bytes.free(b) + bytes.wipe_free(b) i = i + 1 } j = j + 1 } - bytes.free(blk) + bytes.wipe_free(blk) out = bytes.new(len + 8) bytes.copy_from_bytes(out, 0, a, 0, 8) bytes.copy_from_bytes(out, 8, r, 0, len) - bytes.free(a) - bytes.free(r) + bytes.wipe_free(a) + bytes.wipe_free(r) free(enc) return out } @@ -487,24 +491,24 @@ key_unwrap(key: byte[], data: byte[]) -> { while k < 8 { bytes.set(a, k, bytes.get(b, k)); k = k + 1 } k = 0 while k < 8 { bytes.set(r, i * 8 + k, bytes.get(b, 8 + k)); k = k + 1 } - bytes.free(b) + bytes.wipe_free(b) i = i - 1 } j = j - 1 } - bytes.free(blk) + bytes.wipe_free(blk) free(dec) // Integrity: A must equal the default IV A6A6A6A6A6A6A6A6. ok = 1 k = 0 while k < 8 { - if (bytes.get(a, k) & 0xFF) != 0xA6 { ok = 0 } + ok = ok & ctime.eq(bytes.get(a, k) & 0xFF, 0xA6) k = k + 1 } - bytes.free(a) - if ok == 0 { - bytes.free(r) + bytes.wipe_free(a) + if ctime.declassify(ok) == 0 { + bytes.wipe_free(r) return null, "integrity check failed" } return r, "" @@ -522,44 +526,64 @@ key_unwrap(key: byte[], data: byte[]) -> { // GF(2^128) multiply (x * y) in GCM's bit-reflected basis. x,y,out are // 16-byte buffers (big-endian block order, MSB-first bit numbering). +// +// Constant time (#2600): x is the running GHASH state and y the hash key H, +// both secret. Every one of the 128 steps adds v to z under a mask built +// from x's bit and folds R into v under a mask built from v's low bit, so +// no branch and no memory index depends on either (the bit-by-bit loop it +// replaces branched on both). gf_mul(x: ptr, y: ptr) -> ptr { - z = bytes.new(16) - v = bytes.new(16) - i = 0 - while i < 16 { bytes.set(z, i, 0); bytes.set(v, i, bytes.get(y, i)); i = i + 1 } + uint64 xh = load_be64(x, 0) + uint64 xl = load_be64(x, 8) + uint64 vh = load_be64(y, 0) + uint64 vl = load_be64(y, 8) + uint64 zh = 0 + uint64 zl = 0 + uint64 r = 0xe100000000000000 // For each bit of x from MSB (bit 0 of byte 0) to LSB: - bit = 0 + int bit = 0 while bit < 128 { - byte_i = bit / 8 - bit_i = 7 - (bit % 8) - if ((bytes.get(x, byte_i) >> bit_i) & 1) == 1 { - k = 0 - while k < 16 { bytes.set(z, k, (bytes.get(z, k) ^ bytes.get(v, k)) & 0xFF); k = k + 1 } - } - // v = (v >> 1); if LSB of v was set, v ^= R (R = 0xe1 || 0...0) - lsb = bytes.get(v, 15) & 1 - gf_shr1(v) - if lsb == 1 { - bytes.set(v, 0, (bytes.get(v, 0) ^ 0xe1) & 0xFF) + uint64 xb = 0 + if bit < 64 { + xb = (xh >> (63 - bit)) & 1 + } else { + xb = (xl >> (127 - bit)) & 1 } + uint64 m = 0 - xb + zh = zh ^ (vh & m) + zl = zl ^ (vl & m) + // v = (v >> 1) ^ (R if v's low bit was set), R = 0xe1 || 0...0 + uint64 lsb = vl & 1 + vl = (vl >> 1) | (vh << 63) + vh = (vh >> 1) ^ (r & (0 - lsb)) bit = bit + 1 } - bytes.free(v) + z = bytes.new(16) + store_be64(z, 0, zh) + store_be64(z, 8, zl) return z } -// Right-shift a 16-byte big-endian value by 1 bit, in place. -gf_shr1(v: ptr) { - i = 0 - carry = 0 - while i < 16 { - cur = bytes.get(v, i) & 0xFF - nv = ((cur >> 1) | (carry << 7)) & 0xFF - carry = cur & 1 - bytes.set(v, i, nv) + +// Big-endian 64-bit load/store at byte offset off of a std.bytes buffer. +fn load_be64(b: ptr, off: int) -> uint64 { + uint64 v = 0 + int i = 0 + while i < 8 { + v = (v << 8) | ((bytes.get(b, off + i) & 255) as uint64) i = i + 1 } + return v } +fn store_be64(b: ptr, off: int, val: uint64) { + uint64 v = val + int i = 7 + while i >= 0 { + bytes.set(b, off + i, (v & 255) as int) + v = v >> 8 + i = i - 1 + } +} // GHASH: fold `data` (len bytes, processed in 16-byte blocks, zero-padded) // into the running hash `acc` (16 bytes) under subkey H. Returns a fresh acc. ghash_update(acc: ptr, h: ptr, data: byte[]) -> ptr { @@ -579,7 +603,7 @@ ghash_update(acc: ptr, h: ptr, data: byte[]) -> ptr { j = j + 1 } nxt = gf_mul(cur, h) - bytes.free(cur) + bytes.wipe_free(cur) cur = nxt off = off + 16 } @@ -620,10 +644,10 @@ gctr(ctx: ptr, ctr0: ptr, data: byte[]) -> ptr { } j = j + 1 } - bytes.free(ks) + bytes.wipe_free(ks) off = off + 16 } - bytes.free(ctr) + bytes.wipe_free(ctr) return out } @@ -638,21 +662,21 @@ gcm_tag(ctx: ptr, h: ptr, j0: ptr, aad: byte[], ct: byte[]) -> ptr { z = 0 while z < 16 { bytes.set(acc, z, 0); z = z + 1 } a1 = ghash_update(acc, h, aad) - bytes.free(acc) + bytes.wipe_free(acc) a2 = ghash_update(a1, h, ct) - bytes.free(a1) + bytes.wipe_free(a1) // length block: [len(AAD) in bits | len(C) in bits], each 64-bit BE lb = bytes.new(16) set_be64_bits(lb, 0, aad_len) set_be64_bits(lb, 8, ct_len) a3 = ghash_update(a2, h, bytes.view(lb)[0..16]) - bytes.free(a2); bytes.free(lb) + bytes.wipe_free(a2); bytes.wipe_free(lb) // S = E(J0); tag = a3 XOR S s = process_block(ctx, j0) tag = bytes.new(16) k = 0 while k < 16 { bytes.set(tag, k, (bytes.get(a3, k) ^ bytes.get(s, k)) & 0xFF); k = k + 1 } - bytes.free(a3); bytes.free(s) + bytes.wipe_free(a3); bytes.wipe_free(s) return tag } // Write (byte_len * 8) as a 64-bit big-endian value at offset off. @@ -685,7 +709,7 @@ gcm_seal(ctx: ptr, iv: ptr, aad: byte[], pt: byte[]) -> ptr { z = 0 while z < 16 { bytes.set(zero, z, 0); z = z + 1 } h = process_block(ctx, zero) - bytes.free(zero) + bytes.wipe_free(zero) j0 = gcm_j0(iv) ct = gctr(ctx, j0, pt) @@ -694,7 +718,7 @@ gcm_seal(ctx: ptr, iv: ptr, aad: byte[], pt: byte[]) -> ptr { out = bytes.new(pt_len + 16) bytes.copy_from_bytes(out, 0, ct, 0, pt_len) bytes.copy_from_bytes(out, pt_len, tag, 0, 16) - bytes.free(h); bytes.free(j0); bytes.free(ct); bytes.free(tag) + bytes.wipe_free(h); bytes.wipe_free(j0); bytes.wipe_free(ct); bytes.wipe_free(tag) return out } @@ -712,7 +736,7 @@ gcm_open(ctx: ptr, iv: ptr, aad: byte[], ct_with_tag: byte[]) -> { z = 0 while z < 16 { bytes.set(zero, z, 0); z = z + 1 } h = process_block(ctx, zero) - bytes.free(zero) + bytes.wipe_free(zero) j0 = gcm_j0(iv) want = gcm_tag(ctx, h, j0, aad, ct_with_tag[0..body_len]) @@ -723,13 +747,13 @@ gcm_open(ctx: ptr, iv: ptr, aad: byte[], ct_with_tag: byte[]) -> { diff = diff | ((bytes.get(want, i) ^ ct_with_tag[body_len + i]) & 0xFF) i = i + 1 } - bytes.free(want) - if diff != 0 { - bytes.free(h); bytes.free(j0) + bytes.wipe_free(want) + if ctime.declassify(ctime.eq(diff, 0)) == 0 { + bytes.wipe_free(h); bytes.wipe_free(j0) return null, "authentication failed" } pt = gctr(ctx, j0, ct_with_tag[0..body_len]) - bytes.free(h); bytes.free(j0) + bytes.wipe_free(h); bytes.wipe_free(j0) return pt, "" } @@ -794,7 +818,7 @@ ccm_cbc_mac(enc: ptr, nonce: byte[], aad: byte[], data: byte[], taglen: int) -> z = 0 while z < 16 { bytes.set(x, z, 0); z = z + 1 } x = ccm_mac_block(enc, x, b0) - bytes.free(b0) + bytes.wipe_free(b0) // AAD: length-prefix then AAD bytes, zero-padded to a block boundary. if aadlen > 0 { @@ -824,18 +848,18 @@ ccm_cbc_mac(enc: ptr, nonce: byte[], aad: byte[], data: byte[], taglen: int) -> while k < aadlen { bytes.set(ablk, hdr_extra + k, aad[k]); k = k + 1 } k = total while k < padded { bytes.set(ablk, k, 0); k = k + 1 } - bytes.free(prefix) + bytes.wipe_free(prefix) off = 0 while off < padded { blk = bytes.new(16) j = 0 while j < 16 { bytes.set(blk, j, bytes.get(ablk, off + j)); j = j + 1 } nx = ccm_mac_block(enc, x, blk) - bytes.free(x); bytes.free(blk) + bytes.wipe_free(x); bytes.wipe_free(blk) x = nx off = off + 16 } - bytes.free(ablk) + bytes.wipe_free(ablk) } // plaintext, zero-padded to a block boundary. @@ -850,7 +874,7 @@ ccm_cbc_mac(enc: ptr, nonce: byte[], aad: byte[], data: byte[], taglen: int) -> j = j + 1 } nx = ccm_mac_block(enc, x, blk) - bytes.free(x); bytes.free(blk) + bytes.wipe_free(x); bytes.wipe_free(blk) x = nx off = off + 16 } @@ -862,7 +886,7 @@ ccm_mac_block(enc: ptr, x: ptr, blk: ptr) -> ptr { j = 0 while j < 16 { bytes.set(t, j, (bytes.get(x, j) ^ bytes.get(blk, j)) & 0xFF); j = j + 1 } eb = process_block(enc, t) - bytes.free(t) + bytes.wipe_free(t) return eb } @@ -878,14 +902,14 @@ ccm_seal(key: byte[], nonce: byte[], aad: byte[], pt: byte[], taglen: int) -> { tag = bytes.new(taglen) i = 0 while i < taglen { bytes.set(tag, i, (bytes.get(mac, i) ^ bytes.get(s0, i)) & 0xFF); i = i + 1 } - bytes.free(s0); bytes.free(mac) + bytes.wipe_free(s0); bytes.wipe_free(mac) // S1.. encrypt the plaintext (ctr0 incremented before first data block). ct = bytes.new(ptlen) ctr = bytes.new(16) i = 0 while i < 16 { bytes.set(ctr, i, bytes.get(ctr0, i)); i = i + 1 } - bytes.free(ctr0) + bytes.wipe_free(ctr0) off = 0 while off < ptlen { ctr_increment(ctr) @@ -897,16 +921,16 @@ ccm_seal(key: byte[], nonce: byte[], aad: byte[], pt: byte[], taglen: int) -> { } j = j + 1 } - bytes.free(ks) + bytes.wipe_free(ks) off = off + 16 } - bytes.free(ctr) + bytes.wipe_free(ctr) free(enc) out = bytes.new(ptlen + taglen) bytes.copy_from_bytes(out, 0, ct, 0, ptlen) bytes.copy_from_bytes(out, ptlen, tag, 0, taglen) - bytes.free(ct); bytes.free(tag) + bytes.wipe_free(ct); bytes.wipe_free(tag) return out, ptlen + taglen } @@ -934,14 +958,14 @@ ccm_open(key: byte[], nonce: byte[], aad: byte[], ct: byte[], taglen: int) -> { } j = j + 1 } - bytes.free(ks) + bytes.wipe_free(ks) off = off + 16 } - bytes.free(ctr) + bytes.wipe_free(ctr) mac = ccm_cbc_mac(enc, nonce, aad, bytes.view(pt)[0..ptlen], taglen) s0 = process_block(enc, ctr0) - bytes.free(ctr0) + bytes.wipe_free(ctr0) // expected tag = (mac XOR S0) truncated; compare const-time to supplied. diff = 0 i = 0 @@ -950,10 +974,10 @@ ccm_open(key: byte[], nonce: byte[], aad: byte[], ct: byte[], taglen: int) -> { diff = diff | (want ^ (ct[ptlen + i] & 0xFF)) i = i + 1 } - bytes.free(mac); bytes.free(s0) + bytes.wipe_free(mac); bytes.wipe_free(s0) free(enc) - if diff != 0 { - bytes.free(pt) + if ctime.declassify(ctime.eq(diff, 0)) == 0 { + bytes.wipe_free(pt) return null, -1 } return pt, ptlen @@ -982,7 +1006,7 @@ eax_omac(key: byte[], t: int, data: byte[]) -> ptr { i = 0 while i < dlen { bytes.set(buf, 16 + i, data[i]); i = i + 1 } tag = cmac(key, bytes.view(buf)[0..16 + dlen]) - bytes.free(buf) + bytes.wipe_free(buf) return tag } @@ -1007,7 +1031,7 @@ eax_seal(key: byte[], nonce: byte[], aad: byte[], pt: byte[], taglen: int) -> { bytes.set(out, ptlen + i, v) i = i + 1 } - bytes.free(n_mac); bytes.free(h_mac); bytes.free(c_mac); bytes.free(ct) + bytes.wipe_free(n_mac); bytes.wipe_free(h_mac); bytes.wipe_free(c_mac); bytes.wipe_free(ct) return out, ptlen + taglen } @@ -1032,16 +1056,16 @@ eax_open(key: byte[], nonce: byte[], aad: byte[], ct: byte[], taglen: int) -> { diff = diff | (want ^ (ct[ptlen + i] & 0xFF)) i = i + 1 } - bytes.free(h_mac); bytes.free(c_mac) - if diff != 0 { - bytes.free(n_mac); bytes.free(body) + bytes.wipe_free(h_mac); bytes.wipe_free(c_mac) + if ctime.declassify(ctime.eq(diff, 0)) == 0 { + bytes.wipe_free(n_mac); bytes.wipe_free(body) return null, -1 } enc = new_encryptor(key) pt = ctr_xor(enc, n_mac, body) free(enc) - bytes.free(n_mac); bytes.free(body) + bytes.wipe_free(n_mac); bytes.wipe_free(body) return pt, ptlen } @@ -1107,7 +1131,7 @@ ocb_build_l(l0: ptr) -> ptr { while n < entries { d = ocb_double_at(tab, (n - 1) * 16) bytes.copy_from_bytes(tab, n * 16, d, 0, 16) - bytes.free(d) + bytes.wipe_free(d) n = n + 1 } return tab @@ -1154,7 +1178,7 @@ ocb_init_offset(enc: ptr, nonce: byte[], taglen: int) -> ptr { bytes.set(nb, 15, bytes.get(nb, 15) & 0xC0) ktop = process_block(enc, nb) - bytes.free(nb) + bytes.wipe_free(nb) // Stretch = Ktop ‖ (Ktop[0..7] XOR Ktop[1..8]) (24 bytes) stretch = bytes.new(24) @@ -1165,7 +1189,7 @@ ocb_init_offset(enc: ptr, nonce: byte[], taglen: int) -> ptr { bytes.set(stretch, 16 + i, (bytes.get(ktop, i) ^ bytes.get(ktop, i + 1)) & 0xFF) i = i + 1 } - bytes.free(ktop) + bytes.wipe_free(ktop) // OffsetMAIN_0 = (Stretch << bottom)[0..15] (bit shift) bits_sh = bottom % 8 @@ -1184,7 +1208,7 @@ ocb_init_offset(enc: ptr, nonce: byte[], taglen: int) -> ptr { i = i + 1 } } - bytes.free(stretch) + bytes.wipe_free(stretch) return off } @@ -1207,7 +1231,7 @@ ocb_hash_aad(enc: ptr, ltab: ptr, l_star: ptr, aad: byte[]) -> ptr { while j < 16 { bytes.set(blk, j, (aad[off + j] ^ bytes.get(offh, j)) & 0xFF); j = j + 1 } eb = process_block(enc, blk) ocb_xor(sum, eb) - bytes.free(blk); bytes.free(eb) + bytes.wipe_free(blk); bytes.wipe_free(eb) blkidx = blkidx + 1 off = off + 16 } @@ -1226,9 +1250,9 @@ ocb_hash_aad(enc: ptr, ltab: ptr, l_star: ptr, aad: byte[]) -> ptr { } eb = process_block(enc, blk) ocb_xor(sum, eb) - bytes.free(blk); bytes.free(eb) + bytes.wipe_free(blk); bytes.wipe_free(eb) } - bytes.free(offh) + bytes.wipe_free(offh) return sum } @@ -1240,11 +1264,11 @@ ocb_seal(key: byte[], nonce: byte[], aad: byte[], pt: byte[], taglen: int) -> { z = 0 while z < 16 { bytes.set(zero, z, 0); z = z + 1 } l_star = process_block(enc, zero) - bytes.free(zero) + bytes.wipe_free(zero) l_dollar = ocb_double(l_star) l0 = ocb_double(l_dollar) ltab = ocb_build_l(l0) - bytes.free(l0) + bytes.wipe_free(l0) sum = ocb_hash_aad(enc, ltab, l_star, aad) offm = ocb_init_offset(enc, nonce, taglen) @@ -1267,7 +1291,7 @@ ocb_seal(key: byte[], nonce: byte[], aad: byte[], pt: byte[], taglen: int) -> { ocb_xor(eb, offm) j = 0 while j < 16 { bytes.set(ct, off + j, bytes.get(eb, j)); j = j + 1 } - bytes.free(blk); bytes.free(eb) + bytes.wipe_free(blk); bytes.wipe_free(eb) blkidx = blkidx + 1 off = off + 16 } @@ -1284,12 +1308,12 @@ ocb_seal(key: byte[], nonce: byte[], aad: byte[], pt: byte[], taglen: int) -> { j = j + 1 } ocb_xor(checksum, pad_in) - bytes.free(pad_in) + bytes.wipe_free(pad_in) ocb_xor(offm, l_star) pad = process_block(enc, offm) j = 0 while j < rem { bytes.set(ct, off + j, (pt[off + j] ^ bytes.get(pad, j)) & 0xFF); j = j + 1 } - bytes.free(pad) + bytes.wipe_free(pad) } // tag = E(Checksum XOR OffsetMAIN XOR L_$) XOR Sum @@ -1303,8 +1327,8 @@ ocb_seal(key: byte[], nonce: byte[], aad: byte[], pt: byte[], taglen: int) -> { i = 0 while i < taglen { bytes.set(out, ptlen + i, bytes.get(tagfull, i)); i = i + 1 } - bytes.free(ct); bytes.free(tagfull); bytes.free(checksum); bytes.free(sum) - bytes.free(offm); bytes.free(l_star); bytes.free(l_dollar); bytes.free(ltab) + bytes.wipe_free(ct); bytes.wipe_free(tagfull); bytes.wipe_free(checksum); bytes.wipe_free(sum) + bytes.wipe_free(offm); bytes.wipe_free(l_star); bytes.wipe_free(l_dollar); bytes.wipe_free(ltab) free(enc) return out, ptlen + taglen } @@ -1321,11 +1345,11 @@ ocb_open(key: byte[], nonce: byte[], aad: byte[], ct: byte[], taglen: int) -> { z = 0 while z < 16 { bytes.set(zero, z, 0); z = z + 1 } l_star = process_block(enc, zero) - bytes.free(zero) + bytes.wipe_free(zero) l_dollar = ocb_double(l_star) l0 = ocb_double(l_dollar) ltab = ocb_build_l(l0) - bytes.free(l0) + bytes.wipe_free(l0) sum = ocb_hash_aad(enc, ltab, l_star, aad) offm = ocb_init_offset(enc, nonce, taglen) @@ -1348,7 +1372,7 @@ ocb_open(key: byte[], nonce: byte[], aad: byte[], ct: byte[], taglen: int) -> { ocb_xor(checksum, db) j = 0 while j < 16 { bytes.set(pt, off + j, bytes.get(db, j)); j = j + 1 } - bytes.free(blk); bytes.free(db) + bytes.wipe_free(blk); bytes.wipe_free(db) blkidx = blkidx + 1 off = off + 16 } @@ -1369,7 +1393,7 @@ ocb_open(key: byte[], nonce: byte[], aad: byte[], ct: byte[], taglen: int) -> { ocb_xor(checksum, recovered) j = 0 while j < rem { bytes.set(pt, off + j, bytes.get(recovered, j)); j = j + 1 } - bytes.free(pad); bytes.free(recovered) + bytes.wipe_free(pad); bytes.wipe_free(recovered) } ocb_xor(checksum, offm) @@ -1383,11 +1407,11 @@ ocb_open(key: byte[], nonce: byte[], aad: byte[], ct: byte[], taglen: int) -> { diff = diff | ((bytes.get(tagfull, i) ^ ct[ptlen + i]) & 0xFF) i = i + 1 } - bytes.free(tagfull); bytes.free(checksum); bytes.free(sum) - bytes.free(offm); bytes.free(l_star); bytes.free(l_dollar); bytes.free(ltab) + bytes.wipe_free(tagfull); bytes.wipe_free(checksum); bytes.wipe_free(sum) + bytes.wipe_free(offm); bytes.wipe_free(l_star); bytes.wipe_free(l_dollar); bytes.wipe_free(ltab) free(enc); free(dec) - if diff != 0 { - bytes.free(pt) + if ctime.declassify(ctime.eq(diff, 0)) == 0 { + bytes.wipe_free(pt) return null, -1 } return pt, ptlen diff --git a/std/cryptography/argon2/module.ae b/std/cryptography/argon2/module.ae index 8872fa9ce..533b6dced 100644 --- a/std/cryptography/argon2/module.ae +++ b/std/cryptography/argon2/module.ae @@ -34,10 +34,11 @@ // uses std.bits.lsr64 / urem64 for the unsigned semantics RFC 9106 // requires. // * The memory matrix is several MiB; it (and all scratch blocks) are -// freed before return (the macOS leak gate is strict). +// wiped and freed before return (#3414; the macOS leak gate is strict). import std.bits import std.bytes +import std.mem import std.cryptography.blake2 import std.string @@ -90,14 +91,14 @@ hprime(input: byte[], out_len: int, out: ptr, out_off: int) { if out_len <= 64 { h = blake2.blake2b_bytes_n(string.bytes(seed_as_string(bytes.view(seed)[0..seed_len]))[0..seed_len], out_len) bytes.copy_from_bytes(out, out_off, h, 0, out_len) - bytes.free(h) - bytes.free(seed) + bytes.wipe_free(h) + bytes.wipe_free(seed) return } // out_len > 64: chained 32-byte halves. v = blake2.blake2b_bytes_n(string.bytes(seed_as_string(bytes.view(seed)[0..seed_len]))[0..seed_len], 64) - bytes.free(seed) + bytes.wipe_free(seed) pos = out_off bytes.copy_from_bytes(out, pos, v, 0, 32) pos = pos + 32 @@ -107,7 +108,7 @@ hprime(input: byte[], out_len: int, out: ptr, out_off: int) { i = 2 while i <= r { nv = blake2.blake2b_bytes_n(string.bytes(bytes_as_string(bytes.view(v)[0..64]))[0..64], 64) - bytes.free(v) + bytes.wipe_free(v) v = nv bytes.copy_from_bytes(out, pos, v, 0, 32) pos = pos + 32 @@ -116,9 +117,9 @@ hprime(input: byte[], out_len: int, out: ptr, out_off: int) { last_len = out_len - 32 * r last = blake2.blake2b_bytes_n(string.bytes(bytes_as_string(bytes.view(v)[0..64]))[0..64], last_len) - bytes.free(v) + bytes.wipe_free(v) bytes.copy_from_bytes(out, pos, last, 0, last_len) - bytes.free(last) + bytes.wipe_free(last) } // blake2 one-shots take a `string` (binary-safe). Convert a bytes buffer @@ -312,7 +313,7 @@ argon2(atype: int, pass: byte[], salt: byte[], secret: byte[], ad: byte[], t_cos if mem_raw == null { return null } - mem = (mem_raw as long[])[0..lanes] + matrix = (mem_raw as long[])[0..lanes] rblk = Block {} zblk = Block {} addr_blk = Block {} @@ -328,7 +329,7 @@ argon2(atype: int, pass: byte[], salt: byte[], secret: byte[], ad: byte[], t_cos put_le32(pre, 16, VERSION) put_le32(pre, 20, atype) blake2.update(ctx, bytes.view(pre)[0..24]) - bytes.free(pre) + bytes.wipe_free(pre) add_byte_string(ctx, pass) add_byte_string(ctx, salt) @@ -350,18 +351,18 @@ argon2(atype: int, pass: byte[], salt: byte[], secret: byte[], ad: byte[], t_cos // block 0 put_le32(seed, 64, 0) hprime(bytes.view(seed)[0..72], 1024, blk_bytes, 0) - load_block_from_bytes(mem, lane * lane_length + 0, blk_bytes) + load_block_from_bytes(matrix, lane * lane_length + 0, blk_bytes) // block 1 put_le32(seed, 64, 1) hprime(bytes.view(seed)[0..72], 1024, blk_bytes, 0) - load_block_from_bytes(mem, lane * lane_length + 1, blk_bytes) + load_block_from_bytes(matrix, lane * lane_length + 1, blk_bytes) lane = lane + 1 } - bytes.free(seed) - bytes.free(h0) + bytes.wipe_free(seed) + bytes.wipe_free(h0) // ---- Fill memory blocks ---- - fill_memory(mem, atype, t_cost, parallelism, memory_blocks, segment_length, lane_length, &rblk, &zblk, &addr_blk, &input_blk) + fill_memory(matrix, atype, t_cost, parallelism, memory_blocks, segment_length, lane_length, &rblk, &zblk, &addr_blk, &input_blk) // ---- Digest: XOR last block of every lane, then H'(out_len, finalBlock) ---- final_idx = lane_length - 1 @@ -372,18 +373,20 @@ argon2(atype: int, pass: byte[], salt: byte[], secret: byte[], ad: byte[], t_cos while k < QWORDS { fbase = final_idx * QWORDS lbase = last_in_lane * QWORDS - mem[fbase + k] = mem[fbase + k] ^ mem[lbase + k] + matrix[fbase + k] = matrix[fbase + k] ^ matrix[lbase + k] k = k + 1 } i = i + 1 } - store_block_to_bytes(mem, final_idx, blk_bytes) + store_block_to_bytes(matrix, final_idx, blk_bytes) out = bytes.new(out_len) hprime(bytes.view(blk_bytes)[0..1024], out_len, out, 0) - bytes.free(blk_bytes) - free(mem) + bytes.wipe_free(blk_bytes) + // The matrix is the password's working memory: wiped first (#3414). + mem.wipe(mem_raw, lanes * 8) + free(matrix) return out } @@ -406,7 +409,7 @@ add_byte_string(ctx: ptr, octets: byte[]) { lp = bytes.new(4) put_le32(lp, 0, use_n) blake2.update(ctx, bytes.view(lp)[0..4]) - bytes.free(lp) + bytes.wipe_free(lp) if use_n > 0 { blake2.update(ctx, octets[0..use_n]) } @@ -614,7 +617,7 @@ to_hex(out: ptr, out_len: int) -> string { bytes.set(hexbuf, i * 2 + 1, c_lo) i = i + 1 } - bytes.free(out) + bytes.wipe_free(out) return bytes.finish(hexbuf, out_len * 2) } diff --git a/std/cryptography/ascon/module.ae b/std/cryptography/ascon/module.ae index 4dc48a2f9..e342aede8 100644 --- a/std/cryptography/ascon/module.ae +++ b/std/cryptography/ascon/module.ae @@ -10,6 +10,7 @@ import std.bits import std.bytes +import std.mem extern malloc(size: int) -> ptr extern free(p: ptr) @@ -87,6 +88,7 @@ new(algo: string) -> { ctx.v4 = 0xa13c42a223be8d87 ctx.rounds = 8 } else { + mem.wipe(ctx, sizeof(AsconCtx)) free(ctx) return null, "unknown algorithm" } @@ -149,7 +151,7 @@ finalize_to_bytes(c: *AsconCtx) -> ptr { free_internals(c: *AsconCtx) { if c.block != null { - bytes.free(c.block) + bytes.wipe_free(c.block) c.block = null } } @@ -159,6 +161,7 @@ final_bytes(ctx: ptr) -> ptr { out = finalize_to_bytes(c) s = bytes.finish(out, 32) free_internals(c) + mem.wipe(ctx, sizeof(AsconCtx)) free(ctx) res = bytes.new(32) bytes.copy_from_string(res, 0, s, 32) @@ -186,9 +189,10 @@ final_hex(ctx: ptr) -> string { bytes.set(hexbuf, i * 2 + 1, c_lo) i = i + 1 } - bytes.free(out) + bytes.wipe_free(out) s = bytes.finish(hexbuf, 64) free_internals(c) + mem.wipe(ctx, sizeof(AsconCtx)) free(ctx) return s } @@ -199,6 +203,7 @@ free_ctx(ctx: ptr) { } c = ctx as *AsconCtx free_internals(c) + mem.wipe(ctx, sizeof(AsconCtx)) free(ctx) } diff --git a/std/cryptography/ascon256/module.ae b/std/cryptography/ascon256/module.ae index 9888a6b97..faf428166 100644 --- a/std/cryptography/ascon256/module.ae +++ b/std/cryptography/ascon256/module.ae @@ -9,6 +9,7 @@ import std.bits import std.bytes +import std.mem extern malloc(size: int) -> ptr extern free(p: ptr) @@ -127,7 +128,7 @@ finalize_to_bytes(c: *Ascon256Ctx) -> ptr { free_internals(c: *Ascon256Ctx) { if c.block != null { - bytes.free(c.block) + bytes.wipe_free(c.block) c.block = null } } @@ -137,6 +138,7 @@ final_bytes(ctx: ptr) -> ptr { out = finalize_to_bytes(c) s = bytes.finish(out, 32) free_internals(c) + mem.wipe(ctx, sizeof(Ascon256Ctx)) free(ctx) res = bytes.new(32) bytes.copy_from_string(res, 0, s, 32) @@ -164,9 +166,10 @@ final_hex(ctx: ptr) -> string { bytes.set(hexbuf, i * 2 + 1, c_lo) i = i + 1 } - bytes.free(out) + bytes.wipe_free(out) s = bytes.finish(hexbuf, 64) free_internals(c) + mem.wipe(ctx, sizeof(Ascon256Ctx)) free(ctx) return s } @@ -177,6 +180,7 @@ free_ctx(ctx: ptr) { } c = ctx as *Ascon256Ctx free_internals(c) + mem.wipe(ctx, sizeof(Ascon256Ctx)) free(ctx) } diff --git a/std/cryptography/ascon_aead/module.ae b/std/cryptography/ascon_aead/module.ae index 85a7cac9e..f178b8c1c 100644 --- a/std/cryptography/ascon_aead/module.ae +++ b/std/cryptography/ascon_aead/module.ae @@ -357,7 +357,7 @@ aead_open(algo: string, key: ptr, nonce: ptr, aad: byte[], ct_with_tag: byte[]) r3 = st.s3 ^ ld64_slice(ct_with_tag, pt_len) r4 = st.s4 ^ ld64_slice(ct_with_tag, pt_len + 8) if (r3 | r4) != 0 { - bytes.free(pt) + bytes.wipe_free(pt) return null, "tag verification failed" } return pt, ok diff --git a/std/cryptography/ascon_aead128/module.ae b/std/cryptography/ascon_aead128/module.ae index 9dbfa6890..5c18aa9f8 100644 --- a/std/cryptography/ascon_aead128/module.ae +++ b/std/cryptography/ascon_aead128/module.ae @@ -327,7 +327,7 @@ aead_open(key: ptr, nonce: ptr, aad: byte[], ct_with_tag: byte[]) -> ptr! { r3 = st.s3 ^ ld64_slice(ct_with_tag, pt_len) r4 = st.s4 ^ ld64_slice(ct_with_tag, pt_len + 8) if (r3 | r4) != 0 { - bytes.free(pt) + bytes.wipe_free(pt) return null, "tag verification failed" } return pt, ok diff --git a/std/cryptography/ascon_xof128/module.ae b/std/cryptography/ascon_xof128/module.ae index 2eb46bbdc..2f44f20ef 100644 --- a/std/cryptography/ascon_xof128/module.ae +++ b/std/cryptography/ascon_xof128/module.ae @@ -9,6 +9,7 @@ import std.bits import std.bytes +import std.mem extern malloc(size: int) -> ptr extern free(p: ptr) @@ -162,7 +163,7 @@ squeeze_bytes(c: *AsconXofCtx, out_len: int) -> ptr { free_internals(c: *AsconXofCtx) { if c.block != null { - bytes.free(c.block) + bytes.wipe_free(c.block) c.block = null } } @@ -176,6 +177,7 @@ final_bytes(ctx: ptr, out_len: int) -> ptr { out = squeeze_bytes(c, n) s = bytes.finish(out, n) free_internals(c) + mem.wipe(ctx, sizeof(AsconXofCtx)) free(ctx) res = bytes.new(n) bytes.copy_from_string(res, 0, s, n) @@ -207,9 +209,10 @@ final_hex(ctx: ptr, out_len: int) -> string { bytes.set(hexbuf, i * 2 + 1, c_lo) i = i + 1 } - bytes.free(out) + bytes.wipe_free(out) s = bytes.finish(hexbuf, n * 2) free_internals(c) + mem.wipe(ctx, sizeof(AsconXofCtx)) free(ctx) return s } @@ -220,6 +223,7 @@ free_ctx(ctx: ptr) { } c = ctx as *AsconXofCtx free_internals(c) + mem.wipe(ctx, sizeof(AsconXofCtx)) free(ctx) } diff --git a/std/cryptography/asn1/module.ae b/std/cryptography/asn1/module.ae index cc038c4a5..8e6134fb1 100644 --- a/std/cryptography/asn1/module.ae +++ b/std/cryptography/asn1/module.ae @@ -233,7 +233,8 @@ read_tlv(r: ptr) -> (ptr, string) { } if is_indefinite == 1 { - // Read TLVs recursively until we find EOC (00 00) + // Read TLVs recursively until we find EOC (00 00); each copy is + // wiped as it is replaced, the TLVs may hold a key (#3382). acc = bytes.new(0) acc_len = 0 loop = 1 @@ -248,19 +249,19 @@ read_tlv(r: ptr) -> (ptr, string) { } if loop == 1 { if rr.pos >= rr.len { - bytes.free(acc) + bytes.wipe_free(acc) return null, "asn1: unexpected end of input in indefinite length" } start_pos = rr.pos sub_content, suberr = read_tlv(r) if string.equals(suberr, "") != 1 { - bytes.free(acc) + bytes.wipe_free(acc) return null, suberr } // suberr is the sub-call's (empty) success string; release it // so the recursive read_tlv's "" doesn't leak per sub-TLV. string.release(suberr) - bytes.free(sub_content) + bytes.wipe_free(sub_content) end_pos = rr.pos sub_tlv_len = end_pos - start_pos @@ -270,7 +271,7 @@ read_tlv(r: ptr) -> (ptr, string) { bytes.copy_from_bytes(new_acc, 0, acc, 0, acc_len) } bytes.copy_from_bytes(new_acc, acc_len, rr.buf, start_pos, sub_tlv_len) - bytes.free(acc) + bytes.wipe_free(acc) acc = new_acc acc_len = acc_len + sub_tlv_len } @@ -318,7 +319,7 @@ read_tagged(r: ptr, want_tag: int) -> (ptr, string) { } rr = r as *Asn1Reader if rr.last_tag != want_tag { - bytes.free(content) + bytes.wipe_free(content) return null, "asn1: unexpected tag" } return content, err @@ -345,7 +346,8 @@ read_integer(r: ptr) -> (ptr, string) { } clen = bytes.length(content) n = bignum.from_bytes(content, clen) - bytes.free(content) + // The content may be a private key's number (an RSA d, #3382). + bytes.wipe_free(content) return n, err } @@ -363,7 +365,7 @@ read_bit_string(r: ptr) -> (ptr, int, string) { } clen = bytes.length(content) if clen < 1 { - bytes.free(content) + bytes.wipe_free(content) return null, 0, "asn1: empty BIT STRING" } unused = bytes.get(content, 0) & 0xFF @@ -373,7 +375,7 @@ read_bit_string(r: ptr) -> (ptr, int, string) { bytes.set(out, i - 1, bytes.get(content, i)) i = i + 1 } - bytes.free(content) + bytes.wipe_free(content) return out, unused, err } @@ -383,7 +385,7 @@ read_null(r: ptr) -> string { return err } n = bytes.length(content) - bytes.free(content) + bytes.wipe_free(content) if n != 0 { return "asn1: NULL with non-empty content" } @@ -396,11 +398,11 @@ read_boolean(r: ptr) -> (int, string) { return 0, err } if bytes.length(content) != 1 { - bytes.free(content) + bytes.wipe_free(content) return 0, "asn1: BOOLEAN length != 1" } v = bytes.get(content, 0) & 0xFF - bytes.free(content) + bytes.wipe_free(content) if v == 0 { return 0, err } return 1, err } @@ -413,7 +415,7 @@ read_oid(r: ptr) -> (string, string) { } n = bytes.length(content) if n < 1 { - bytes.free(content) + bytes.wipe_free(content) return "", "asn1: empty OID" } sb = strbuilder.new(n * 4) @@ -438,7 +440,7 @@ read_oid(r: ptr) -> (string, string) { } i = i + 1 } - bytes.free(content) + bytes.wipe_free(content) return strbuilder.finish(sb), err } @@ -529,9 +531,9 @@ wrap_tlv(tag: int, content: ptr) -> ptr { bytes.set(out, ntag + nlen + j, bytes.get(content, j)) j = j + 1 } - bytes.free(tag_bytes) - bytes.free(lenbytes) - bytes.free(content) + bytes.wipe_free(tag_bytes) + bytes.wipe_free(lenbytes) + bytes.wipe_free(content) return out } @@ -559,9 +561,9 @@ wrap_tlv_ext(tag_class: int, tag_pc: int, tag_number: int, content: ptr) -> ptr bytes.set(out, ntag + nlen + j, bytes.get(content, j)) j = j + 1 } - bytes.free(tag_bytes) - bytes.free(lenbytes) - bytes.free(content) + bytes.wipe_free(tag_bytes) + bytes.wipe_free(lenbytes) + bytes.wipe_free(content) return out } @@ -780,7 +782,7 @@ read_bmp_string(r: ptr) -> (string, string) { return "", err } s = decode_bmp_string_bytes(content) - bytes.free(content) + bytes.wipe_free(content) return s, err // reuse tracked (empty) err, not a fresh "" (avoids 1-byte leak) } @@ -795,7 +797,7 @@ read_universal_string(r: ptr) -> (string, string) { return "", err } s = decode_universal_string_bytes(content) - bytes.free(content) + bytes.wipe_free(content) return s, err // reuse tracked (empty) err, not a fresh "" (avoids 1-byte leak) } @@ -811,7 +813,7 @@ bignum_to_int(bn: ptr) -> int { bytes_buf = bignum.to_bytes(bn) n = bytes.length(bytes_buf) if n == 0 { - bytes.free(bytes_buf) + bytes.wipe_free(bytes_buf) return 0 } first = bytes.get(bytes_buf, 0) @@ -824,7 +826,7 @@ bignum_to_int(bn: ptr) -> int { v = (v << 8) | (bytes.get(bytes_buf, i) & 0xFF) i = i + 1 } - bytes.free(bytes_buf) + bytes.wipe_free(bytes_buf) return v } @@ -835,7 +837,7 @@ read_enumerated(r: ptr) -> (int, string) { } clen = bytes.length(content) bn = bignum.from_bytes(content, clen) - bytes.free(content) + bytes.wipe_free(content) v = bignum_to_int(bn) bignum.free(bn) return v, err @@ -912,7 +914,7 @@ encode_bmp_string_bytes(s: string) -> ptr { } final_out = bytes.new(out_idx) bytes.copy_from_bytes(final_out, 0, out, 0, out_idx) - bytes.free(out) + bytes.wipe_free(out) return final_out } @@ -981,7 +983,7 @@ encode_set(elements_list: ptr) -> ptr { while i < sz { el = list.at(elements_list, i) off = emit_bytes(body, off, el) - bytes.free(el) + bytes.wipe_free(el) i = i + 1 } @@ -1024,7 +1026,7 @@ encode_sequence_list(elements_list: ptr) -> ptr { while i < sz { el = list.at(elements_list, i) off = emit_bytes(body, off, el) - bytes.free(el) + bytes.wipe_free(el) i = i + 1 } list_free(elements_list) @@ -1089,8 +1091,8 @@ encode_tagged(is_explicit: int, tag_class: int, tag_number: int, content: @keep i = i + 1 } - bytes.free(new_tag_bytes) - bytes.free(content) + bytes.wipe_free(new_tag_bytes) + bytes.wipe_free(content) return out } @@ -1106,7 +1108,7 @@ read_tagged_ext(r: ptr, is_explicit: int, want_class: int, want_number: int) -> return null, err } if rr.last_tag_class != want_class || rr.last_tag_number != want_number { - bytes.free(content) + bytes.wipe_free(content) return null, "asn1: unexpected tag class or number" } return content, err @@ -1198,6 +1200,6 @@ encode_universal_string_bytes(s: string) -> ptr { } final_out = bytes.new(out_idx) bytes.copy_from_bytes(final_out, 0, out, 0, out_idx) - bytes.free(out) + bytes.wipe_free(out) return final_out } diff --git a/std/cryptography/blake2/module.ae b/std/cryptography/blake2/module.ae index cb5dd9b0e..1f8f4b343 100644 --- a/std/cryptography/blake2/module.ae +++ b/std/cryptography/blake2/module.ae @@ -37,6 +37,7 @@ import std.bits import std.bytes +import std.mem extern malloc(size: int) -> ptr extern free(p: ptr) @@ -446,13 +447,13 @@ finalize_to_bytes(c: *Blake2Ctx) -> ptr { bytes.set(out, i, bytes.get(full, i)) i = i + 1 } - bytes.free(full) + bytes.wipe_free(full) return out } free_internals(c: *Blake2Ctx) { if c.block != null { - bytes.free(c.block) + bytes.wipe_free(c.block) c.block = null } } @@ -465,6 +466,7 @@ final_bytes(ctx: ptr) -> ptr { out = finalize_to_bytes(c) s = bytes.finish(out, n) free_internals(c) + mem.wipe(ctx, sizeof(Blake2Ctx)) free(ctx) res = bytes.new(n) bytes.copy_from_string(res, 0, s, n) @@ -495,9 +497,10 @@ final_hex(ctx: ptr) -> string { bytes.set(hexbuf, i * 2 + 1, c_lo) i = i + 1 } - bytes.free(out) + bytes.wipe_free(out) s = bytes.finish(hexbuf, n * 2) free_internals(c) + mem.wipe(ctx, sizeof(Blake2Ctx)) free(ctx) return s } @@ -511,6 +514,7 @@ free_ctx(ctx: ptr) { } c = ctx as *Blake2Ctx free_internals(c) + mem.wipe(ctx, sizeof(Blake2Ctx)) free(ctx) } diff --git a/std/cryptography/blake3/module.ae b/std/cryptography/blake3/module.ae index 70caf754a..999094125 100644 --- a/std/cryptography/blake3/module.ae +++ b/std/cryptography/blake3/module.ae @@ -417,6 +417,7 @@ adjust_stack(c: *Blake3Ctx) { i = i + 1 } + mem.wipe(myLeft, 8 * 8) free(myLeft) init_parent_block(c) @@ -447,6 +448,7 @@ process_stack(c: *Blake3Ctx) { i = i + 1 } + mem.wipe(myLeft, 8 * 8) free(myLeft) init_parent_block(c) @@ -588,6 +590,7 @@ final_bytes(ctx: ptr) -> ptr { bytes.set(res, i, mem.get_byte(out_raw, i)) i = i + 1 } + mem.wipe(out_raw, n) free(out_raw) reset_ctx(c) @@ -619,6 +622,7 @@ final_hex(ctx: ptr) -> string { bytes.set(hexbuf, i * 2 + 1, c_lo) i = i + 1 } + mem.wipe(out_raw, n) free(out_raw) reset_ctx(c) @@ -628,26 +632,32 @@ final_hex(ctx: ptr) -> string { free_ctx_internals(c: *Blake3Ctx) { if c.theBuffer != null { + mem.wipe(c.theBuffer, BLOCKLEN) free(c.theBuffer) c.theBuffer = null } if c.theK != null { + mem.wipe(c.theK, NUMWORDS * 8) free(c.theK) c.theK = null } if c.theChaining != null { + mem.wipe(c.theChaining, NUMWORDS * 8) free(c.theChaining) c.theChaining = null } if c.theV != null { + mem.wipe(c.theV, 16 * 8) free(c.theV) c.theV = null } if c.theM != null { + mem.wipe(c.theM, 16 * 8) free(c.theM) c.theM = null } if c.theIndices != null { + mem.wipe(c.theIndices, 16 * 8) free(c.theIndices) c.theIndices = null } @@ -656,10 +666,12 @@ free_ctx_internals(c: *Blake3Ctx) { while i < c.stack_count { node = mem.get_ptr(c.stack, i * 8) if node != null { + mem.wipe(node, 8 * 8) free(node) } i = i + 1 } + mem.wipe(c.stack, 64 * 8) free(c.stack) c.stack = null } @@ -671,6 +683,7 @@ free_ctx(ctx: ptr) { } c = ctx as *Blake3Ctx free_ctx_internals(c) + mem.wipe(ctx, sizeof(Blake3Ctx)) free(ctx) } @@ -739,6 +752,7 @@ copy_state(dst_ptr: ptr, src_ptr: ptr) { while i < dst.stack_count { node = mem.get_ptr(dst.stack, i * 8) if node != null { + mem.wipe(node, 8 * 8) free(node) } i = i + 1 diff --git a/std/cryptography/chacha20poly1305/module.ae b/std/cryptography/chacha20poly1305/module.ae index d61123f80..084223a98 100644 --- a/std/cryptography/chacha20poly1305/module.ae +++ b/std/cryptography/chacha20poly1305/module.ae @@ -32,6 +32,7 @@ import std.bytes import std.bits import std.intarr +import std.cryptography.ct as ctime exports( chacha20_xor, poly1305_mac, aead_seal, aead_open @@ -147,7 +148,8 @@ chacha20_xor(key: ptr, nonce: ptr, counter: long, data: byte[]) -> ptr { off = off + 64 blk = blk + 1 } - bytes.free(ks) + // The keystream XORed with the ciphertext gives the plaintext (#3376). + bytes.wipe_free(ks) return out } @@ -311,7 +313,7 @@ poly_key_for(key: ptr, nonce: ptr) -> ptr { chacha_block(key, nonce, 0, blk) pk = bytes.new(32) bytes.copy_from_bytes(pk, 0, blk, 0, 32) - bytes.free(blk) + bytes.wipe_free(blk) return pk } @@ -360,14 +362,14 @@ aead_seal(key: ptr, nonce: ptr, aad: byte[], plaintext: byte[]) -> ptr { pk = poly_key_for(key, nonce) md = mac_data(aad, bytes.view(ct)[0..pt_len]) tag = poly1305_mac(pk, bytes.view(md)) - bytes.free(pk) - bytes.free(md) + bytes.wipe_free(pk) + bytes.wipe_free(md) out = bytes.new(pt_len + 16) bytes.copy_from_bytes(out, 0, ct, 0, pt_len) bytes.copy_from_bytes(out, pt_len, tag, 0, 16) - bytes.free(ct) - bytes.free(tag) + bytes.wipe_free(ct) + bytes.wipe_free(tag) return out } @@ -384,8 +386,8 @@ aead_open(key: ptr, nonce: ptr, aad: byte[], ct_with_tag: byte[]) -> { pk = poly_key_for(key, nonce) md = mac_data(aad, ct_with_tag[0..body_len]) want = poly1305_mac(pk, bytes.view(md)) - bytes.free(pk) - bytes.free(md) + bytes.wipe_free(pk) + bytes.wipe_free(md) // Constant-time compare against the supplied tag at offset body_len. diff = 0 @@ -394,8 +396,8 @@ aead_open(key: ptr, nonce: ptr, aad: byte[], ct_with_tag: byte[]) -> { diff = diff | ((bytes.get(want, i) ^ ct_with_tag[body_len + i]) & 0xFF) i = i + 1 } - bytes.free(want) - if diff != 0 { + bytes.wipe_free(want) + if ctime.declassify(ctime.eq(diff, 0)) == 0 { return null, "authentication failed" } diff --git a/std/cryptography/ct/module.ae b/std/cryptography/ct/module.ae new file mode 100644 index 000000000..09d227d1e --- /dev/null +++ b/std/cryptography/ct/module.ae @@ -0,0 +1,99 @@ +// Copyright (c) 2026 Aether Developers. +// +// std.cryptography.ct: constant-time building blocks shared by the +// std.cryptography modules that handle secrets, and the declassification +// point the constant-time test harness hooks. +// +// Import with: import std.cryptography.ct +// +// A function here never branches on, and never indexes memory with, a value +// derived from its arguments: comparisons and selections are computed with +// masks. They are written for 32-bit `int` operands that hold one byte or one +// small counter (0..2^31-1); the result of a comparison is 1 or 0. +// +// eq(a, b) -> 1 if a == b, else 0 +// lt(a, b) -> 1 if a < b (both non-negative), else 0 +// select(bit, a, b) -> a when bit is 1, b when bit is 0 +// bytes_eq(a, b, n) -> 1 if the first n bytes of a and b are equal +// declassify(v) -> v, marked public for the harness +// +// declassify is the one place a secret-derived value is allowed to decide a +// branch: a signature's retry decision, a key's validity, an AEAD tag check's +// verdict, an all-zero shared secret. Each of those values is public by the +// time it is used (the protocol reveals it), and the call names that fact. In +// a normal program it returns its argument. The constant-time test +// (tests/integration/crypto_ct_valgrind) marks secret inputs undefined for +// Valgrind's memcheck and installs a hook here that marks the declassified +// value defined again, so memcheck reports every OTHER branch or memory index +// that depends on a secret. + +import std.bytes + +exports( + DeclassifyHook, eq, lt, select, bytes_eq, bytes_eq_slice, + declassify, set_declassify_hook +) + +// fn(int) -> int, the hook's C signature. +type DeclassifyHook = fn(int) -> int + +var declassify_hook: DeclassifyHook = null + +// Install the harness's hook (a C function `int f(int)`), or null to +// remove it. Test-only: call it once, before any other thread runs crypto. +set_declassify_hook(h: DeclassifyHook) { + declassify_hook = h +} + +// Return v, marked public. See the module comment. +declassify(v: int) -> int { + if declassify_hook != null { + return declassify_hook(v) + } + return v +} + +// 1 if a == b, else 0, without a branch. +eq(a: int, b: int) -> int { + uint32 x = (a ^ b) as uint32 + return ((((x | (0 - x)) >> 31) ^ 1) & 1) as int +} + +// 1 if a < b, else 0, for non-negative a and b, without a branch. +lt(a: int, b: int) -> int { + long d = (a as long) - (b as long) + return ((d >> 63) & 1) as int +} + +// a when bit is 1, b when bit is 0 (bit must be 0 or 1), without a branch. +select(bit: int, a: int, b: int) -> int { + int mask = 0 - bit + return b ^ (mask & (a ^ b)) +} + +// 1 if the first n bytes of the std.bytes buffers a and b are equal, else 0. +// Reads all n bytes whatever they hold; n itself is public. +bytes_eq(a: ptr, b: ptr, n: int) -> int { + int acc = 0 + int i = 0 + while i < n { + acc = acc | ((bytes.get(a, i) ^ bytes.get(b, i)) & 255) + i = i + 1 + } + return eq(acc, 0) +} + +// bytes_eq over two byte slices of the same length (0 if the lengths differ; +// the lengths are public). +bytes_eq_slice(a: byte[], b: byte[]) -> int { + if a.len != b.len { + return 0 + } + int acc = 0 + int i = 0 + while i < a.len { + acc = acc | ((a[i] ^ b[i]) & 255) + i = i + 1 + } + return eq(acc, 0) +} diff --git a/std/cryptography/ct/test_ct.ae b/std/cryptography/ct/test_ct.ae new file mode 100644 index 000000000..954bb4d8e --- /dev/null +++ b/std/cryptography/ct/test_ct.ae @@ -0,0 +1,47 @@ +// std.cryptography.ct: the masked comparisons and selection, and declassify +// (the identity without a hook). + +import std.bytes +import std.cryptography.ct + +extern exit(code: int) + +fn ck(cond: bool, msg: string) -> int { + if cond { + println("ok ${msg}") + return 0 + } + println("FAIL ${msg}") + return 1 +} + +main() { + fails = 0 + fails = fails + ck(ct.eq(7, 7) == 1 && ct.eq(7, 8) == 0 && ct.eq(0, 0) == 1, "eq") + fails = fails + ck(ct.eq(-1, 2147483647) == 0 && ct.eq(-5, -5) == 1, "eq on negative values") + fails = fails + ck(ct.lt(3, 4) == 1 && ct.lt(4, 3) == 0 && ct.lt(5, 5) == 0, "lt") + fails = fails + ck(ct.lt(0, 2147483647) == 1 && ct.lt(2147483647, 0) == 0, "lt at the ends of the range") + fails = fails + ck(ct.select(1, 10, 20) == 10 && ct.select(0, 10, 20) == 20, "select") + a = bytes.new(4) + b = bytes.new(4) + i = 0 + while i < 4 { + bytes.set(a, i, i * 50) + bytes.set(b, i, i * 50) + i = i + 1 + } + fails = fails + ck(ct.bytes_eq(a, b, 4) == 1, "bytes_eq equal") + fails = fails + ck(ct.bytes_eq_slice(bytes.view(a), bytes.view(b)) == 1, "bytes_eq_slice equal") + bytes.set(b, 3, 151) + fails = fails + ck(ct.bytes_eq(a, b, 4) == 0 && ct.bytes_eq(a, b, 3) == 1, "bytes_eq differs in the last byte only") + fails = fails + ck(ct.bytes_eq_slice(bytes.view(a), bytes.view(b)) == 0, "bytes_eq_slice differs") + fails = fails + ck(ct.bytes_eq_slice(bytes.view(a)[0..3], bytes.view(b)) == 0, "bytes_eq_slice of other lengths") + fails = fails + ck(ct.declassify(42) == 42, "declassify is the identity without a hook") + bytes.free(a) + bytes.free(b) + if fails > 0 { + println("${fails} FAILED") + exit(1) + } + println("ALL PASS") +} diff --git a/std/cryptography/des3/module.ae b/std/cryptography/des3/module.ae index 8e219697b..c8c54f5a1 100644 --- a/std/cryptography/des3/module.ae +++ b/std/cryptography/des3/module.ae @@ -48,6 +48,7 @@ // pre/post rotations use std.bits.rotl32. import std.bytes +import std.mem import std.bits exports( @@ -455,6 +456,7 @@ new_decryptor(key: byte[]) -> ptr { } free(ctx: @frees ptr) { if ctx == null { return } + mem.wipe(ctx, sizeof(Des3Ctx)) libc_free(ctx) } @@ -487,7 +489,7 @@ ecb_run(ctx: ptr, data: ptr) -> ptr { eb = process_block(ctx, blk) k = 0 while k < 8 { bytes.set(out, off + k, bytes.get(eb, k)); k = k + 1 } - bytes.free(blk); bytes.free(eb) + bytes.wipe_free(blk); bytes.wipe_free(eb) off = off + 8 } return out @@ -512,11 +514,11 @@ ctr_xor(ctx: ptr, iv: ptr, data: ptr) -> ptr { } j = j + 1 } - bytes.free(ks) + bytes.wipe_free(ks) ctr_increment(ctr) off = off + 8 } - bytes.free(ctr) + bytes.wipe_free(ctr) return out } ctr_increment(ctr: ptr) { @@ -561,10 +563,10 @@ cbc_encrypt(ctx: ptr, iv: ptr, data: ptr) -> ptr { bytes.set(prev, k, v) k = k + 1 } - bytes.free(blk); bytes.free(eb) + bytes.wipe_free(blk); bytes.wipe_free(eb) off = off + 8 } - bytes.free(prev) + bytes.wipe_free(prev) return out } @@ -589,10 +591,10 @@ cbc_decrypt(ctx: ptr, iv: ptr, data: ptr) -> ptr { bytes.set(prev, k, bytes.get(blk, k)) k = k + 1 } - bytes.free(blk); bytes.free(db) + bytes.wipe_free(blk); bytes.wipe_free(db) off = off + 8 } - bytes.free(prev) + bytes.wipe_free(prev) return out } @@ -645,7 +647,7 @@ pkcs7_unpad(data: ptr) -> { cbc_encrypt_pkcs7(ctx: ptr, iv: ptr, data: ptr) -> ptr { padded = pkcs7_pad(data) out = cbc_encrypt(ctx, iv, padded) - bytes.free(padded) + bytes.wipe_free(padded) return out } @@ -659,6 +661,6 @@ cbc_decrypt_pkcs7(ctx: ptr, iv: ptr, data: ptr) -> { } raw = cbc_decrypt(ctx, iv, data) out, err = pkcs7_unpad(raw) - bytes.free(raw) + bytes.wipe_free(raw) return out, err } diff --git a/std/cryptography/drbg/module.ae b/std/cryptography/drbg/module.ae index 1857c77a3..dffd3fae3 100644 --- a/std/cryptography/drbg/module.ae +++ b/std/cryptography/drbg/module.ae @@ -32,6 +32,7 @@ // reseed_counter++. import std.bytes +import std.mem import std.cryptography.hmac extern malloc(size: int) -> ptr @@ -88,13 +89,13 @@ update(ctx: *DrbgCtx, provided_data: byte[]) { j = j + 1 } new_k = hmac.bytes(ctx.algo, bytes.view(ctx.k)[0..ms], bytes.view(buf)[0..ms + 1 + pd_len]) - bytes.free(buf) - bytes.free(ctx.k) + bytes.wipe_free(buf) + bytes.wipe_free(ctx.k) ctx.k = new_k // V = HMAC(K, V) new_v = hmac.bytes(ctx.algo, bytes.view(ctx.k)[0..ms], bytes.view(ctx.v)[0..ms]) - bytes.free(ctx.v) + bytes.wipe_free(ctx.v) ctx.v = new_v if pd_len <= 0 { @@ -115,13 +116,13 @@ update(ctx: *DrbgCtx, provided_data: byte[]) { j = j + 1 } new_k2 = hmac.bytes(ctx.algo, bytes.view(ctx.k)[0..ms], bytes.view(buf2)[0..ms + 1 + pd_len]) - bytes.free(buf2) - bytes.free(ctx.k) + bytes.wipe_free(buf2) + bytes.wipe_free(ctx.k) ctx.k = new_k2 // V = HMAC(K, V) new_v2 = hmac.bytes(ctx.algo, bytes.view(ctx.k)[0..ms], bytes.view(ctx.v)[0..ms]) - bytes.free(ctx.v) + bytes.wipe_free(ctx.v) ctx.v = new_v2 } @@ -188,7 +189,7 @@ new(algo: string, entropy: byte[], nonce: byte[], perso: byte[]) -> { // seed_material = entropy || nonce || perso seed = concat3(entropy, nonce, perso) update(ctx, bytes.view(seed)) - bytes.free(seed) + bytes.wipe_free(seed) ctx.reseed_counter = 1 return ctx, "" } @@ -198,7 +199,7 @@ reseed(ctx: ptr, entropy: byte[], addl: byte[]) { c = ctx as *DrbgCtx seed = concat3(entropy, addl, null) update(c, bytes.view(seed)) - bytes.free(seed) + bytes.wipe_free(seed) c.reseed_counter = 1 } @@ -216,7 +217,7 @@ generate_core(c: *DrbgCtx, num_bytes: int, addl: byte[]) -> ptr { while produced < num_bytes { // V = HMAC(K, V) new_v = hmac.bytes(c.algo, bytes.view(c.k)[0..ms], bytes.view(c.v)[0..ms]) - bytes.free(c.v) + bytes.wipe_free(c.v) c.v = new_v // append min(ms, remaining) bytes of V remaining = num_bytes - produced @@ -257,12 +258,13 @@ free_ctx(ctx: ptr) { } c = ctx as *DrbgCtx if c.k != null { - bytes.free(c.k) + bytes.wipe_free(c.k) c.k = null } if c.v != null { - bytes.free(c.v) + bytes.wipe_free(c.v) c.v = null } + mem.wipe(ctx, sizeof(DrbgCtx)) free(ctx) } diff --git a/std/cryptography/dstu7564/module.ae b/std/cryptography/dstu7564/module.ae index b434cc26d..b076d68d4 100644 --- a/std/cryptography/dstu7564/module.ae +++ b/std/cryptography/dstu7564/module.ae @@ -9,6 +9,7 @@ import std.bits import std.bytes +import std.mem extern malloc(size: int) -> ptr extern free(p: ptr) @@ -477,6 +478,7 @@ new(algo: string) -> { ctx.columns = 16 ctx.rounds = 14 } else { + mem.wipe(ctx, sizeof(DstuCtx)) free(ctx) return null, "unknown algorithm" } @@ -579,7 +581,7 @@ finalize_to_bytes(c_ctx: *DstuCtx) -> ptr { } free_internals(c: *DstuCtx) { - if c.buf != null { bytes.free(c.buf); c.buf = null } + if c.buf != null { bytes.wipe_free(c.buf); c.buf = null } } final_bytes(ctx: ptr) -> ptr { @@ -588,6 +590,7 @@ final_bytes(ctx: ptr) -> ptr { out = finalize_to_bytes(c) s = bytes.finish(out, n) free_internals(c) + mem.wipe(ctx, sizeof(DstuCtx)) free(ctx) res = bytes.new(n) bytes.copy_from_string(res, 0, s, n) @@ -616,9 +619,10 @@ final_hex(ctx: ptr) -> string { bytes.set(hexbuf, i * 2 + 1, c_lo) i = i + 1 } - bytes.free(out) + bytes.wipe_free(out) s = bytes.finish(hexbuf, n * 2) free_internals(c) + mem.wipe(ctx, sizeof(DstuCtx)) free(ctx) return s } @@ -629,6 +633,7 @@ free_ctx(ctx: ptr) { } c = ctx as *DstuCtx free_internals(c) + mem.wipe(ctx, sizeof(DstuCtx)) free(ctx) } diff --git a/std/cryptography/ecc/module.ae b/std/cryptography/ecc/module.ae new file mode 100644 index 000000000..ed4383967 --- /dev/null +++ b/std/cryptography/ecc/module.ae @@ -0,0 +1,1852 @@ +// Copyright (c) 2026 Aether Developers. +// +// std.cryptography.ecc: the constant-time elliptic-curve engine under +// std.cryptography.{p256, p384, p521, secp256k1, ed25519, ed448, x448}. +// +// Import with: import std.cryptography.ecc +// +// Most programs use the per-curve modules, which keep their own API and call +// in here. This module holds what they share: +// +// 1. Field arithmetic modulo an odd prime on a fixed number of 32-bit limbs +// (`Fe`, up to 576 bits), in Montgomery form, with no allocation. Every +// operation runs the same instructions and touches the same memory whatever +// the values: carries and conditional subtractions are masks, never +// branches; inversion is Fermat's a^(m-2) with the public exponent m-2. +// The limb count of a field is public (it is the modulus's). +// +// 2. Short-Weierstrass curves y^2 = x^3 + ax + b (a = -3 for the NIST +// curves, a = 0 for secp256k1) in homogeneous projective coordinates with +// the complete addition and doubling formulas of Renes, Costello and +// Batina (EUROCRYPT 2016, algorithms 4, 6, 7 and 9). The formulas have no +// exceptional case: the identity, P + P and P + (-P) all go through the +// same code, so no branch is needed on a point. +// +// 3. Scalar multiplication by a secret scalar: a fixed 4-bit window from the +// most significant end, every window doubles four times and adds one table +// entry, and the entry is selected by reading all 16 and keeping one with +// a mask. Verification (public inputs only) computes u1*G + u2*Q in one +// interleaved pass and may take shortcuts. +// +// 4. ECDSA with RFC 6979 deterministic nonces (HMAC-DRBG over SHA-2), or +// hedged ones (RFC 6979 3.6: fresh randomness mixed into the seed), and +// ECDH with full public-key validation (coordinates below p, point on the +// curve), for P-256, P-384, P-521 and secp256k1. +// +// 5. The Edwards curves of Ed25519 and Ed448 (a = -1 and a = 1, d not a +// square), with the complete projective formulas of Bernstein, Birkner, +// Joye, Lange and Peters: the same constant-time window multiplication, +// RFC 8032 point encoding and decoding, scalars mod L, and a one-pass +// [S]B - [k]A for verification. The EdDSA protocols stay in +// std.cryptography.ed25519 and ed448, and X448's ladder in x448, on this +// field arithmetic. +// +// The secret-dependent decisions that remain (a private key out of range, a +// rejected RFC 6979 candidate, an r or s of zero, an ECDH result at +// infinity) are public by the time they are taken and pass through +// ct.declassify, which the constant-time harness +// (tests/integration/crypto_ct_valgrind) checks. +// +// Per-curve API (curve is P256, P384, P521 or SECP256K1; buffers are +// std.bytes handles of the curve's byte length L: 32, 48, 66, 32): +// scalar_mult_base(curve, k) -> (x, y) k*G, (null, null) at infinity +// scalar_mult(curve, k, px, py) -> (x, y) k*P, null on a bad P +// ecdh(curve, priv, pubx, puby) -> x null on a bad key or point +// ecdsa_sign(curve, priv, hash) -> (r, s) (null, null) on a bad key +// ecdsa_sign_hedged(curve, priv, hash, extra) -> (r, s) the same, extra mixed in +// ecdsa_verify(curve, pubx, puby, hash, r, s) -> 1 valid, 0 not +// public_key_valid(curve, pubx, puby) -> 1 if (x, y) is a point of the curve +// private_key_valid(curve, priv) -> 1 if priv is in [1, n-1] +// +// Edwards API (curve is ED25519 or ED448; little-endian, 32 or 57 bytes): +// ed_scalar_mult_base(curve, k) -> encoded k*B (constant time) +// ed_reduce(curve, h) -> h mod L (constant time, any length) +// ed_sign_s(curve, r, k, a) -> (r + k a) mod L (constant time) +// ed_verify(curve, pub, r_enc, s, k) -> 1 if [s]B - [k]A encodes as r_enc + +import std.bytes +import std.cryptography.ct +import std.cryptography.hmac + +exports( + Fe, Field, NMAX, + P256, P384, P521, SECP256K1, + field_init, fe_zero, fe_copy, fe_one, fe_from_int, + fe_add, fe_sub, fe_neg, fe_mul, fe_sqr, fe_pow, fe_inv, + fe_cmov, fe_cswap, fe_is_zero, fe_eq, fe_lt, fe_cond_sub, + fe_to_mont, fe_from_mont, + fe_from_be, fe_to_be, fe_from_le, fe_to_le, fe_reduce_be, fe_reduce_le, + fe_from_hex, fe_shr, + scalar_mult_base, scalar_mult, ecdh, ecdsa_sign, ecdsa_sign_hedged, ecdsa_verify, + public_key_valid, private_key_valid, + ED25519, ED448, ed_scalar_mult_base, ed_reduce, ed_sign_s, ed_verify +) + +extern string_char_at_n(str: string, known_length: int, index: int) -> int +extern strlen(s: string) -> int + +// Limbs of an element: 18 x 32 = 576 bits, enough for P-521 (17 limbs). +const NMAX = 18 + +const P256 = 1 +const P384 = 2 +const P521 = 3 +const SECP256K1 = 4 + +// A field element or integer: little-endian 32-bit limbs. Only the first +// Field.n limbs are used by field operations; the rest stay zero. +struct Fe { + v: uint32[NMAX] +} + +// An odd modulus m and its Montgomery constants (R = 2^(32 n)). +struct Field { + n: int // limbs of m + bits: int // bit length of m + m: Fe + m0inv: uint32 // -m^-1 mod 2^32 + r2: Fe // R^2 mod m: to_mont(a) = a * R^2 / R + one: Fe // R mod m: 1 in Montgomery form + pm2: Fe // m - 2, the inversion exponent +} + +// ---- integers on limbs ---- + +fe_zero(a: *Fe) { + int i = 0 + while i < NMAX { + a.v[i] = 0 + i = i + 1 + } +} + +fe_copy(r: *Fe, a: *Fe) { + int i = 0 + while i < NMAX { + r.v[i] = a.v[i] + i = i + 1 + } +} + +fn hexv(c: int) -> int { + if c >= 48 && c <= 57 { return c - 48 } + if c >= 97 && c <= 102 { return c - 87 } + if c >= 65 && c <= 70 { return c - 55 } + return 0 +} + +// r = the big-endian hex constant h (a public value). +fe_from_hex(r: *Fe, h: string) { + fe_zero(r) + int len = strlen(h) + int d = 0 + while d < len { + int c = hexv(string_char_at_n(h, len, len - 1 - d)) + int limb = d / 8 + if limb < NMAX { + r.v[limb] = r.v[limb] | ((c as uint32) << ((d % 8) * 4)) + } + d = d + 1 + } +} + +// r = the len-byte big-endian integer at buf[off..off+len] (len <= 72). +fe_from_be(r: *Fe, buf: ptr, off: int, len: int) { + fe_zero(r) + int i = 0 + while i < len { + int limb = i / 4 + if limb < NMAX { + uint32 bv = (bytes.get(buf, off + len - 1 - i) & 255) as uint32 + r.v[limb] = r.v[limb] | (bv << ((i % 4) * 8)) + } + i = i + 1 + } +} + +// r = the len-byte little-endian integer at buf[off..off+len] (len <= 72). +fe_from_le(r: *Fe, buf: ptr, off: int, len: int) { + fe_zero(r) + int i = 0 + while i < len { + int limb = i / 4 + if limb < NMAX { + uint32 bv = (bytes.get(buf, off + i) & 255) as uint32 + r.v[limb] = r.v[limb] | (bv << ((i % 4) * 8)) + } + i = i + 1 + } +} + +// Write the low len bytes of a, big-endian, to out[off..off+len]. +fe_to_be(a: *Fe, out: ptr, off: int, len: int) { + // Write the last byte first so the buffer's length covers the range. + int i = 0 + while i < len { + int limb = i / 4 + int bv = 0 + if limb < NMAX { + bv = ((a.v[limb] >> ((i % 4) * 8)) & 255) as int + } + bytes.set(out, off + len - 1 - i, bv) + i = i + 1 + } +} + +// Write the low len bytes of a, little-endian, to out[off..off+len]. +fe_to_le(a: *Fe, out: ptr, off: int, len: int) { + int i = len - 1 + while i >= 0 { + int limb = i / 4 + int bv = 0 + if limb < NMAX { + bv = ((a.v[limb] >> ((i % 4) * 8)) & 255) as int + } + bytes.set(out, off + i, bv) + i = i - 1 + } +} + +// a >>= s bits, 0 <= s < 32 (s public). +fe_shr(a: *Fe, s: int) { + if s == 0 { + return + } + int i = 0 + while i < NMAX - 1 { + a.v[i] = (a.v[i] >> s) | (a.v[i + 1] << (32 - s)) + i = i + 1 + } + a.v[NMAX - 1] = a.v[NMAX - 1] >> s +} + +// ---- field set-up ---- + +// F = the field of the odd prime given as big-endian hex. +field_init(F: *Field, hex: string) { + fe_from_hex(&F.m, hex) + int n = NMAX + while n > 1 && F.m.v[n - 1] == 0 { + n = n - 1 + } + F.n = n + uint32 top = F.m.v[n - 1] + int tb = 0 + while top != 0 { + tb = tb + 1 + top = top >> 1 + } + F.bits = 32 * (n - 1) + tb + // -m^-1 mod 2^32 by Newton's iteration (each step doubles the bits). + uint32 m0 = F.m.v[0] + uint32 x = m0 + int it = 0 + while it < 5 { + x = x * (2 - m0 * x) + it = it + 1 + } + F.m0inv = 0 - x + // R mod m and R^2 mod m by doubling 1, 32n and 64n times. + fe_zero(&F.one) + F.one.v[0] = 1 + int k = 0 + while k < 32 * n { + fe_add(F, &F.one, &F.one, &F.one) + k = k + 1 + } + fe_copy(&F.r2, &F.one) + k = 0 + while k < 32 * n { + fe_add(F, &F.r2, &F.r2, &F.r2) + k = k + 1 + } + // m - 2 + fe_copy(&F.pm2, &F.m) + uint64 bw = 2 + int i = 0 + while i < n { + uint64 d = (F.pm2.v[i] as uint64) - bw + F.pm2.v[i] = d as uint32 + bw = d >> 63 + i = i + 1 + } +} + +// ---- field operations (constant time; operands < m, results < m) ---- + +// r = a + b mod m. r may alias a or b. +fe_add(F: *Field, r: *Fe, a: *Fe, b: *Fe) { + uint32[NMAX] t + int n = F.n + uint64 c = 0 + int i = 0 + while i < n { + uint64 s = (a.v[i] as uint64) + (b.v[i] as uint64) + c + t[i] = s as uint32 + c = s >> 32 + i = i + 1 + } + uint64 bw = 0 + i = 0 + while i < n { + uint64 d = (t[i] as uint64) - (F.m.v[i] as uint64) - bw + r.v[i] = d as uint32 + bw = d >> 63 + i = i + 1 + } + // Keep the sum when it did not carry and is below m (the subtraction + // borrowed); otherwise keep the difference. + uint32 mask = 0 - (((1 - c) & bw) as uint32) + i = 0 + while i < n { + r.v[i] = r.v[i] ^ (mask & (r.v[i] ^ t[i])) + i = i + 1 + } +} + +// r = a - b mod m. r may alias a or b. +fe_sub(F: *Field, r: *Fe, a: *Fe, b: *Fe) { + uint32[NMAX] t + int n = F.n + uint64 bw = 0 + int i = 0 + while i < n { + uint64 d = (a.v[i] as uint64) - (b.v[i] as uint64) - bw + t[i] = d as uint32 + bw = d >> 63 + i = i + 1 + } + uint32 mask = 0 - (bw as uint32) + uint64 c = 0 + i = 0 + while i < n { + uint64 s = (t[i] as uint64) + ((F.m.v[i] & mask) as uint64) + c + r.v[i] = s as uint32 + c = s >> 32 + i = i + 1 + } +} + +// r = -a mod m. +fe_neg(F: *Field, r: *Fe, a: *Fe) { + z = Fe {} + fe_zero(&z) + fe_sub(F, r, &z, a) +} + +// r = a * b / R mod m (Montgomery product, CIOS). r may alias a or b. +fe_mul(F: *Field, r: *Fe, a: *Fe, b: *Fe) { + uint32[NMAX + 2] t + int n = F.n + int i = 0 + while i < n + 2 { + t[i] = 0 + i = i + 1 + } + uint32 m0inv = F.m0inv + i = 0 + while i < n { + uint64 bi = b.v[i] as uint64 + uint64 c = 0 + int j = 0 + while j < n { + uint64 s = (t[j] as uint64) + (a.v[j] as uint64) * bi + c + t[j] = s as uint32 + c = s >> 32 + j = j + 1 + } + uint64 s2 = (t[n] as uint64) + c + t[n] = s2 as uint32 + t[n + 1] = (s2 >> 32) as uint32 + uint64 mm = ((t[0] * m0inv) as uint32) as uint64 + uint64 s3 = (t[0] as uint64) + mm * (F.m.v[0] as uint64) + c = s3 >> 32 + j = 1 + while j < n { + uint64 s4 = (t[j] as uint64) + mm * (F.m.v[j] as uint64) + c + t[j - 1] = s4 as uint32 + c = s4 >> 32 + j = j + 1 + } + uint64 s5 = (t[n] as uint64) + c + t[n - 1] = s5 as uint32 + t[n] = t[n + 1] + ((s5 >> 32) as uint32) + i = i + 1 + } + // t < 2m: subtract m unless t < m. + uint64 bw = 0 + i = 0 + while i < n { + uint64 d = (t[i] as uint64) - (F.m.v[i] as uint64) - bw + r.v[i] = d as uint32 + bw = d >> 63 + i = i + 1 + } + uint32 keep = (bw as uint32) & ((((t[n] as uint64) - 1) >> 63) as uint32) + uint32 mask = 0 - keep + i = 0 + while i < n { + r.v[i] = r.v[i] ^ (mask & (r.v[i] ^ t[i])) + i = i + 1 + } +} + +fe_sqr(F: *Field, r: *Fe, a: *Fe) { + fe_mul(F, r, a, a) +} + +// r = a^e (Montgomery form in and out) for a PUBLIC exponent e of ebits bits: +// the square-and-multiply sequence depends on e only. +fe_pow(F: *Field, r: *Fe, a: *Fe, e: *Fe, ebits: int) { + base = Fe {} + fe_copy(&base, a) + acc = Fe {} + fe_copy(&acc, &F.one) + int i = ebits - 1 + while i >= 0 { + fe_mul(F, &acc, &acc, &acc) + if ((e.v[i / 32] >> (i % 32)) & 1) == 1 { + fe_mul(F, &acc, &acc, &base) + } + i = i - 1 + } + fe_copy(r, &acc) +} + +// r = 1/a (Montgomery form), 0 when a is 0. +fe_inv(F: *Field, r: *Fe, a: *Fe) { + fe_pow(F, r, a, &F.pm2, F.bits) +} + +// r = 1 (Montgomery form). +fe_one(F: *Field, r: *Fe) { + fe_copy(r, &F.one) +} + +// r = v (Montgomery form), 0 <= v < m. +fe_from_int(F: *Field, r: *Fe, v: int) { + t = Fe {} + fe_zero(&t) + t.v[0] = v as uint32 + fe_mul(F, r, &t, &F.r2) +} + +// r = a * R mod m (into Montgomery form), a < m. +fe_to_mont(F: *Field, r: *Fe, a: *Fe) { + fe_mul(F, r, a, &F.r2) +} + +// r = a / R mod m (out of Montgomery form). +fe_from_mont(F: *Field, r: *Fe, a: *Fe) { + t = Fe {} + fe_zero(&t) + t.v[0] = 1 + fe_mul(F, r, a, &t) +} + +// r = a when mask is all ones, unchanged when mask is 0. +fe_cmov(F: *Field, r: *Fe, a: *Fe, mask: uint32) { + int i = 0 + while i < F.n { + r.v[i] = r.v[i] ^ (mask & (r.v[i] ^ a.v[i])) + i = i + 1 + } +} + +// Swap a and b when bit is 1, nothing when 0. +fe_cswap(F: *Field, a: *Fe, b: *Fe, bit: int) { + uint32 mask = 0 - ((bit & 1) as uint32) + int i = 0 + while i < F.n { + uint32 d = mask & (a.v[i] ^ b.v[i]) + a.v[i] = a.v[i] ^ d + b.v[i] = b.v[i] ^ d + i = i + 1 + } +} + +// 1 if a is zero, else 0. +fe_is_zero(F: *Field, a: *Fe) -> int { + uint32 acc = 0 + int i = 0 + while i < F.n { + acc = acc | a.v[i] + i = i + 1 + } + return ((((acc | (0 - acc)) >> 31) ^ 1) & 1) as int +} + +// 1 if a == b, else 0. +fe_eq(F: *Field, a: *Fe, b: *Fe) -> int { + uint32 acc = 0 + int i = 0 + while i < F.n { + acc = acc | (a.v[i] ^ b.v[i]) + i = i + 1 + } + return ((((acc | (0 - acc)) >> 31) ^ 1) & 1) as int +} + +// 1 if a < b as integers on the field's limbs, else 0. +fe_lt(F: *Field, a: *Fe, b: *Fe) -> int { + uint64 bw = 0 + int i = 0 + while i < F.n { + uint64 d = (a.v[i] as uint64) - (b.v[i] as uint64) - bw + bw = d >> 63 + i = i + 1 + } + return bw as int +} + +// a = a - m if a >= m (a < 2m, a on n limbs). +fe_cond_sub(F: *Field, a: *Fe) { + uint32[NMAX] t + uint64 bw = 0 + int i = 0 + while i < F.n { + uint64 d = (a.v[i] as uint64) - (F.m.v[i] as uint64) - bw + t[i] = d as uint32 + bw = d >> 63 + i = i + 1 + } + // bw == 1: a < m, keep a. + uint32 mask = 0 - ((1 - bw) as uint32) + i = 0 + while i < F.n { + a.v[i] = a.v[i] ^ (mask & (a.v[i] ^ t[i])) + i = i + 1 + } +} + +// a = 2a + bit mod m (a < m in normal form, bit 0 or 1). +fn fe_dbl_bit(F: *Field, a: *Fe, bit: uint32) { + uint32[NMAX] t + int n = F.n + uint32 cin = bit + int i = 0 + while i < n { + uint32 w = a.v[i] + t[i] = (w << 1) | cin + cin = w >> 31 + i = i + 1 + } + uint64 bw = 0 + i = 0 + while i < n { + uint64 d = (t[i] as uint64) - (F.m.v[i] as uint64) - bw + a.v[i] = d as uint32 + bw = d >> 63 + i = i + 1 + } + uint32 mask = 0 - (((1 - cin) as uint32) & (bw as uint32)) + i = 0 + while i < n { + a.v[i] = a.v[i] ^ (mask & (a.v[i] ^ t[i])) + i = i + 1 + } +} + +// r = (the len-byte big-endian integer at buf[off..]) mod m, in normal form, +// for any len: one modular doubling per input bit, constant time. +fe_reduce_be(F: *Field, r: *Fe, buf: ptr, off: int, len: int) { + fe_zero(r) + int i = 0 + while i < len { + uint32 bv = (bytes.get(buf, off + i) & 255) as uint32 + int j = 7 + while j >= 0 { + fe_dbl_bit(F, r, (bv >> j) & 1) + j = j - 1 + } + i = i + 1 + } +} + +// r = (the len-byte little-endian integer at buf[off..]) mod m, normal form. +fe_reduce_le(F: *Field, r: *Fe, buf: ptr, off: int, len: int) { + fe_zero(r) + int i = len - 1 + while i >= 0 { + uint32 bv = (bytes.get(buf, off + i) & 255) as uint32 + int j = 7 + while j >= 0 { + fe_dbl_bit(F, r, (bv >> j) & 1) + j = j - 1 + } + i = i - 1 + } +} + +// ---- curves ---- + +struct Curve { + p: Field // coordinates + q: Field // scalars: the group order n + nbytes: int // octets of a coordinate and of a scalar + a_m3: int // 1: a = -3, 0: a = 0 + b: Fe // b, Montgomery form + b3: Fe // 3b, Montgomery form + gx: Fe // generator, Montgomery form + gy: Fe + hlen: int // digest length of the RFC 6979 hash used for other lengths +} + +// A point in homogeneous projective coordinates (X:Y:Z), x = X/Z, y = Y/Z; +// the identity is (0:1:0). +struct Pt { + x: Fe + y: Fe + z: Fe +} + +fn set_mont_hex(F: *Field, r: *Fe, h: string) { + t = Fe {} + fe_from_hex(&t, h) + fe_to_mont(F, r, &t) +} + +// Fill c for a curve id; 0 for an unknown id. +fn curve_init(c: *Curve, id: int) -> int { + if id == P256 { + field_init(&c.p, "ffffffff00000001000000000000000000000000ffffffffffffffffffffffff") + field_init(&c.q, "ffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551") + c.a_m3 = 1 + set_mont_hex(&c.p, &c.b, "5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604b") + set_mont_hex(&c.p, &c.gx, "6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296") + set_mont_hex(&c.p, &c.gy, "4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5") + c.hlen = 32 + } else if id == P384 { + field_init(&c.p, "fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffeffffffff0000000000000000ffffffff") + field_init(&c.q, "ffffffffffffffffffffffffffffffffffffffffffffffffc7634d81f4372ddf581a0db248b0a77aecec196accc52973") + c.a_m3 = 1 + set_mont_hex(&c.p, &c.b, "b3312fa7e23ee7e4988e056be3f82d19181d9c6efe8141120314088f5013875ac656398d8a2ed19d2a85c8edd3ec2aef") + set_mont_hex(&c.p, &c.gx, "aa87ca22be8b05378eb1c71ef320ad746e1d3b628ba79b9859f741e082542a385502f25dbf55296c3a545e3872760ab7") + set_mont_hex(&c.p, &c.gy, "3617de4a96262c6f5d9e98bf9292dc29f8f41dbd289a147ce9da3113b5f0b8c00a60b1ce1d7e819d7a431d7c90ea0e5f") + c.hlen = 48 + } else if id == P521 { + field_init(&c.p, "01ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff") + field_init(&c.q, "01fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffa51868783bf2f966b7fcc0148f709a5d03bb5c9b8899c47aebb6fb71e91386409") + c.a_m3 = 1 + set_mont_hex(&c.p, &c.b, "0051953eb9618e1c9a1f929a21a0b68540eea2da725b99b315f3b8b489918ef109e156193951ec7e937b1652c0bd3bb1bf073573df883d2c34f1ef451fd46b503f00") + set_mont_hex(&c.p, &c.gx, "00c6858e06b70404e9cd9e3ecb662395b4429c648139053fb521f828af606b4d3dbaa14b5e77efe75928fe1dc127a2ffa8de3348b3c1856a429bf97e7e31c2e5bd66") + set_mont_hex(&c.p, &c.gy, "011839296a789a3bc0045c8a5fb42c7d1bd998f54449579b446817afbd17273e662c97ee72995ef42640c550b9013fad0761353c7086a272c24088be94769fd16650") + c.hlen = 64 + } else if id == SECP256K1 { + field_init(&c.p, "fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f") + field_init(&c.q, "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141") + c.a_m3 = 0 + fe_from_int(&c.p, &c.b, 7) + set_mont_hex(&c.p, &c.gx, "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798") + set_mont_hex(&c.p, &c.gy, "483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8") + c.hlen = 32 + } else { + return 0 + } + fe_add(&c.p, &c.b3, &c.b, &c.b) + fe_add(&c.p, &c.b3, &c.b3, &c.b) + c.nbytes = (c.p.bits + 7) / 8 + return 1 +} + +fn pt_identity(c: *Curve, r: *Pt) { + fe_zero(&r.x) + fe_copy(&r.y, &c.p.one) + fe_zero(&r.z) +} + +fn pt_copy(r: *Pt, a: *Pt) { + fe_copy(&r.x, &a.x) + fe_copy(&r.y, &a.y) + fe_copy(&r.z, &a.z) +} + +fn pt_cmov(c: *Curve, r: *Pt, a: *Pt, mask: uint32) { + fe_cmov(&c.p, &r.x, &a.x, mask) + fe_cmov(&c.p, &r.y, &a.y, mask) + fe_cmov(&c.p, &r.z, &a.z, mask) +} + +// r = p + q, complete for a = -3 (RCB algorithm 4). r may alias p or q. +fn pt_add_m3(c: *Curve, r: *Pt, p: *Pt, q: *Pt) { + F = &c.p + xx = Fe {} + yy = Fe {} + zz = Fe {} + xy = Fe {} + yz = Fe {} + xz = Fe {} + t = Fe {} + u = Fe {} + fe_mul(F, &xx, &p.x, &q.x) + fe_mul(F, &yy, &p.y, &q.y) + fe_mul(F, &zz, &p.z, &q.z) + // xy = (X1 + Y1)(X2 + Y2) - (xx + yy) + fe_add(F, &t, &p.x, &p.y) + fe_add(F, &u, &q.x, &q.y) + fe_mul(F, &xy, &t, &u) + fe_add(F, &t, &xx, &yy) + fe_sub(F, &xy, &xy, &t) + // yz = (Y1 + Z1)(Y2 + Z2) - (yy + zz) + fe_add(F, &t, &p.y, &p.z) + fe_add(F, &u, &q.y, &q.z) + fe_mul(F, &yz, &t, &u) + fe_add(F, &t, &yy, &zz) + fe_sub(F, &yz, &yz, &t) + // xz = (X1 + Z1)(X2 + Z2) - (xx + zz) + fe_add(F, &t, &p.x, &p.z) + fe_add(F, &u, &q.x, &q.z) + fe_mul(F, &xz, &t, &u) + fe_add(F, &t, &xx, &zz) + fe_sub(F, &xz, &xz, &t) + // bzz3 = 3(xz - b zz); yym = yy - bzz3; yyp = yy + bzz3 + bzz3 = Fe {} + fe_mul(F, &t, &c.b, &zz) + fe_sub(F, &t, &xz, &t) + fe_add(F, &bzz3, &t, &t) + fe_add(F, &bzz3, &bzz3, &t) + yym = Fe {} + yyp = Fe {} + fe_sub(F, &yym, &yy, &bzz3) + fe_add(F, &yyp, &yy, &bzz3) + // zz3 = 3 zz; bxz3 = 3(b xz - zz3 - xx); xx3 = 3 xx - zz3 + zz3 = Fe {} + fe_add(F, &zz3, &zz, &zz) + fe_add(F, &zz3, &zz3, &zz) + bxz3 = Fe {} + fe_mul(F, &t, &c.b, &xz) + fe_sub(F, &t, &t, &zz3) + fe_sub(F, &t, &t, &xx) + fe_add(F, &bxz3, &t, &t) + fe_add(F, &bxz3, &bxz3, &t) + xx3 = Fe {} + fe_add(F, &xx3, &xx, &xx) + fe_add(F, &xx3, &xx3, &xx) + fe_sub(F, &xx3, &xx3, &zz3) + // X3 = yyp xy - yz bxz3; Y3 = yyp yym + xx3 bxz3; Z3 = yym yz + xy xx3 + x3 = Fe {} + y3 = Fe {} + z3 = Fe {} + fe_mul(F, &x3, &yyp, &xy) + fe_mul(F, &t, &yz, &bxz3) + fe_sub(F, &x3, &x3, &t) + fe_mul(F, &y3, &yyp, &yym) + fe_mul(F, &t, &xx3, &bxz3) + fe_add(F, &y3, &y3, &t) + fe_mul(F, &z3, &yym, &yz) + fe_mul(F, &t, &xy, &xx3) + fe_add(F, &z3, &z3, &t) + fe_copy(&r.x, &x3) + fe_copy(&r.y, &y3) + fe_copy(&r.z, &z3) +} + +// r = 2p for a = -3 (RCB algorithm 6). r may alias p. +fn pt_dbl_m3(c: *Curve, r: *Pt, p: *Pt) { + F = &c.p + t0 = Fe {} + t1 = Fe {} + t2 = Fe {} + t3 = Fe {} + x3 = Fe {} + y3 = Fe {} + z3 = Fe {} + fe_sqr(F, &t0, &p.x) + fe_sqr(F, &t1, &p.y) + fe_sqr(F, &t2, &p.z) + fe_mul(F, &t3, &p.x, &p.y) + fe_add(F, &t3, &t3, &t3) + fe_mul(F, &z3, &p.x, &p.z) + fe_add(F, &z3, &z3, &z3) + fe_mul(F, &y3, &c.b, &t2) + fe_sub(F, &y3, &y3, &z3) + fe_add(F, &x3, &y3, &y3) + fe_add(F, &y3, &x3, &y3) + fe_sub(F, &x3, &t1, &y3) + fe_add(F, &y3, &t1, &y3) + fe_mul(F, &y3, &x3, &y3) + fe_mul(F, &x3, &x3, &t3) + fe_add(F, &t3, &t2, &t2) + fe_add(F, &t2, &t2, &t3) + fe_mul(F, &z3, &c.b, &z3) + fe_sub(F, &z3, &z3, &t2) + fe_sub(F, &z3, &z3, &t0) + fe_add(F, &t3, &z3, &z3) + fe_add(F, &z3, &z3, &t3) + fe_add(F, &t3, &t0, &t0) + fe_add(F, &t0, &t3, &t0) + fe_sub(F, &t0, &t0, &t2) + fe_mul(F, &t0, &t0, &z3) + fe_add(F, &y3, &y3, &t0) + fe_mul(F, &t0, &p.y, &p.z) + fe_add(F, &t0, &t0, &t0) + fe_mul(F, &z3, &t0, &z3) + fe_sub(F, &x3, &x3, &z3) + fe_mul(F, &z3, &t0, &t1) + fe_add(F, &z3, &z3, &z3) + fe_add(F, &z3, &z3, &z3) + fe_copy(&r.x, &x3) + fe_copy(&r.y, &y3) + fe_copy(&r.z, &z3) +} + +// r = p + q, complete for a = 0 (RCB algorithm 7). r may alias p or q. +fn pt_add_a0(c: *Curve, r: *Pt, p: *Pt, q: *Pt) { + F = &c.p + t0 = Fe {} + t1 = Fe {} + t2 = Fe {} + t3 = Fe {} + t4 = Fe {} + x3 = Fe {} + y3 = Fe {} + z3 = Fe {} + fe_mul(F, &t0, &p.x, &q.x) + fe_mul(F, &t1, &p.y, &q.y) + fe_mul(F, &t2, &p.z, &q.z) + fe_add(F, &t3, &p.x, &p.y) + fe_add(F, &t4, &q.x, &q.y) + fe_mul(F, &t3, &t3, &t4) + fe_add(F, &t4, &t0, &t1) + fe_sub(F, &t3, &t3, &t4) + fe_add(F, &t4, &p.y, &p.z) + fe_add(F, &x3, &q.y, &q.z) + fe_mul(F, &t4, &t4, &x3) + fe_add(F, &x3, &t1, &t2) + fe_sub(F, &t4, &t4, &x3) + fe_add(F, &x3, &p.x, &p.z) + fe_add(F, &y3, &q.x, &q.z) + fe_mul(F, &x3, &x3, &y3) + fe_add(F, &y3, &t0, &t2) + fe_sub(F, &y3, &x3, &y3) + fe_add(F, &x3, &t0, &t0) + fe_add(F, &t0, &x3, &t0) + fe_mul(F, &t2, &c.b3, &t2) + fe_add(F, &z3, &t1, &t2) + fe_sub(F, &t1, &t1, &t2) + fe_mul(F, &y3, &c.b3, &y3) + fe_mul(F, &x3, &t4, &y3) + fe_mul(F, &t2, &t3, &t1) + fe_sub(F, &x3, &t2, &x3) + fe_mul(F, &y3, &y3, &t0) + fe_mul(F, &t1, &t1, &z3) + fe_add(F, &y3, &t1, &y3) + fe_mul(F, &t0, &t0, &t3) + fe_mul(F, &z3, &z3, &t4) + fe_add(F, &z3, &z3, &t0) + fe_copy(&r.x, &x3) + fe_copy(&r.y, &y3) + fe_copy(&r.z, &z3) +} + +// r = 2p for a = 0 (RCB algorithm 9). r may alias p. +fn pt_dbl_a0(c: *Curve, r: *Pt, p: *Pt) { + F = &c.p + t0 = Fe {} + t1 = Fe {} + t2 = Fe {} + x3 = Fe {} + y3 = Fe {} + z3 = Fe {} + fe_sqr(F, &t0, &p.y) + fe_add(F, &z3, &t0, &t0) + fe_add(F, &z3, &z3, &z3) + fe_add(F, &z3, &z3, &z3) + fe_mul(F, &t1, &p.y, &p.z) + fe_sqr(F, &t2, &p.z) + fe_mul(F, &t2, &c.b3, &t2) + fe_mul(F, &x3, &t2, &z3) + fe_add(F, &y3, &t0, &t2) + fe_mul(F, &z3, &t1, &z3) + fe_add(F, &t1, &t2, &t2) + fe_add(F, &t2, &t1, &t2) + fe_sub(F, &t0, &t0, &t2) + fe_mul(F, &y3, &t0, &y3) + fe_add(F, &y3, &x3, &y3) + fe_mul(F, &t1, &p.x, &p.y) + fe_mul(F, &x3, &t0, &t1) + fe_add(F, &x3, &x3, &x3) + fe_copy(&r.x, &x3) + fe_copy(&r.y, &y3) + fe_copy(&r.z, &z3) +} + +fn pt_add(c: *Curve, r: *Pt, p: *Pt, q: *Pt) { + if c.a_m3 == 1 { + pt_add_m3(c, r, p, q) + } else { + pt_add_a0(c, r, p, q) + } +} + +fn pt_dbl(c: *Curve, r: *Pt, p: *Pt) { + if c.a_m3 == 1 { + pt_dbl_m3(c, r, p) + } else { + pt_dbl_a0(c, r, p) + } +} + +// Fill t[0..16] with 0*p .. 15*p. +fn pt_table(c: *Curve, t: Pt[], p: *Pt) { + pt_identity(c, &t[0]) + pt_copy(&t[1], p) + int i = 2 + while i < 16 { + if (i & 1) == 0 { + pt_dbl(c, &t[i], &t[i / 2]) + } else { + pt_add(c, &t[i], &t[i - 1], p) + } + i = i + 1 + } +} + +// r = k * p, k the klen-byte big-endian scalar at kb[0..klen]: constant +// time in k. Every 4-bit digit costs four doublings, a read of all 16 table +// entries and one complete addition, whatever its value. +fn pt_mul_ct(c: *Curve, r: *Pt, p: *Pt, kb: ptr, klen: int) { + Pt[16] tbl + pt_table(c, tbl, p) + acc = Pt {} + pt_identity(c, &acc) + sel = Pt {} + int bi = 0 + while bi < klen { + int kv = bytes.get(kb, bi) & 255 + int half = 0 + while half < 2 { + int digit = (kv >> (4 - 4 * half)) & 15 + pt_dbl(c, &acc, &acc) + pt_dbl(c, &acc, &acc) + pt_dbl(c, &acc, &acc) + pt_dbl(c, &acc, &acc) + pt_copy(&sel, &tbl[0]) + int j = 1 + while j < 16 { + uint32 mask = 0 - (ct.eq(j, digit) as uint32) + pt_cmov(c, &sel, &tbl[j], mask) + j = j + 1 + } + pt_add(c, &acc, &acc, &sel) + half = half + 1 + } + bi = bi + 1 + } + pt_copy(r, &acc) +} + +// r = u1 * p1 + u2 * p2 for PUBLIC scalars (big-endian, klen bytes each): +// one interleaved pass, skipping zero digits. Not constant time. +fn pt_mul2_vt(c: *Curve, r: *Pt, u1: ptr, p1: *Pt, u2: ptr, p2: *Pt, klen: int) { + Pt[16] t1 + Pt[16] t2 + pt_table(c, t1, p1) + pt_table(c, t2, p2) + acc = Pt {} + pt_identity(c, &acc) + int started = 0 + int bi = 0 + while bi < klen { + int a = bytes.get(u1, bi) & 255 + int b = bytes.get(u2, bi) & 255 + int half = 0 + while half < 2 { + int sh = 4 - 4 * half + int d1 = (a >> sh) & 15 + int d2 = (b >> sh) & 15 + if started == 1 { + pt_dbl(c, &acc, &acc) + pt_dbl(c, &acc, &acc) + pt_dbl(c, &acc, &acc) + pt_dbl(c, &acc, &acc) + } + if d1 != 0 { + pt_add(c, &acc, &acc, &t1[d1]) + started = 1 + } + if d2 != 0 { + pt_add(c, &acc, &acc, &t2[d2]) + started = 1 + } + half = half + 1 + } + bi = bi + 1 + } + pt_copy(r, &acc) +} + +// Affine coordinates of p in Montgomery form; returns 1, or 0 for the +// identity (x and y are then 0). Constant time. +fn pt_affine(c: *Curve, x: *Fe, y: *Fe, p: *Pt) -> int { + F = &c.p + zi = Fe {} + fe_inv(F, &zi, &p.z) + fe_mul(F, x, &p.x, &zi) + fe_mul(F, y, &p.y, &zi) + return 1 - fe_is_zero(F, &p.z) +} + +// 1 if the affine (x, y) (Montgomery form) satisfies y^2 = x^3 + ax + b. +fn on_curve(c: *Curve, x: *Fe, y: *Fe) -> int { + F = &c.p + lhs = Fe {} + rhs = Fe {} + t = Fe {} + fe_sqr(F, &lhs, y) + fe_sqr(F, &rhs, x) + fe_mul(F, &rhs, &rhs, x) + if c.a_m3 == 1 { + fe_add(F, &t, x, x) + fe_add(F, &t, &t, x) + fe_sub(F, &rhs, &rhs, &t) + } + fe_add(F, &rhs, &rhs, &c.b) + return fe_eq(F, &lhs, &rhs) +} + +// Decode and validate a public point (two L-byte big-endian coordinates): +// each coordinate below p and the point on the curve. 1 on success. +fn decode_point(c: *Curve, r: *Pt, xb: ptr, yb: ptr) -> int { + if xb == null || yb == null { + return 0 + } + int nb = c.nbytes + if bytes.length(xb) != nb || bytes.length(yb) != nb { + return 0 + } + F = &c.p + x = Fe {} + y = Fe {} + fe_from_be(&x, xb, 0, nb) + fe_from_be(&y, yb, 0, nb) + if fe_lt(F, &x, &F.m) == 0 || fe_lt(F, &y, &F.m) == 0 { + return 0 + } + fe_to_mont(F, &r.x, &x) + fe_to_mont(F, &r.y, &y) + fe_copy(&r.z, &F.one) + return on_curve(c, &r.x, &r.y) +} + +fn base_point(c: *Curve, r: *Pt) { + fe_copy(&r.x, &c.gx) + fe_copy(&r.y, &c.gy) + fe_copy(&r.z, &c.p.one) +} + +// A fresh L-byte big-endian buffer holding the Montgomery-form a. +fn out_coord(c: *Curve, a: *Fe) -> ptr { + t = Fe {} + fe_from_mont(&c.p, &t, a) + out = bytes.new(c.nbytes) + fe_to_be(&t, out, 0, c.nbytes) + return out +} + +fn wipe_free(b: @frees ptr) { + bytes.wipe_free(b) +} + +// The leftmost qlen bits of the blen-byte string buf as an integer (the +// bits2int of RFC 6979 section 2.3.2 and FIPS 186-5); qlen, blen public. +fn bits2int(c: *Curve, r: *Fe, buf: ptr, blen: int) { + int qlen = c.q.bits + if blen * 8 <= qlen { + fe_from_be(r, buf, 0, blen) + return + } + int drop = blen * 8 - qlen + fe_from_be(r, buf, 0, blen - drop / 8) + fe_shr(r, drop % 8) +} + +// ---- public API ---- + +scalar_mult_base(curve: int, k: ptr) -> (ptr, ptr) { + c = Curve {} + if curve_init(&c, curve) == 0 || k == null || bytes.length(k) != c.nbytes { + return null, null + } + g = Pt {} + base_point(&c, &g) + q = Pt {} + pt_mul_ct(&c, &q, &g, k, c.nbytes) + x = Fe {} + y = Fe {} + if ct.declassify(pt_affine(&c, &x, &y, &q)) == 0 { + return null, null + } + return out_coord(&c, &x), out_coord(&c, &y) +} + +scalar_mult(curve: int, k: ptr, px: ptr, py: ptr) -> (ptr, ptr) { + c = Curve {} + if curve_init(&c, curve) == 0 || k == null || bytes.length(k) != c.nbytes { + return null, null + } + p = Pt {} + if decode_point(&c, &p, px, py) == 0 { + return null, null + } + q = Pt {} + pt_mul_ct(&c, &q, &p, k, c.nbytes) + x = Fe {} + y = Fe {} + if ct.declassify(pt_affine(&c, &x, &y, &q)) == 0 { + return null, null + } + return out_coord(&c, &x), out_coord(&c, &y) +} + +// 1 if 0 < d < n for the L-byte big-endian private scalar at priv. +fn priv_valid(c: *Curve, d: *Fe) -> int { + return (1 - fe_is_zero(&c.q, d)) & fe_lt(&c.q, d, &c.q.m) +} + +public_key_valid(curve: int, pubx: ptr, puby: ptr) -> int { + c = Curve {} + if curve_init(&c, curve) == 0 { + return 0 + } + p = Pt {} + return decode_point(&c, &p, pubx, puby) +} + +// 1 if priv is an L-byte big-endian scalar in [1, n-1], the range a private +// key must be in. Constant time in the key; the answer itself is public. +private_key_valid(curve: int, priv: ptr) -> int { + c = Curve {} + if curve_init(&c, curve) == 0 || priv == null || bytes.length(priv) != c.nbytes { + return 0 + } + d = Fe {} + fe_from_be(&d, priv, 0, c.nbytes) + return ct.declassify(priv_valid(&c, &d)) +} + +ecdh(curve: int, priv: ptr, pubx: ptr, puby: ptr) -> ptr { + c = Curve {} + if curve_init(&c, curve) == 0 || priv == null || bytes.length(priv) != c.nbytes { + return null + } + p = Pt {} + if decode_point(&c, &p, pubx, puby) == 0 { + return null + } + d = Fe {} + fe_from_be(&d, priv, 0, c.nbytes) + if ct.declassify(priv_valid(&c, &d)) == 0 { + return null + } + q = Pt {} + pt_mul_ct(&c, &q, &p, priv, c.nbytes) + x = Fe {} + y = Fe {} + if ct.declassify(pt_affine(&c, &x, &y, &q)) == 0 { + return null + } + return out_coord(&c, &x) +} + +// The SHA-2 function RFC 6979's HMAC-DRBG runs on: the one whose digest +// length is the message hash's (so the RFC's vectors for each hash hold), or +// the curve's own for any other length. +fn drbg_hash(hlen: int, dflt: int) -> string { + int l = hlen + if l != 28 && l != 32 && l != 48 && l != 64 { + l = dflt + } + if l == 28 { return "sha224" } + if l == 48 { return "sha384" } + if l == 64 { return "sha512" } + return "sha256" +} + +fn drbg_hash_len(algo: string) -> int { + if algo == "sha224" { return 28 } + if algo == "sha384" { return 48 } + if algo == "sha512" { return 64 } + return 32 +} + +// K = HMAC_K(V || sep || x || h), V = HMAC_K(V), where x and h may be null +// (left out). Frees the old K and V; returns the new pair. +fn drbg_step(algo: string, k: ptr, v: ptr, sep: int, x: ptr, h: ptr) -> (ptr, ptr) { + int hl = bytes.length(v) + int xl = 0 + if x != null { + xl = bytes.length(x) + } + int hhl = 0 + if h != null { + hhl = bytes.length(h) + } + msg = bytes.new(hl + 1 + xl + hhl) + bytes.copy_from_bytes(msg, 0, v, 0, hl) + bytes.set(msg, hl, sep) + if xl > 0 { + bytes.copy_from_bytes(msg, hl + 1, x, 0, xl) + } + if hhl > 0 { + bytes.copy_from_bytes(msg, hl + 1 + xl, h, 0, hhl) + } + nk = hmac.bytes(algo, bytes.view(k), bytes.view(msg)) + wipe_free(msg) + wipe_free(k) + nv = hmac.bytes(algo, bytes.view(nk), bytes.view(v)) + wipe_free(v) + return nk, nv +} + +// ECDSA signature of the message digest `hash` (any length; its leftmost +// qlen bits are used) under the L-byte private key `priv`, with the nonce of +// RFC 6979. Constant time in the key and the nonce. +ecdsa_sign(curve: int, priv: ptr, hash: ptr) -> (ptr, ptr) { + return ecdsa_sign_extra(curve, priv, hash, null) +} + +// The same with a hedged nonce (#3376): `extra`, fresh random bytes (32 is +// plenty), joins the key and the digest in seeding RFC 6979's HMAC-DRBG, +// the variant RFC 6979 section 3.6 defines (k'). A fault in one of two +// signatures of one digest under a deterministic nonce gives the key away; +// with fresh bytes each time, no two signings share a nonce. Whatever +// `extra` holds (repeated, zero, chosen by an attacker), the nonce is no +// weaker than RFC 6979's: the key is still in the seed. A null or empty +// `extra` is RFC 6979 exactly. +ecdsa_sign_hedged(curve: int, priv: ptr, hash: ptr, extra: ptr) -> (ptr, ptr) { + return ecdsa_sign_extra(curve, priv, hash, extra) +} + +fn ecdsa_sign_extra(curve: int, priv: ptr, hash: ptr, extra: ptr) -> (ptr, ptr) { + c = Curve {} + if curve_init(&c, curve) == 0 || priv == null || hash == null { + return null, null + } + int nb = c.nbytes + int hlen = bytes.length(hash) + if bytes.length(priv) != nb || hlen < 1 { + return null, null + } + Q = &c.q + d = Fe {} + fe_from_be(&d, priv, 0, nb) + if ct.declassify(priv_valid(&c, &d)) == 0 { + return null, null + } + // e = bits2int(hash) mod n; bits2int(hash) < 2^qlen < 2n. + e = Fe {} + bits2int(&c, &e, hash, hlen) + fe_cond_sub(Q, &e) + + // RFC 6979 section 3.2: V = 0x01.., K = 0x00..; two seeding rounds over + // int2octets(d) || bits2octets(hash), and k' after them for a hedged + // nonce (section 3.6). + algo = drbg_hash(hlen, c.hlen) + int hl = drbg_hash_len(algo) + xo = bytes.new(nb) + fe_to_be(&d, xo, 0, nb) + int el = 0 + if extra != null { + el = bytes.length(extra) + } + ho = bytes.new(nb + el) + fe_to_be(&e, ho, 0, nb) + if el > 0 { + bytes.copy_from_bytes(ho, nb, extra, 0, el) + } + kk = bytes.new(hl) + vv = bytes.new(hl) + int i = 0 + while i < hl { + bytes.set(kk, i, 0) + bytes.set(vv, i, 1) + i = i + 1 + } + kk, vv = drbg_step(algo, kk, vv, 0, xo, ho) + kk, vv = drbg_step(algo, kk, vv, 1, xo, ho) + + // Montgomery forms of d and e for s = k^-1 (e + r d) mod n. + dm = Fe {} + em = Fe {} + fe_to_mont(Q, &dm, &d) + fe_to_mont(Q, &em, &e) + + int tlen = ((c.q.bits + 8 * hl - 1) / (8 * hl)) * hl + rb = bytes.new(nb) + sb = bytes.new(nb) + kb = bytes.new(nb) + g = Pt {} + base_point(&c, &g) + int done = 0 + while done == 0 { + // T = V' || V'' || ... until qlen bits; k = bits2int(T). + tt = bytes.new(tlen) + int off = 0 + while off < tlen { + nv = hmac.bytes(algo, bytes.view(kk), bytes.view(vv)) + wipe_free(vv) + vv = nv + bytes.copy_from_bytes(tt, off, vv, 0, hl) + off = off + hl + } + k = Fe {} + bits2int(&c, &k, tt, tlen) + wipe_free(tt) + if ct.declassify(priv_valid(&c, &k)) == 1 { + fe_to_be(&k, kb, 0, nb) + // r = x(k G) mod n; x < p < 2n. + rp = Pt {} + pt_mul_ct(&c, &rp, &g, kb, nb) + x = Fe {} + y = Fe {} + pt_affine(&c, &x, &y, &rp) + r = Fe {} + fe_from_mont(&c.p, &r, &x) + fe_cond_sub(Q, &r) + // s = k^-1 (e + r d) mod n + km = Fe {} + fe_to_mont(Q, &km, &k) + fe_inv(Q, &km, &km) + rm = Fe {} + fe_to_mont(Q, &rm, &r) + t = Fe {} + fe_mul(Q, &t, &rm, &dm) + fe_add(Q, &t, &t, &em) + fe_mul(Q, &t, &t, &km) + s = Fe {} + fe_from_mont(Q, &s, &t) + int ok = (1 - fe_is_zero(Q, &r)) & (1 - fe_is_zero(Q, &s)) + if ct.declassify(ok) == 1 { + fe_to_be(&r, rb, 0, nb) + fe_to_be(&s, sb, 0, nb) + done = 1 + } + fe_zero(&k) + fe_zero(&km) + } + if done == 0 { + // RFC 6979 3.2 step h.3: K = HMAC_K(V || 0x00), V = HMAC_K(V). + kk, vv = drbg_step(algo, kk, vv, 0, null, null) + } + } + fe_zero(&d) + fe_zero(&dm) + wipe_free(xo) + wipe_free(ho) + wipe_free(kk) + wipe_free(vv) + wipe_free(kb) + return rb, sb +} + +ecdsa_verify(curve: int, pubx: ptr, puby: ptr, hash: ptr, r: ptr, s: ptr) -> int { + c = Curve {} + if curve_init(&c, curve) == 0 || hash == null || r == null || s == null { + return 0 + } + int nb = c.nbytes + if bytes.length(r) != nb || bytes.length(s) != nb { + return 0 + } + int hlen = bytes.length(hash) + if hlen < 1 { + return 0 + } + pub = Pt {} + if decode_point(&c, &pub, pubx, puby) == 0 { + return 0 + } + Q = &c.q + rn = Fe {} + sn = Fe {} + fe_from_be(&rn, r, 0, nb) + fe_from_be(&sn, s, 0, nb) + if fe_is_zero(Q, &rn) == 1 || fe_is_zero(Q, &sn) == 1 { + return 0 + } + if fe_lt(Q, &rn, &Q.m) == 0 || fe_lt(Q, &sn, &Q.m) == 0 { + return 0 + } + e = Fe {} + bits2int(&c, &e, hash, hlen) + fe_cond_sub(Q, &e) + // w = s^-1; u1 = e w; u2 = r w (mod n) + w = Fe {} + fe_to_mont(Q, &w, &sn) + fe_inv(Q, &w, &w) + t = Fe {} + u = Fe {} + fe_to_mont(Q, &t, &e) + fe_mul(Q, &t, &t, &w) + fe_from_mont(Q, &u, &t) + u1 = bytes.new(nb) + fe_to_be(&u, u1, 0, nb) + fe_to_mont(Q, &t, &rn) + fe_mul(Q, &t, &t, &w) + fe_from_mont(Q, &u, &t) + u2 = bytes.new(nb) + fe_to_be(&u, u2, 0, nb) + g = Pt {} + base_point(&c, &g) + rp = Pt {} + pt_mul2_vt(&c, &rp, u1, &g, u2, &pub, nb) + bytes.wipe_free(u1) + bytes.wipe_free(u2) + x = Fe {} + y = Fe {} + if pt_affine(&c, &x, &y, &rp) == 0 { + return 0 + } + v = Fe {} + fe_from_mont(&c.p, &v, &x) + fe_cond_sub(Q, &v) + return fe_eq(Q, &v, &rn) +} + +// ---- Edwards curves (Ed25519, Ed448) ---- +// +// a x^2 + y^2 = 1 + d x^2 y^2 with a = -1 (edwards25519) or a = 1 +// (edwards448), d not a square: the projective addition law of Bernstein, +// Birkner, Joye, Lange and Peters (add-2008-bbjlp) and its doubling +// (dbl-2008-bbjlp) are complete, so no branch is needed on a point. The +// EdDSA protocols (hashing, clamping, dom4) stay in std.cryptography.ed25519 +// and ed448; this is their arithmetic. Scalars are little-endian, as +// RFC 8032 encodes them. +// +// ed_scalar_mult_base(curve, k_le) -> encoded k*B (constant time) +// ed_reduce(curve, h_le) -> h mod L (constant time) +// ed_sign_s(curve, r_le, k_le, a_le) -> (r + k a) mod L (constant time) +// ed_verify(curve, pub, r_enc, s_le, k_le) -> 1 if [S]B - [k]A encodes as R + +const ED25519 = 5 +const ED448 = 6 + +struct EdCurve { + p: Field + q: Field // the group order L + nb: int // bytes of an encoded point and of a scalar: 32 or 57 + a_neg: int // 1: a = -1, 0: a = 1 + d: Fe // Montgomery form + gx: Fe + gy: Fe + sqrt_e: Fe // square-root exponent: (p+3)/8 (p = 5 mod 8) or (p+1)/4 + sqrt_m1: Fe // sqrt(-1), Montgomery form (edwards25519 only) +} + +// e = (m + add) >> sh over F's limbs (public; small add and sh < 32). +fn exp_from_p(F: *Field, e: *Fe, add: int, sh: int) { + fe_copy(e, &F.m) + uint64 c = add as uint64 + int i = 0 + while i < NMAX { + uint64 s = (e.v[i] as uint64) + c + e.v[i] = s as uint32 + c = s >> 32 + i = i + 1 + } + fe_shr(e, sh) +} + +fn ed_init(c: *EdCurve, id: int) -> int { + if id == ED25519 { + field_init(&c.p, "7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffed") + field_init(&c.q, "1000000000000000000000000000000014def9dea2f79cd65812631a5cf5d3ed") + c.nb = 32 + c.a_neg = 1 + set_mont_hex(&c.p, &c.d, "52036cee2b6ffe738cc740797779e89800700a4d4141d8ab75eb4dca135978a3") + set_mont_hex(&c.p, &c.gx, "216936d3cd6e53fec0a4e231fdd6dc5c692cc7609525a7b2c9562d608f25d51a") + set_mont_hex(&c.p, &c.gy, "6666666666666666666666666666666666666666666666666666666666666658") + exp_from_p(&c.p, &c.sqrt_e, 3, 3) + // sqrt(-1) = 2^((p-1)/4) + e = Fe {} + exp_from_p(&c.p, &e, 0, 2) + two = Fe {} + fe_from_int(&c.p, &two, 2) + fe_pow(&c.p, &c.sqrt_m1, &two, &e, c.p.bits) + } else if id == ED448 { + field_init(&c.p, "fffffffffffffffffffffffffffffffffffffffffffffffffffffffeffffffffffffffffffffffffffffffffffffffffffffffffffffffff") + field_init(&c.q, "3fffffffffffffffffffffffffffffffffffffffffffffffffffffff7cca23e9c44edb49aed63690216cc2728dc58f552378c292ab5844f3") + c.nb = 57 + c.a_neg = 0 + // d = -39081 + t = Fe {} + fe_from_int(&c.p, &t, 39081) + fe_neg(&c.p, &c.d, &t) + set_mont_hex(&c.p, &c.gx, "4f1970c66bed0ded221d15a622bf36da9e146570470f1767ea6de324a3d3a46412ae1af72ab66511433b80e18b00938e2626a82bc70cc05e") + set_mont_hex(&c.p, &c.gy, "693f46716eb6bc248876203756c9c7624bea73736ca3984087789c1e05a0c2d73ad3ff1ce67c39c4fdbd132c4ed7c8ad9808795bf230fa14") + exp_from_p(&c.p, &c.sqrt_e, 1, 2) + fe_zero(&c.sqrt_m1) + } else { + return 0 + } + return 1 +} + +fn ed_identity(c: *EdCurve, r: *Pt) { + fe_zero(&r.x) + fe_copy(&r.y, &c.p.one) + fe_copy(&r.z, &c.p.one) +} + +// r = p + q (add-2008-bbjlp, complete). r may alias p or q. +fn ed_add(c: *EdCurve, r: *Pt, p: *Pt, q: *Pt) { + F = &c.p + a = Fe {} + b = Fe {} + cc = Fe {} + dd = Fe {} + e = Fe {} + f = Fe {} + g = Fe {} + t = Fe {} + u = Fe {} + fe_mul(F, &a, &p.z, &q.z) // A = Z1 Z2 + fe_sqr(F, &b, &a) // B = A^2 + fe_mul(F, &cc, &p.x, &q.x) // C = X1 X2 + fe_mul(F, &dd, &p.y, &q.y) // D = Y1 Y2 + fe_mul(F, &e, &cc, &dd) + fe_mul(F, &e, &e, &c.d) // E = d C D + fe_sub(F, &f, &b, &e) // F = B - E + fe_add(F, &g, &b, &e) // G = B + E + // X3 = A F ((X1 + Y1)(X2 + Y2) - C - D) + fe_add(F, &t, &p.x, &p.y) + fe_add(F, &u, &q.x, &q.y) + fe_mul(F, &t, &t, &u) + fe_sub(F, &t, &t, &cc) + fe_sub(F, &t, &t, &dd) + fe_mul(F, &t, &t, &f) + x3 = Fe {} + fe_mul(F, &x3, &t, &a) + // Y3 = A G (D - a C) + if c.a_neg == 1 { + fe_add(F, &t, &dd, &cc) + } else { + fe_sub(F, &t, &dd, &cc) + } + fe_mul(F, &t, &t, &g) + y3 = Fe {} + fe_mul(F, &y3, &t, &a) + // Z3 = F G + fe_mul(F, &r.z, &f, &g) + fe_copy(&r.x, &x3) + fe_copy(&r.y, &y3) +} + +// r = 2p (dbl-2008-bbjlp). r may alias p. +fn ed_dbl(c: *EdCurve, r: *Pt, p: *Pt) { + F = &c.p + b = Fe {} + cc = Fe {} + dd = Fe {} + e = Fe {} + f = Fe {} + h = Fe {} + j = Fe {} + fe_add(F, &b, &p.x, &p.y) + fe_sqr(F, &b, &b) // B = (X1 + Y1)^2 + fe_sqr(F, &cc, &p.x) // C = X1^2 + fe_sqr(F, &dd, &p.y) // D = Y1^2 + if c.a_neg == 1 { + fe_neg(F, &e, &cc) // E = a C + } else { + fe_copy(&e, &cc) + } + fe_add(F, &f, &e, &dd) // F = E + D + fe_sqr(F, &h, &p.z) // H = Z1^2 + fe_add(F, &j, &h, &h) + fe_sub(F, &j, &f, &j) // J = F - 2H + // X3 = (B - C - D) J; Y3 = F (E - D); Z3 = F J + fe_sub(F, &b, &b, &cc) + fe_sub(F, &b, &b, &dd) + fe_mul(F, &r.x, &b, &j) + fe_sub(F, &e, &e, &dd) + fe_mul(F, &r.y, &f, &e) + fe_mul(F, &r.z, &f, &j) +} + +fn ed_cmov(c: *EdCurve, r: *Pt, a: *Pt, mask: uint32) { + fe_cmov(&c.p, &r.x, &a.x, mask) + fe_cmov(&c.p, &r.y, &a.y, mask) + fe_cmov(&c.p, &r.z, &a.z, mask) +} + +fn ed_table(c: *EdCurve, t: Pt[], p: *Pt) { + ed_identity(c, &t[0]) + pt_copy(&t[1], p) + int i = 2 + while i < 16 { + if (i & 1) == 0 { + ed_dbl(c, &t[i], &t[i / 2]) + } else { + ed_add(c, &t[i], &t[i - 1], p) + } + i = i + 1 + } +} + +// r = k * p, k the klen-byte LITTLE-endian scalar at kb: constant time in k +// (the windows of pt_mul_ct, read from the last byte down). +fn ed_mul_ct(c: *EdCurve, r: *Pt, p: *Pt, kb: ptr, klen: int) { + Pt[16] tbl + ed_table(c, tbl, p) + acc = Pt {} + ed_identity(c, &acc) + sel = Pt {} + int bi = klen - 1 + while bi >= 0 { + int kv = bytes.get(kb, bi) & 255 + int half = 0 + while half < 2 { + int digit = (kv >> (4 - 4 * half)) & 15 + ed_dbl(c, &acc, &acc) + ed_dbl(c, &acc, &acc) + ed_dbl(c, &acc, &acc) + ed_dbl(c, &acc, &acc) + pt_copy(&sel, &tbl[0]) + int j = 1 + while j < 16 { + uint32 mask = 0 - (ct.eq(j, digit) as uint32) + ed_cmov(c, &sel, &tbl[j], mask) + j = j + 1 + } + ed_add(c, &acc, &acc, &sel) + half = half + 1 + } + bi = bi - 1 + } + pt_copy(r, &acc) +} + +// r = u1 * p1 + u2 * p2 for PUBLIC little-endian scalars; not constant time. +fn ed_mul2_vt(c: *EdCurve, r: *Pt, u1: ptr, p1: *Pt, u2: ptr, p2: *Pt, klen: int) { + Pt[16] t1 + Pt[16] t2 + ed_table(c, t1, p1) + ed_table(c, t2, p2) + acc = Pt {} + ed_identity(c, &acc) + int started = 0 + int bi = klen - 1 + while bi >= 0 { + int a = bytes.get(u1, bi) & 255 + int b = bytes.get(u2, bi) & 255 + int half = 0 + while half < 2 { + int sh = 4 - 4 * half + int d1 = (a >> sh) & 15 + int d2 = (b >> sh) & 15 + if started == 1 { + ed_dbl(c, &acc, &acc) + ed_dbl(c, &acc, &acc) + ed_dbl(c, &acc, &acc) + ed_dbl(c, &acc, &acc) + } + if d1 != 0 { + ed_add(c, &acc, &acc, &t1[d1]) + started = 1 + } + if d2 != 0 { + ed_add(c, &acc, &acc, &t2[d2]) + started = 1 + } + half = half + 1 + } + bi = bi - 1 + } + pt_copy(r, &acc) +} + +// RFC 8032 encoding of p: y little-endian in nb bytes, x's parity in the +// top bit of the last byte. Constant time. +fn ed_encode(c: *EdCurve, p: *Pt) -> ptr { + F = &c.p + zi = Fe {} + fe_inv(F, &zi, &p.z) + x = Fe {} + y = Fe {} + fe_mul(F, &x, &p.x, &zi) + fe_mul(F, &y, &p.y, &zi) + fe_from_mont(F, &x, &x) + fe_from_mont(F, &y, &y) + int nb = c.nb + out = bytes.new(nb) + fe_to_le(&y, out, 0, nb) + int top = bytes.get(out, nb - 1) | (((x.v[0] & 1) as int) << 7) + bytes.set(out, nb - 1, top) + return out +} + +// Decode an encoded point (a PUBLIC value): 1 on success. y must be below p +// (with the sign bit cleared), x must exist, and x = 0 must not carry the +// sign bit (RFC 8032 5.1.3 and 5.2.3). +fn ed_decode(c: *EdCurve, r: *Pt, enc: ptr) -> int { + if enc == null { + return 0 + } + int nb = c.nb + if bytes.length(enc) != nb { + return 0 + } + F = &c.p + int sign = (bytes.get(enc, nb - 1) >> 7) & 1 + y = Fe {} + fe_from_le(&y, enc, 0, nb) + int top = 8 * nb - 1 + y.v[top / 32] = y.v[top / 32] & ~((1 as uint32) << (top % 32)) + int i = F.n + while i < NMAX { + if y.v[i] != 0 { + return 0 + } + i = i + 1 + } + if fe_lt(F, &y, &F.m) == 0 { + return 0 + } + ym = Fe {} + fe_to_mont(F, &ym, &y) + // x^2 = (y^2 - 1) / (d y^2 - a) + u = Fe {} + v = Fe {} + fe_sqr(F, &u, &ym) + fe_mul(F, &v, &u, &c.d) + fe_sub(F, &u, &u, &F.one) + if c.a_neg == 1 { + fe_add(F, &v, &v, &F.one) + } else { + fe_sub(F, &v, &v, &F.one) + } + w = Fe {} + fe_inv(F, &w, &v) + fe_mul(F, &w, &w, &u) + x = Fe {} + fe_pow(F, &x, &w, &c.sqrt_e, F.bits) + x2 = Fe {} + fe_sqr(F, &x2, &x) + if fe_eq(F, &x2, &w) == 0 { + if c.a_neg == 0 { + return 0 + } + nw = Fe {} + fe_neg(F, &nw, &w) + if fe_eq(F, &x2, &nw) == 0 { + return 0 + } + fe_mul(F, &x, &x, &c.sqrt_m1) + } + xn = Fe {} + fe_from_mont(F, &xn, &x) + if fe_is_zero(F, &xn) == 1 && sign == 1 { + return 0 + } + if ((xn.v[0] & 1) as int) != sign { + fe_neg(F, &x, &x) + } + fe_copy(&r.x, &x) + fe_copy(&r.y, &ym) + fe_copy(&r.z, &F.one) + return 1 +} + +ed_scalar_mult_base(curve: int, k: ptr) -> ptr { + c = EdCurve {} + if ed_init(&c, curve) == 0 || k == null || bytes.length(k) > c.nb { + return null + } + g = Pt {} + fe_copy(&g.x, &c.gx) + fe_copy(&g.y, &c.gy) + fe_copy(&g.z, &c.p.one) + q = Pt {} + ed_mul_ct(&c, &q, &g, k, bytes.length(k)) + return ed_encode(&c, &q) +} + +// h (little-endian, any length) mod L as nb little-endian bytes. +ed_reduce(curve: int, h: ptr) -> ptr { + c = EdCurve {} + if ed_init(&c, curve) == 0 || h == null { + return null + } + s = Fe {} + fe_reduce_le(&c.q, &s, h, 0, bytes.length(h)) + out = bytes.new(c.nb) + fe_to_le(&s, out, 0, c.nb) + return out +} + +// (r + k a) mod L as nb little-endian bytes; r and k below L, a any +// little-endian scalar (reduced here). Constant time. +ed_sign_s(curve: int, r: ptr, k: ptr, a: ptr) -> ptr { + c = EdCurve {} + if ed_init(&c, curve) == 0 || r == null || k == null || a == null { + return null + } + Q = &c.q + rr = Fe {} + kk = Fe {} + aa = Fe {} + fe_reduce_le(Q, &rr, r, 0, bytes.length(r)) + fe_reduce_le(Q, &kk, k, 0, bytes.length(k)) + fe_reduce_le(Q, &aa, a, 0, bytes.length(a)) + fe_to_mont(Q, &rr, &rr) + fe_to_mont(Q, &kk, &kk) + fe_to_mont(Q, &aa, &aa) + s = Fe {} + fe_mul(Q, &s, &kk, &aa) + fe_add(Q, &s, &s, &rr) + fe_from_mont(Q, &s, &s) + out = bytes.new(c.nb) + fe_to_le(&s, out, 0, c.nb) + fe_zero(&aa) + fe_zero(&rr) + return out +} + +// 1 if the encoding of [s]B - [k]A is r_enc (RFC 8032's check, without the +// cofactor), with pub = A's encoding and s below L. Public inputs only. +ed_verify(curve: int, pub: ptr, r_enc: ptr, s: ptr, k: ptr) -> int { + c = EdCurve {} + if ed_init(&c, curve) == 0 || r_enc == null || s == null || k == null { + return 0 + } + int nb = c.nb + if bytes.length(r_enc) != nb || bytes.length(s) != nb || bytes.length(k) != nb { + return 0 + } + sv = Fe {} + fe_from_le(&sv, s, 0, nb) + int i = c.q.n + while i < NMAX { + if sv.v[i] != 0 { + return 0 + } + i = i + 1 + } + if fe_lt(&c.q, &sv, &c.q.m) == 0 { + return 0 + } + a = Pt {} + if ed_decode(&c, &a, pub) == 0 { + return 0 + } + fe_neg(&c.p, &a.x, &a.x) + g = Pt {} + fe_copy(&g.x, &c.gx) + fe_copy(&g.y, &c.gy) + fe_copy(&g.z, &c.p.one) + q = Pt {} + ed_mul2_vt(&c, &q, s, &g, k, &a, nb) + enc = ed_encode(&c, &q) + int ok = 1 + i = 0 + while i < nb { + if bytes.get(enc, i) != bytes.get(r_enc, i) { + ok = 0 + } + i = i + 1 + } + bytes.wipe_free(enc) + return ok +} diff --git a/std/cryptography/ecc/test_ecc.ae b/std/cryptography/ecc/test_ecc.ae new file mode 100644 index 000000000..f8161bbba --- /dev/null +++ b/std/cryptography/ecc/test_ecc.ae @@ -0,0 +1,230 @@ +// std.cryptography.ecc: the field engine (Montgomery arithmetic, inversion, +// reduction of wide inputs), for each Weierstrass curve the generator +// multiples, ECDH agreement both ways and a sign/verify round trip, and the +// Edwards scalar arithmetic and encodings. + +import std.bytes +import std.string +import std.cryptography.ecc + +extern exit(code: int) +extern string_char_at_n(str: string, known_length: int, index: int) -> int + +fn hv(c: int) -> int { + if c >= 48 && c <= 57 { return c - 48 } + if c >= 97 && c <= 102 { return c - 87 } + if c >= 65 && c <= 70 { return c - 55 } + return 0 +} + +fn hb(h: string) -> ptr { + n = string.length(h) + b = bytes.new(n / 2) + i = 0 + while i < n { + bytes.set(b, i / 2, hv(string_char_at_n(h, n, i)) * 16 + hv(string_char_at_n(h, n, i + 1))) + i = i + 2 + } + return b +} + +fn bh(b: ptr) -> string { + if b == null { return "(null)" } + d = "0123456789abcdef" + o = "" + n = bytes.length(b) + i = 0 + while i < n { + v = bytes.get(b, i) + o = string.concat(o, string.concat(string.substring(d, (v / 16) & 15, ((v / 16) & 15) + 1), string.substring(d, v & 15, (v & 15) + 1))) + i = i + 1 + } + return o +} + +fn ck(cond: bool, msg: string) -> int { + if cond { + println("ok ${msg}") + return 0 + } + println("FAIL ${msg}") + return 1 +} + +// A buffer of n bytes, the scalar k in its last byte. +fn small(n: int, k: int) -> ptr { + b = bytes.new(n) + i = 0 + while i < n { + bytes.set(b, i, 0) + i = i + 1 + } + bytes.set(b, n - 1, k) + return b +} + +fn field_cases() -> int { + fails = 0 + F = ecc.Field {} + ecc.field_init(&F, "ffffffff00000001000000000000000000000000ffffffffffffffffffffffff") + fails = fails + ck(F.n == 8 && F.bits == 256, "P-256 field: 8 limbs, 256 bits") + a = ecc.Fe {} + b = ecc.Fe {} + t = ecc.Fe {} + u = ecc.Fe {} + ecc.fe_from_hex(&t, "6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296") + ecc.fe_to_mont(&F, &a, &t) + ecc.fe_from_int(&F, &b, 12345) + // a * a^-1 = 1 + ecc.fe_inv(&F, &u, &a) + ecc.fe_mul(&F, &u, &u, &a) + one = ecc.Fe {} + ecc.fe_one(&F, &one) + fails = fails + ck(ecc.fe_eq(&F, &u, &one) == 1, "a * a^-1 = 1") + // (a + b) - b = a; a + (-a) = 0 + ecc.fe_add(&F, &u, &a, &b) + ecc.fe_sub(&F, &u, &u, &b) + fails = fails + ck(ecc.fe_eq(&F, &u, &a) == 1, "(a + b) - b = a") + ecc.fe_neg(&F, &u, &a) + ecc.fe_add(&F, &u, &u, &a) + fails = fails + ck(ecc.fe_is_zero(&F, &u) == 1, "a + (-a) = 0") + // from_mont(to_mont(t)) = t + ecc.fe_from_mont(&F, &u, &a) + fails = fails + ck(ecc.fe_eq(&F, &u, &t) == 1, "Montgomery round trip") + // 0 has no inverse: inv(0) = 0 + z = ecc.Fe {} + ecc.fe_zero(&z) + ecc.fe_inv(&F, &u, &z) + fails = fails + ck(ecc.fe_is_zero(&F, &u) == 1, "inv(0) = 0") + // p + 5 (as 33 bytes) reduces to 5; 2^256 to 2^224 - 2^192 - 2^96 + 1. + w = hb("00ffffffff00000001000000000000000000000001000000000000000000000004") + ecc.fe_reduce_be(&F, &u, w, 0, 33) + ecc.fe_from_hex(&t, "05") + fails = fails + ck(ecc.fe_eq(&F, &u, &t) == 1, "reduce(p + 5) = 5") + w2 = hb("010000000000000000000000000000000000000000000000000000000000000000") + ecc.fe_reduce_be(&F, &u, w2, 0, 33) + ecc.fe_from_hex(&t, "00000000fffffffeffffffffffffffffffffffff000000000000000000000001") + fails = fails + ck(ecc.fe_eq(&F, &u, &t) == 1, "reduce(2^256) = 2^224 - 2^192 - 2^96 + 1") + // Little-endian round trip. + le = bytes.new(32) + ecc.fe_to_le(&t, le, 0, 32) + ecc.fe_from_le(&u, le, 0, 32) + fails = fails + ck(ecc.fe_eq(&F, &u, &t) == 1, "little-endian round trip") + bytes.free(w) + bytes.free(w2) + bytes.free(le) + return fails +} + +// G, 2G, k*G = scalar_mult(k, G), ECDH both ways, sign/verify, per curve. +fn curve_cases(name: string, curve: int, nb: int, gx: string, gy: string, g2x: string, g2y: string) -> int { + fails = 0 + one = small(nb, 1) + two = small(nb, 2) + x1, y1 = ecc.scalar_mult_base(curve, one) + fails = fails + ck(bh(x1) == gx && bh(y1) == gy, "${name} 1*G = G") + x2, y2 = ecc.scalar_mult_base(curve, two) + fails = fails + ck(bh(x2) == g2x && bh(y2) == g2y, "${name} 2*G") + // scalar_mult on G agrees with scalar_mult_base. + k = small(nb, 0) + i = 0 + while i < nb { + bytes.set(k, i, (i * 29 + 7) & 255) + i = i + 1 + } + bytes.set(k, 0, 0) + bytes.set(k, 1, 1) + kx, ky = ecc.scalar_mult_base(curve, k) + sx, sy = ecc.scalar_mult(curve, k, x1, y1) + fails = fails + ck(bh(kx) == bh(sx) && bh(ky) == bh(sy), "${name} scalar_mult(k, G) = k*G") + // ECDH: k * (2G) = 2 * (kG) + e1 = ecc.ecdh(curve, k, x2, y2) + e2 = ecc.ecdh(curve, two, kx, ky) + fails = fails + ck(e1 != null && bh(e1) == bh(e2), "${name} ecdh agrees both ways") + // Sign / verify / tamper. + h = small(32, 9) + r, s = ecc.ecdsa_sign(curve, k, h) + fails = fails + ck(ecc.ecdsa_verify(curve, kx, ky, h, r, s) == 1, "${name} sign/verify") + // Hedged (RFC 6979 3.6, #3376): fresh bytes give another nonce, and a + // signature that verifies; the same bytes give the same signature; + // none at all is RFC 6979 exactly. + z1 = small(32, 1) + z2 = small(32, 2) + hr1, hs1 = ecc.ecdsa_sign_hedged(curve, k, h, z1) + hr1b, hs1b = ecc.ecdsa_sign_hedged(curve, k, h, z1) + hr2, hs2 = ecc.ecdsa_sign_hedged(curve, k, h, z2) + hrn, hsn = ecc.ecdsa_sign_hedged(curve, k, h, null) + fails = fails + ck(ecc.ecdsa_verify(curve, kx, ky, h, hr1, hs1) == 1 && ecc.ecdsa_verify(curve, kx, ky, h, hr2, hs2) == 1, "${name} hedged signatures verify") + fails = fails + ck(bh(hr1) != bh(r) && bh(hr1) != bh(hr2), "${name} hedged nonces differ from RFC 6979's and from each other") + fails = fails + ck(bh(hr1) == bh(hr1b) && bh(hs1) == bh(hs1b), "${name} hedged with the same bytes: the same signature") + fails = fails + ck(bh(hrn) == bh(r) && bh(hsn) == bh(s), "${name} hedged with none: RFC 6979's signature") + bytes.free(z1); bytes.free(z2) + bytes.free(hr1); bytes.free(hs1); bytes.free(hr1b); bytes.free(hs1b) + bytes.free(hr2); bytes.free(hs2); bytes.free(hrn); bytes.free(hsn) + bytes.set(h, 0, 1) + fails = fails + ck(ecc.ecdsa_verify(curve, kx, ky, h, r, s) == 0, "${name} tampered digest rejected") + fails = fails + ck(ecc.public_key_valid(curve, kx, ky) == 1 && ecc.public_key_valid(curve, kx, kx) == 0, "${name} public_key_valid") + bytes.free(one); bytes.free(two); bytes.free(k); bytes.free(h) + bytes.free(x1); bytes.free(y1); bytes.free(x2); bytes.free(y2) + bytes.free(kx); bytes.free(ky); bytes.free(sx); bytes.free(sy) + bytes.free(e1); bytes.free(e2); bytes.free(r); bytes.free(s) + return fails +} + +// Edwards: scalars mod L, and the identity / base point encodings. +fn edwards_cases() -> int { + fails = 0 + // L + 5, little-endian, reduces to 5. + lp5 = hb("f2d3f55c1a631258d69cf7a2def9de1400000000000000000000000000000010") + r = ecc.ed_reduce(ecc.ED25519, lp5) + fails = fails + ck(bh(r) == "0500000000000000000000000000000000000000000000000000000000000000", "ed25519 reduce(L + 5) = 5") + // 0 * B is the identity, encoded as y = 1, x = 0. + z = small(32, 0) + id = ecc.ed_scalar_mult_base(ecc.ED25519, z) + fails = fails + ck(bh(id) == "0100000000000000000000000000000000000000000000000000000000000000", "ed25519 0*B is the identity") + // 1 * B: y = 4/5, x even. + one = bytes.new(32) + i = 0 + while i < 32 { + bytes.set(one, i, 0) + i = i + 1 + } + bytes.set(one, 0, 1) + b = ecc.ed_scalar_mult_base(ecc.ED25519, one) + fails = fails + ck(bh(b) == "5866666666666666666666666666666666666666666666666666666666666666", "ed25519 1*B encodes as RFC 8032's B") + // (r + k a) mod L with r = 1, k = 1, a = L - 1: 0. + lm1 = hb("ecd3f55c1a631258d69cf7a2def9de1400000000000000000000000000000010") + s = ecc.ed_sign_s(ecc.ED25519, one, one, lm1) + fails = fails + ck(bh(s) == "0000000000000000000000000000000000000000000000000000000000000000", "ed25519 1 + 1 * (L - 1) = 0 mod L") + bytes.free(lp5); bytes.free(r); bytes.free(z); bytes.free(id); bytes.free(one); bytes.free(b); bytes.free(lm1); bytes.free(s) + return fails +} +main() { + fails = field_cases() + fails = fails + edwards_cases() + fails = fails + curve_cases("P-256", ecc.P256, 32, + "6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296", + "4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5", + "7cf27b188d034f7e8a52380304b51ac3c08969e277f21b35a60b48fc47669978", + "07775510db8ed040293d9ac69f7430dbba7dade63ce982299e04b79d227873d1") + fails = fails + curve_cases("P-384", ecc.P384, 48, + "aa87ca22be8b05378eb1c71ef320ad746e1d3b628ba79b9859f741e082542a385502f25dbf55296c3a545e3872760ab7", + "3617de4a96262c6f5d9e98bf9292dc29f8f41dbd289a147ce9da3113b5f0b8c00a60b1ce1d7e819d7a431d7c90ea0e5f", + "08d999057ba3d2d969260045c55b97f089025959a6f434d651d207d19fb96e9e4fe0e86ebe0e64f85b96a9c75295df61", + "8e80f1fa5b1b3cedb7bfe8dffd6dba74b275d875bc6cc43e904e505f256ab4255ffd43e94d39e22d61501e700a940e80") + fails = fails + curve_cases("P-521", ecc.P521, 66, + "00c6858e06b70404e9cd9e3ecb662395b4429c648139053fb521f828af606b4d3dbaa14b5e77efe75928fe1dc127a2ffa8de3348b3c1856a429bf97e7e31c2e5bd66", + "011839296a789a3bc0045c8a5fb42c7d1bd998f54449579b446817afbd17273e662c97ee72995ef42640c550b9013fad0761353c7086a272c24088be94769fd16650", + "00433c219024277e7e682fcb288148c282747403279b1ccc06352c6e5505d769be97b3b204da6ef55507aa104a3a35c5af41cf2fa364d60fd967f43e3933ba6d783d", + "00f4bb8cc7f86db26700a7f3eceeeed3f0b5c6b5107c4da97740ab21a29906c42dbbb3e377de9f251f6b93937fa99a3248f4eafcbe95edc0f4f71be356d661f41b02") + fails = fails + curve_cases("secp256k1", ecc.SECP256K1, 32, + "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8", + "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5", + "1ae168fea63dc339a3c58419466ceaeef7f632653266d0e1236431a950cfe52a") + if fails > 0 { + println("${fails} FAILED") + exit(1) + } + println("ALL PASS") +} diff --git a/std/cryptography/ed25519/module.ae b/std/cryptography/ed25519/module.ae index d4c758b0f..a614d8f79 100644 --- a/std/cryptography/ed25519/module.ae +++ b/std/cryptography/ed25519/module.ae @@ -2,454 +2,142 @@ // Portions copyright (c) 2000-2026 The Legion of the Bouncy Castle Inc. (https://www.bouncycastle.org) // Portions copyright (c) 2026 Aether Developers. // -// std.cryptography.ed25519 — Ed25519 (RFC 8032) signatures in pure -// Aether, on top of std.bignum and std.cryptography.sha2 (SHA-512). NO -// externs to OpenSSL. +// std.cryptography.ed25519: Ed25519 (RFC 8032) signatures in pure Aether, +// constant time, on std.cryptography.ecc and std.cryptography.sha2 +// (SHA-512). NO externs to OpenSSL. // // Ed25519 is EdDSA over the twisted Edwards curve // -x^2 + y^2 = 1 + d x^2 y^2 over GF(2^255 - 19), d = -121665/121666, // group order L = 2^252 + 27742317777372353535851937790883648493. // // publickey(seed32) -> public32 -// sign(seed32, msg) -> sig64 +// sign(seed32, msg) -> sig64 +// sign_hedged(seed32, msg, extra) -> sig64 (fresh bytes mixed into r) // verify(public32, msg, sig64) -> int (1 = valid, 0 = invalid) // // 32-byte keys, 64-byte signatures, all little-endian per RFC 8032. -// Point arithmetic uses extended homogeneous coordinates (X:Y:Z:T). // -// NOTE: correctness-first port over the variable-time std.bignum — not -// constant-time/side-channel-hardened. Validated against RFC 8032 §7.1. +// publickey and sign are constant time in the seed, the secret scalar and +// the per-message nonce r: the scalar multiplication by the base point is a +// fixed 4-bit window over complete Edwards formulas that reads every table +// entry, and S = r + k a mod L is computed on fixed-width limbs +// (std.cryptography.ecc). verify works on public values and is not constant +// time. It checks [S]B = R + [k]A without the cofactor, and refuses S >= L, +// a public key that is not a canonical point encoding, and a non-canonical +// R (no point encodes to it, so the comparison fails). +// +// Measured with the default `ae build` on an x86-64 Windows desktop, per +// operation: sign 665 us, verify 410 us (7850 and 8350 us on std.bignum). import std.bytes -import std.bignum +import std.cryptography.ecc import std.cryptography.sha2 exports( - publickey, sign, verify + publickey, sign, sign_hedged, verify ) -// ---- constants ---- -p_bytes_be() -> ptr { - b = bytes.new(32) - i = 0 - while i < 32 { bytes.set(b, i, 0xFF); i = i + 1 } - bytes.set(b, 0, 0x7F) - bytes.set(b, 31, 0xED) - return b -} -field_p() -> ptr { - pb = p_bytes_be() - p = bignum.from_bytes_unsigned(pb, 32) - bytes.free(pb) - return p -} -// L = 2^252 + 27742317777372353535851937790883648493 -// hex BE: 1000000000000000000000000000000014def9dea2f79cd65812631a5cf5d3ed -order_L() -> ptr { - h = "1000000000000000000000000000000014def9dea2f79cd65812631a5cf5d3ed" - return bn_from_hex(h) -} -// d = -121665 * inv(121666) mod p -curve_d(p: ptr) -> ptr { - n121665 = bignum.from_int(121665) - n121666 = bignum.from_int(121666) - neg = bignum.subtract(p, n121665) // -121665 mod p = p - 121665 - inv = bignum.mod_inverse(n121666, p) - prod = bignum.multiply(neg, inv) - d = bignum.mod(prod, p) - bignum.free(n121665); bignum.free(n121666); bignum.free(neg) - bignum.free(inv); bignum.free(prod) - return d -} - -// 2*d mod p (frees the temporary 2). -compute_d2(d: ptr, p: ptr) -> ptr { - two = bignum.from_int(2) - r = fmul(d, two, p) - bignum.free(two) - return r -} - -bn_from_hex(h: string) -> ptr { - // hex string -> big-endian bytes -> *BN - n = len_of(h) - b = bytes.new(n / 2) - i = 0 - while i < n { - hi = hexv(char_at(h, i)) - lo = hexv(char_at(h, i + 1)) - bytes.set(b, i / 2, hi * 16 + lo) - i = i + 2 - } - r = bignum.from_bytes_unsigned(b, n / 2) - bytes.free(b) - return r -} -extern string_char_at_n(str: string, known_length: int, index: int) -> int -extern strlen(s: string) -> int -len_of(s: string) -> int { return strlen(s) } -char_at(s: string, i: int) -> int { return string_char_at_n(s, strlen(s), i) } -hexv(c: int) -> int { - if c >= 48 { if c <= 57 { return c - 48 } } - if c >= 97 { if c <= 102 { return c - 87 } } - if c >= 65 { if c <= 70 { return c - 55 } } - return 0 -} - -// ---- field ops mod p ---- -fadd(a: ptr, b: ptr, p: ptr) -> ptr { s = bignum.add(a, b); r = bignum.mod(s, p); bignum.free(s); return r } -fsub(a: ptr, b: ptr, p: ptr) -> ptr { d = bignum.subtract(a, b); r = bignum.mod(d, p); bignum.free(d); return r } -fmul(a: ptr, b: ptr, p: ptr) -> ptr { m = bignum.multiply(a, b); r = bignum.mod(m, p); bignum.free(m); return r } -finv(a: ptr, p: ptr) -> ptr { - two = bignum.from_int(2) - e = bignum.subtract(p, two) // p - 2 - r = bignum.mod_pow(a, e, p) - bignum.free(two); bignum.free(e) - return r -} - -// ---- little-endian <-> *BN (32 bytes) ---- -from_le(b: byte[]) -> ptr { - if b.len < 0 { panic("ed25519.from_le: the `b` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } - int n = b.len - be = bytes.new(n) - i = 0 - while i < n { bytes.set(be, i, b[n - 1 - i]); i = i + 1 } - r = bignum.from_bytes_unsigned(be, n) - bytes.free(be) - return r -} -to_le32(nn: ptr) -> ptr { - be = bignum.to_bytes_unsigned(nn) - blen = bytes.length(be) - out = bytes.new(32) - i = 0 - while i < 32 { bytes.set(out, i, 0); i = i + 1 } - i = 0 - while i < blen { if i < 32 { bytes.set(out, i, bytes.get(be, blen - 1 - i)) } i = i + 1 } - bytes.free(be) - return out -} - -// ---- extended-coordinate point (X:Y:Z:T), each a *BN ---- -struct EdPt { x: ptr y: ptr z: ptr t: ptr } -extern malloc(size: int) -> ptr -@extern("free") libc_free(p: ptr) - -pt_new(x: @keep ptr, y: @keep ptr, z: @keep ptr, t: @keep ptr) -> *EdPt { - q = malloc(sizeof(EdPt)) as *EdPt - q.x = x; q.y = y; q.z = z; q.t = t - return q -} -pt_free(q: @frees *EdPt) { - bignum.free(q.x); bignum.free(q.y); bignum.free(q.z); bignum.free(q.t) - libc_free(q) -} - -// Neutral element (0, 1, 1, 0). -pt_identity() -> *EdPt { - return pt_new(bignum.from_int(0), bignum.from_int(1), bignum.from_int(1), bignum.from_int(0)) -} - -// Point addition in extended coords (RFC 8032 / Hisil-Wong-Carter-Dawson), -// using the precomputed d2 = 2*d. -pt_add(a: *EdPt, b: *EdPt, p: ptr, d2: ptr) -> *EdPt { - // A = (Y1-X1)*(Y2-X2) - ym1 = fsub(a.y, a.x, p); ym2 = fsub(b.y, b.x, p) - A = fmul(ym1, ym2, p) - // B = (Y1+X1)*(Y2+X2) - yp1 = fadd(a.y, a.x, p); yp2 = fadd(b.y, b.x, p) - B = fmul(yp1, yp2, p) - // C = T1*d2*T2 - t1d = fmul(a.t, d2, p); C = fmul(t1d, b.t, p) - // D = Z1*2*Z2 - z1z2 = fmul(a.z, b.z, p); two = bignum.from_int(2); D = fmul(z1z2, two, p) - // E=B-A, F=D-C, G=D+C, H=B+A - E = fsub(B, A, p); F = fsub(D, C, p); G = fadd(D, C, p); H = fadd(B, A, p) - // X3=E*F, Y3=G*H, T3=E*H, Z3=F*G - X3 = fmul(E, F, p); Y3 = fmul(G, H, p); T3 = fmul(E, H, p); Z3 = fmul(F, G, p) - - bignum.free(ym1); bignum.free(ym2); bignum.free(yp1); bignum.free(yp2) - bignum.free(A); bignum.free(B); bignum.free(C); bignum.free(D) - bignum.free(t1d); bignum.free(z1z2); bignum.free(two) - bignum.free(E); bignum.free(F); bignum.free(G); bignum.free(H) - return pt_new(X3, Y3, Z3, T3) -} - -pt_clone(a: *EdPt) -> *EdPt { - return pt_new(bignum.clone(a.x), bignum.clone(a.y), bignum.clone(a.z), bignum.clone(a.t)) -} - -// Scalar mult: e * P, double-and-add over the bits of e (a *BN). -pt_mul(e: ptr, P: *EdPt, p: ptr, d2: ptr) -> *EdPt { - q = pt_identity() - nbits = bignum.bit_length(e) - // iterate from MSB to LSB - i = nbits - 1 - while i >= 0 { - dbl = pt_add(q, q, p, d2) - pt_free(q) - q = dbl - ei = bignum.shift_right(e, i) - if bit0(ei) == 1 { - sum = pt_add(q, P, p, d2) - pt_free(q) - q = sum - } - bignum.free(ei) - i = i - 1 - } - return q -} - -bit0(n: ptr) -> int { - if bignum.is_zero(n) == 1 { return 0 } - two = bignum.from_int(2) - r = bignum.mod(n, two) - b = 0 - if bignum.is_zero(r) != 1 { b = 1 } - bignum.free(two); bignum.free(r) - return b -} - -// The base point B (from RFC 8032): y = 4/5, x recovered (even). -base_point(p: ptr, d: ptr) -> *EdPt { - four = bignum.from_int(4); five = bignum.from_int(5) - inv5 = bignum.mod_inverse(five, p) - y = fmul(four, inv5, p) - x = recover_x(y, 0, p, d) - z = bignum.from_int(1) - t = fmul(x, y, p) - bignum.free(four); bignum.free(five); bignum.free(inv5) - return pt_new(x, y, z, t) -} - -// Recover x from y and the sign bit (x is even when sign==0). Curve eqn: -// x^2 = (y^2 - 1) / (d y^2 + 1) mod p. p ≡ 5 (mod 8), so the candidate root -// is u*v^3 * (u v^7)^((p-5)/8); fix up by the sqrt(-1) factor if needed. -recover_x(y: ptr, sign: int, p: ptr, d: ptr) -> ptr { - one = bignum.from_int(1) - y2 = fmul(y, y, p) - u = fsub(y2, one, p) // y^2 - 1 - dy2 = fmul(d, y2, p) - v = fadd(dy2, one, p) // d y^2 + 1 - - // x = u * v^3 * (u v^7)^((p-5)/8) - v2 = fmul(v, v, p); v3 = fmul(v2, v, p) - v4 = fmul(v3, v, p) // v^4 = v^3 * v - v7 = fmul(v3, v4, p) // v^7 = v^3 * v^4 - bignum.free(v4) - uv7 = fmul(u, v7, p) - exp = exp_p_minus_5_over_8(p) - pw = bignum.mod_pow(uv7, exp, p) - uv3 = fmul(u, v3, p) - x = fmul(uv3, pw, p) - - // Check v*x^2 == u (mod p). If v*x^2 == -u, multiply x by sqrt(-1). - x2 = fmul(x, x, p); vx2 = fmul(v, x2, p) - chk = fsub(vx2, u, p) // should be 0 - ok = 1 - if bignum.is_zero(chk) != 1 { - // try x * I - I = sqrt_m1(p) - nx = fmul(x, I, p) - bignum.free(x); x = nx - bignum.free(I) - // RFC 8032 §5.1.3 step 3: re-check; if v*x^2 is STILL not u, no - // square root exists and decoding MUST fail (previously this fell - // through with a garbage x, surviving only because the verify - // equation then failed downstream). - bignum.free(x2); bignum.free(vx2); bignum.free(chk) - x2 = fmul(x, x, p); vx2 = fmul(v, x2, p) - chk = fsub(vx2, u, p) - if bignum.is_zero(chk) != 1 { ok = 0 } - } - bignum.free(chk); bignum.free(vx2); bignum.free(x2) - - // RFC 8032 §5.1.3 step 4: if x = 0 and the sign bit is set, decoding - // fails — the only points with x = 0 (y = ±1) have no "negative x" - // form. Skipping this check accepts non-canonical encodings; caught - // by Wycheproof ed25519 tcId 151 (R = y=1 with sign bit set). - if ok == 1 && bignum.is_zero(x) == 1 && sign == 1 { ok = 0 } - - // Adjust sign: x's low bit must equal `sign`. - if ok == 1 && bit0(x) != sign { - nx = fsub(p, x, p) // p - x (i.e. -x mod p) - bignum.free(x); x = nx - } - - bignum.free(one); bignum.free(y2); bignum.free(u); bignum.free(dy2); bignum.free(v) - bignum.free(v2); bignum.free(v3); bignum.free(v7); bignum.free(uv7); bignum.free(exp) - bignum.free(pw); bignum.free(uv3) - if ok != 1 { - bignum.free(x) - return null - } - return x -} - -// (p - 5) / 8 -exp_p_minus_5_over_8(p: ptr) -> ptr { - five = bignum.from_int(5) - pm5 = bignum.subtract(p, five) - eight = bignum.from_int(8) - r = bignum.divide(pm5, eight) - bignum.free(five); bignum.free(pm5); bignum.free(eight) - return r -} -// sqrt(-1) mod p = 2^((p-1)/4) -sqrt_m1(p: ptr) -> ptr { - one = bignum.from_int(1); two = bignum.from_int(2); four = bignum.from_int(4) - pm1 = bignum.subtract(p, one) - e = bignum.divide(pm1, four) - r = bignum.mod_pow(two, e, p) - bignum.free(one); bignum.free(two); bignum.free(four); bignum.free(pm1); bignum.free(e) - return r -} - -// Encode a point to 32 bytes: little-endian y with the low bit of x in the -// top bit of the last byte. -pt_encode(q: *EdPt, p: ptr) -> ptr { - zinv = finv(q.z, p) - x = fmul(q.x, zinv, p) - y = fmul(q.y, zinv, p) - out = to_le32(y) - if bit0(x) == 1 { - cur = bytes.get(out, 31) - last = cur | 0x80 - bytes.set(out, 31, last) - } - bignum.free(zinv); bignum.free(x); bignum.free(y) - return out -} - -// Decode 32 bytes to a point; returns null on an invalid encoding. -pt_decode(b: ptr, p: ptr, d: ptr) -> *EdPt { - tmp = bytes.new(32) - bytes.copy_from_bytes(tmp, 0, b, 0, 32) - sign = (bytes.get(tmp, 31) >> 7) & 1 - bytes.set(tmp, 31, bytes.get(tmp, 31) & 0x7F) - y = from_le(bytes.view(tmp)[0..32]) - bytes.free(tmp) - // y must be < p - if bignum.compare(y, p) >= 0 { - bignum.free(y) - return null as *EdPt - } - x = recover_x(y, sign, p, d) - if x == null { - // Invalid encoding (no square root, or x=0 with sign bit set — - // RFC 8032 §5.1.3 steps 3-4). - bignum.free(y) - return null as *EdPt - } - z = bignum.from_int(1) - t = fmul(x, y, p) - return pt_new(x, y, z, t) -} - -// ---- hashing helpers ---- -// SHA-512 of a bytes buffer -> 64-byte bytes. -sha512(b: byte[]) -> ptr { +// SHA-512 of a byte slice -> 64-byte std.bytes, or null. +fn sha512(b: byte[]) -> ptr { ctx, err = sha2.new("sha512") - // sha2.new returns (null, "allocation failed"). Dropping the error here - // fed a null context to update_bytes, which is the one input it cannot - // take; the rest of the digest wrappers in this family check (#1947). if err != "" { return null } sha2.update(ctx, b) return sha2.final_bytes(ctx) } -// reduce a little-endian byte buffer mod L -> *BN -reduce_mod_L(b: ptr, len: int) -> ptr { - n = from_le(bytes.view(b)[0..len]) - L = order_L() - r = bignum.mod(n, L) - bignum.free(n); bignum.free(L) - return r -} -// ---- public API ---- - -// Derive the 32-byte public key from a 32-byte seed. -publickey(seed: ptr) -> ptr { - p = field_p(); d = curve_d(p) - d2 = compute_d2(d, p) - B = base_point(p, d) +fn wipe_free(b: @frees ptr) { + bytes.wipe_free(b) +} +// The secret scalar a (clamped SHA-512(seed)[0..32]) into a_le, and the +// prefix SHA-512(seed)[32..64] into prefix. Both 32-byte buffers. +fn expand_seed(seed: ptr, a_le: ptr, prefix: ptr) { h = sha512(bytes.view(seed)[0..32]) - // a = clamp(h[0..32]) as scalar - a_le = bytes.new(32) bytes.copy_from_bytes(a_le, 0, h, 0, 32) bytes.set(a_le, 0, bytes.get(a_le, 0) & 248) bytes.set(a_le, 31, (bytes.get(a_le, 31) & 63) | 64) - a = from_le(bytes.view(a_le)[0..32]) - - A = pt_mul(a, B, p, d2) - pub = pt_encode(A, p) + bytes.copy_from_bytes(prefix, 0, h, 32, 32) + wipe_free(h) +} - bytes.free(h); bytes.free(a_le); bignum.free(a) - pt_free(A); pt_free(B); bignum.free(p); bignum.free(d); bignum.free(d2) +// Derive the 32-byte public key from a 32-byte seed. +publickey(seed: ptr) -> ptr { + a_le = bytes.new(32) + prefix = bytes.new(32) + expand_seed(seed, a_le, prefix) + pub = ecc.ed_scalar_mult_base(ecc.ED25519, a_le) + wipe_free(a_le) + wipe_free(prefix) return pub } // Sign msg with the 32-byte seed; returns a 64-byte signature. sign(seed: ptr, msg: byte[]) -> ptr { if msg.len < 0 { panic("ed25519.sign: the `msg` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } - int msg_len = msg.len - p = field_p(); d = curve_d(p) - d2 = compute_d2(d, p) - B = base_point(p, d) - L = order_L() - - h = sha512(bytes.view(seed)[0..32]) - a_le = bytes.new(32) - bytes.copy_from_bytes(a_le, 0, h, 0, 32) - bytes.set(a_le, 0, bytes.get(a_le, 0) & 248) - bytes.set(a_le, 31, (bytes.get(a_le, 31) & 63) | 64) - a = from_le(bytes.view(a_le)[0..32]) + return sign_with(seed, msg, null) +} - // prefix = h[32..64]; A = a*B - A = pt_mul(a, B, p, d2) - Aenc = pt_encode(A, p) +// The same with a hedged nonce (#3376): `extra`, fresh random bytes (32 is +// plenty), is mixed into r, so no two signings of one message share it. A +// fault in one of two signatures of one message under RFC 8032's +// deterministic r gives the key away. r = SHA-512(Z || 0..0 || prefix || M) +// mod L, Z being `extra` zero-padded to a SHA-512 block (128 bytes), so the +// secret prefix starts a block whose input state is random. Verification is +// unchanged, so a verifier cannot tell; whatever `extra` holds, r is no +// weaker than RFC 8032's, the secret prefix still being hashed in. Null or +// empty: RFC 8032 exactly. At most 128 bytes are used. +sign_hedged(seed: ptr, msg: byte[], extra: ptr) -> ptr { + if msg.len < 0 { panic("ed25519.sign_hedged: the `msg` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } + return sign_with(seed, msg, extra) +} - // r = SHA512(prefix || msg) mod L - rin_len = 32 + msg_len +fn sign_with(seed: ptr, msg: byte[], extra: ptr) -> ptr { + int msg_len = msg.len + a_le = bytes.new(32) + prefix = bytes.new(32) + expand_seed(seed, a_le, prefix) + aenc = ecc.ed_scalar_mult_base(ecc.ED25519, a_le) + + // r = SHA-512([Z || pad ||] prefix || M) mod L; R = r B + int zl = 0 + if extra != null { + zl = bytes.length(extra) + } + if zl > 128 { zl = 128 } + int zpad = 0 + if zl > 0 { zpad = 128 } + rin_len = zpad + 32 + msg_len rin = bytes.new(rin_len) - bytes.copy_from_bytes(rin, 0, h, 32, 32) - if msg_len > 0 { bytes.copy_from_slice(rin, 32, msg[0..msg_len]) } + if zpad > 0 { + bytes.set(rin, zpad - 1, 0) + bytes.copy_from_bytes(rin, 0, extra, 0, zl) + } + bytes.copy_from_bytes(rin, zpad, prefix, 0, 32) + if msg_len > 0 { bytes.copy_from_slice(rin, zpad + 32, msg[0..msg_len]) } rh = sha512(bytes.view(rin)[0..rin_len]) - r = reduce_mod_L(rh, 64) - - // R = r*B - R = pt_mul(r, B, p, d2) - Renc = pt_encode(R, p) + r = ecc.ed_reduce(ecc.ED25519, rh) + renc = ecc.ed_scalar_mult_base(ecc.ED25519, r) - // k = SHA512(Renc || Aenc || msg) mod L + // k = SHA-512(R || A || M) mod L kin_len = 64 + msg_len kin = bytes.new(kin_len) - bytes.copy_from_bytes(kin, 0, Renc, 0, 32) - bytes.copy_from_bytes(kin, 32, Aenc, 0, 32) + bytes.copy_from_bytes(kin, 0, renc, 0, 32) + bytes.copy_from_bytes(kin, 32, aenc, 0, 32) if msg_len > 0 { bytes.copy_from_slice(kin, 64, msg[0..msg_len]) } kh = sha512(bytes.view(kin)[0..kin_len]) - k = reduce_mod_L(kh, 64) + k = ecc.ed_reduce(ecc.ED25519, kh) - // S = (r + k*a) mod L - ka = bignum.multiply(k, a) - rka = bignum.add(r, ka) - S = bignum.mod(rka, L) - Senc = to_le32(S) + // S = (r + k a) mod L + s = ecc.ed_sign_s(ecc.ED25519, r, k, a_le) sig = bytes.new(64) - bytes.copy_from_bytes(sig, 0, Renc, 0, 32) - bytes.copy_from_bytes(sig, 32, Senc, 0, 32) + bytes.copy_from_bytes(sig, 0, renc, 0, 32) + bytes.copy_from_bytes(sig, 32, s, 0, 32) - bytes.free(h); bytes.free(a_le); bytes.free(Aenc); bytes.free(rin); bytes.free(rh) - bytes.free(Renc); bytes.free(kin); bytes.free(kh); bytes.free(Senc) - bignum.free(a); bignum.free(r); bignum.free(k); bignum.free(ka); bignum.free(rka); bignum.free(S) - pt_free(A); pt_free(R); pt_free(B) - bignum.free(p); bignum.free(d); bignum.free(d2); bignum.free(L) + wipe_free(a_le); wipe_free(prefix); wipe_free(rin); wipe_free(rh); wipe_free(r) + bytes.wipe_free(aenc); bytes.wipe_free(renc); bytes.wipe_free(kin); bytes.wipe_free(kh); bytes.wipe_free(k); bytes.wipe_free(s) return sig } @@ -458,65 +146,23 @@ sign(seed: ptr, msg: byte[]) -> ptr { verify(public: ptr, msg: byte[], sig: byte[]) -> int { if msg.len < 0 { panic("ed25519.verify: the `msg` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } if sig.len < 0 { panic("ed25519.verify: the `sig` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } - if sig.len != 64 { return 0 } + if sig.len != 64 || public == null || bytes.length(public) != 32 { return 0 } int msg_len = msg.len - p = field_p(); d = curve_d(p) - d2 = compute_d2(d, p) - B = base_point(p, d) - L = order_L() - - Renc = bytes.new(32); bytes.copy_from_slice(Renc, 0, sig[0..32]) - Senc = bytes.new(32); bytes.copy_from_slice(Senc, 0, sig[32..64]) - S = from_le(bytes.view(Senc)[0..32]) - - ok = 1 - // S must be < L - if bignum.compare(S, L) >= 0 { ok = 0 } - - A = pt_decode(public, p, d) - R = pt_decode(Renc, p, d) - if A == null as *EdPt { ok = 0 } - if R == null as *EdPt { ok = 0 } + renc = bytes.new(32) + bytes.copy_from_slice(renc, 0, sig[0..32]) + senc = bytes.new(32) + bytes.copy_from_slice(senc, 0, sig[32..64]) - result = 0 - if ok == 1 { - // k = SHA512(Renc || public || msg) mod L - kin_len = 64 + msg_len - kin = bytes.new(kin_len) - bytes.copy_from_bytes(kin, 0, Renc, 0, 32) - bytes.copy_from_bytes(kin, 32, public, 0, 32) - if msg_len > 0 { bytes.copy_from_slice(kin, 64, msg[0..msg_len]) } - kh = sha512(bytes.view(kin)[0..kin_len]) - k = reduce_mod_L(kh, 64) - - // check: S*B == R + k*A - sB = pt_mul(S, B, p, d2) - kA = pt_mul(k, A, p, d2) - rhs = pt_add(R, kA, p, d2) - - lenc = pt_encode(sB, p) - renc = pt_encode(rhs, p) - if bytes_eq(lenc, renc) == 1 { result = 1 } - - bytes.free(kin); bytes.free(kh); bignum.free(k) - pt_free(sB); pt_free(kA); pt_free(rhs) - bytes.free(lenc); bytes.free(renc) - } - - bytes.free(Renc); bytes.free(Senc); bignum.free(S) - if A != null as *EdPt { pt_free(A) } - if R != null as *EdPt { pt_free(R) } - bignum.free(p); bignum.free(d); bignum.free(d2); bignum.free(L) - pt_free(B) - return result -} + // k = SHA-512(R || A || M) mod L + kin_len = 64 + msg_len + kin = bytes.new(kin_len) + bytes.copy_from_bytes(kin, 0, renc, 0, 32) + bytes.copy_from_bytes(kin, 32, public, 0, 32) + if msg_len > 0 { bytes.copy_from_slice(kin, 64, msg[0..msg_len]) } + kh = sha512(bytes.view(kin)[0..kin_len]) + k = ecc.ed_reduce(ecc.ED25519, kh) -bytes_eq(a: ptr, b: ptr) -> int { - if bytes.length(a) != bytes.length(b) { return 0 } - i = 0 - while i < bytes.length(a) { - if bytes.get(a, i) != bytes.get(b, i) { return 0 } - i = i + 1 - } - return 1 + ok = ecc.ed_verify(ecc.ED25519, public, renc, senc, k) + bytes.wipe_free(renc); bytes.wipe_free(senc); bytes.wipe_free(kin); bytes.wipe_free(kh); bytes.wipe_free(k) + return ok } diff --git a/std/cryptography/ed448/module.ae b/std/cryptography/ed448/module.ae index 30b6da710..de96ef8a1 100644 --- a/std/cryptography/ed448/module.ae +++ b/std/cryptography/ed448/module.ae @@ -2,357 +2,59 @@ // Portions copyright (c) 2000-2026 The Legion of the Bouncy Castle Inc. (https://www.bouncycastle.org) // Portions copyright (c) 2026 Aether Developers. // -// std.cryptography.ed448 — Ed448 (RFC 8032) signatures in pure Aether, -// on top of std.bignum and std.cryptography.sha3 (SHAKE256). NO externs to -// OpenSSL. +// std.cryptography.ed448: Ed448 (RFC 8032) signatures in pure Aether, +// constant time, on std.cryptography.ecc and std.cryptography.sha3 +// (SHAKE256). NO externs to OpenSSL. // // Ed448 is EdDSA over the UNTWISTED Edwards curve // x^2 + y^2 = 1 + d x^2 y^2 over GF(2^448 - 2^224 - 1), d = -39081, -// cofactor 4, group order L = 2^446 - 138180668... (see order_L below). +// cofactor 4, group order +// L = 2^446 - 13818066809895115352007386748515426880336692474882178609894547503885. // // publickey(seed57) -> public57 -// sign(seed57, msg) -> sig114 +// sign(seed57, msg) -> sig114 // verify(public57, msg, sig114) -> int (1 = valid, 0 = invalid) // // 57-byte keys, 114-byte signatures, all little-endian per RFC 8032. The hash // is SHAKE256 with a 114-byte (= 2b/8) output, prefixed with dom4 = // "SigEd448" || phflag(0) || octet(ctxlen=0) for the pure (non-prehash, -// empty-context) variant. Point arithmetic uses projective coordinates -// (X:Y:Z) on the untwisted Edwards curve. +// empty-context) variant. // -// NOTE: correctness-first port over the variable-time std.bignum — not -// constant-time/side-channel-hardened. Validated against the RFC 8032 §7.4 -// "Blank" test vector. +// publickey and sign are constant time in the seed, the secret scalar and +// the nonce r (a fixed-window scalar multiplication over complete Edwards +// formulas on std.cryptography.ecc's fixed-width field, and S = r + k s mod L +// on fixed-width limbs). verify works on public values; it checks +// [S]B = R + [k]A without the cofactor and refuses S >= L and a public key +// that is not a canonical point encoding. +// +// Measured with the default `ae build` on an x86-64 Windows desktop, per +// operation: sign 2.3 ms, verify 1.6 ms (23.2 and 25.5 ms on std.bignum). import std.bytes -import std.bignum +import std.cryptography.ecc import std.cryptography.sha3 exports( publickey, sign, verify ) -extern malloc(size: int) -> ptr -@extern("free") libc_free(p: ptr) -extern string_char_at_n(str: string, known_length: int, index: int) -> int -extern strlen(s: string) -> int - -// ---- constants ---- -// p = 2^448 - 2^224 - 1 (big-endian: byte[27] = 0xFE, all others 0xFF) -field_p() -> ptr { - b = bytes.new(56) - i = 0 - while i < 56 { bytes.set(b, i, 0xFF); i = i + 1 } - bytes.set(b, 27, 0xFE) - p = bignum.from_bytes_unsigned(b, 56) - bytes.free(b) - return p -} -// L = 2^446 - 13818066809895115352007386748515426880336692474882178609894547503885 -order_L() -> ptr { - h = "3fffffffffffffffffffffffffffffffffffffffffffffffffffffff7cca23e9c44edb49aed63690216cc2728dc58f552378c292ab5844f3" - return bn_from_hex(h) -} -// d = -39081 mod p -curve_d(p: ptr) -> ptr { - n39081 = bignum.from_int(39081) - d = bignum.subtract(p, n39081) // p - 39081 = -39081 mod p - bignum.free(n39081) - return d -} - -bn_from_hex(h: string) -> ptr { - n = strlen(h) - b = bytes.new(n / 2) - i = 0 - while i < n { - hi = hexv(string_char_at_n(h, n, i)) - lo = hexv(string_char_at_n(h, n, i + 1)) - bytes.set(b, i / 2, hi * 16 + lo) - i = i + 2 - } - r = bignum.from_bytes_unsigned(b, n / 2) - bytes.free(b) - return r -} -hexv(c: int) -> int { - if c >= 48 { if c <= 57 { return c - 48 } } - if c >= 97 { if c <= 102 { return c - 87 } } - if c >= 65 { if c <= 70 { return c - 55 } } - return 0 -} - -// ---- field ops mod p ---- -fadd(a: ptr, b: ptr, p: ptr) -> ptr { s = bignum.add(a, b); r = bignum.mod(s, p); bignum.free(s); return r } -fsub(a: ptr, b: ptr, p: ptr) -> ptr { d = bignum.subtract(a, b); r = bignum.mod(d, p); bignum.free(d); return r } -fmul(a: ptr, b: ptr, p: ptr) -> ptr { m = bignum.multiply(a, b); r = bignum.mod(m, p); bignum.free(m); return r } -finv(a: ptr, p: ptr) -> ptr { - two = bignum.from_int(2) - e = bignum.subtract(p, two) // p - 2 - r = bignum.mod_pow(a, e, p) - bignum.free(two); bignum.free(e) - return r -} - -// ---- little-endian <-> *BN ---- -from_le(b: byte[]) -> ptr { - if b.len < 0 { panic("ed448.from_le: the `b` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } - int n = b.len - be = bytes.new(n) - i = 0 - while i < n { bytes.set(be, i, b[n - 1 - i]); i = i + 1 } - r = bignum.from_bytes_unsigned(be, n) - bytes.free(be) - return r -} -// Encode a *BN as a 56-byte little-endian buffer (the y-coordinate body). -to_le56(nn: ptr) -> ptr { - be = bignum.to_bytes_unsigned(nn) - blen = bytes.length(be) - out = bytes.new(56) - i = 0 - while i < 56 { bytes.set(out, i, 0); i = i + 1 } - i = 0 - while i < blen { if i < 56 { bytes.set(out, i, bytes.get(be, blen - 1 - i)) } i = i + 1 } - bytes.free(be) - return out -} -// Encode a *BN (a scalar mod L) as a 57-byte little-endian buffer. -to_le57(nn: ptr) -> ptr { - be = bignum.to_bytes_unsigned(nn) - blen = bytes.length(be) - out = bytes.new(57) - i = 0 - while i < 57 { bytes.set(out, i, 0); i = i + 1 } - i = 0 - while i < blen { if i < 57 { bytes.set(out, i, bytes.get(be, blen - 1 - i)) } i = i + 1 } - bytes.free(be) - return out -} - -// ---- projective-coordinate point (X:Y:Z), each a *BN. affine = (X/Z, Y/Z) ---- -struct Pt { x: ptr y: ptr z: ptr } -pt_new(x: @keep ptr, y: @keep ptr, z: @keep ptr) -> *Pt { - q = malloc(sizeof(Pt)) as *Pt - q.x = x; q.y = y; q.z = z - return q -} -pt_free(q: @frees *Pt) { - bignum.free(q.x); bignum.free(q.y); bignum.free(q.z) - libc_free(q) -} - -// Neutral element (0, 1, 1). -pt_identity() -> *Pt { - return pt_new(bignum.from_int(0), bignum.from_int(1), bignum.from_int(1)) -} - -// Projective point addition on the untwisted Edwards curve (a = 1), using the -// "add-2008-bbjlp" complete addition formula (RFC 8032 §5.2 reference code). -// A = Z1*Z2 -// B = A^2 -// C = X1*X2 -// D = Y1*Y2 -// E = d*C*D -// F = B - E -// G = B + E -// H = (X1+Y1)*(X2+Y2) -// X3 = A*F*(H - C - D) -// Y3 = A*G*(D - C) -// Z3 = F*G -pt_add(a: *Pt, b: *Pt, p: ptr, d: ptr) -> *Pt { - A = fmul(a.z, b.z, p) - B = fmul(A, A, p) - C = fmul(a.x, b.x, p) - D = fmul(a.y, b.y, p) - dC = fmul(d, C, p) - E = fmul(dC, D, p) - F = fsub(B, E, p) - G = fadd(B, E, p) - xy1 = fadd(a.x, a.y, p) - xy2 = fadd(b.x, b.y, p) - H = fmul(xy1, xy2, p) - // X3 = A*F*(H - C - D) - hc = fsub(H, C, p) - hcd = fsub(hc, D, p) - af = fmul(A, F, p) - X3 = fmul(af, hcd, p) - // Y3 = A*G*(D - C) - dc = fsub(D, C, p) - ag = fmul(A, G, p) - Y3 = fmul(ag, dc, p) - // Z3 = F*G - Z3 = fmul(F, G, p) - - bignum.free(A); bignum.free(B); bignum.free(C); bignum.free(D) - bignum.free(dC); bignum.free(E); bignum.free(F); bignum.free(G) - bignum.free(xy1); bignum.free(xy2); bignum.free(H) - bignum.free(hc); bignum.free(hcd); bignum.free(af) - bignum.free(dc); bignum.free(ag) - return pt_new(X3, Y3, Z3) -} - -// Scalar mult: e * P, double-and-add over the bits of e (a *BN). -pt_mul(e: ptr, P: *Pt, p: ptr, d: ptr) -> *Pt { - q = pt_identity() - nbits = bignum.bit_length(e) - i = nbits - 1 - while i >= 0 { - dbl = pt_add(q, q, p, d) - pt_free(q) - q = dbl - ei = bignum.shift_right(e, i) - if bit0(ei) == 1 { - sum = pt_add(q, P, p, d) - pt_free(q) - q = sum - } - bignum.free(ei) - i = i - 1 - } - return q -} - -bit0(n: ptr) -> int { - if bignum.is_zero(n) == 1 { return 0 } - two = bignum.from_int(2) - r = bignum.mod(n, two) - b = 0 - if bignum.is_zero(r) != 1 { b = 1 } - bignum.free(two); bignum.free(r) - return b -} - -// The base point B (RFC 8032 §5.2 generator), Z = 1. -base_point() -> *Pt { - xh = "4f1970c66bed0ded221d15a622bf36da9e146570470f1767ea6de324a3d3a46412ae1af72ab66511433b80e18b00938e2626a82bc70cc05e" - yh = "693f46716eb6bc248876203756c9c7624bea73736ca3984087789c1e05a0c2d73ad3ff1ce67c39c4fdbd132c4ed7c8ad9808795bf230fa14" - return pt_new(bn_from_hex(xh), bn_from_hex(yh), bignum.from_int(1)) -} - -// Recover x from y and the sign bit. Untwisted Edwards: x^2+y^2 = 1+d x^2 y^2 -// => x^2 = (y^2 - 1) / (d y^2 - 1) mod p. -// p ≡ 3 (mod 4), so the square root is candidate^((p+1)/4); validate, and -// fix the sign so x's low bit equals `sign`. Returns null on non-square. -recover_x(y: ptr, sign: int, p: ptr, d: ptr) -> ptr { - one = bignum.from_int(1) - y2 = fmul(y, y, p) - u = fsub(y2, one, p) // y^2 - 1 - dy2 = fmul(d, y2, p) - v = fsub(dy2, one, p) // d y^2 - 1 - vinv = finv(v, p) - x2 = fmul(u, vinv, p) // candidate x^2 - - if bignum.is_zero(x2) == 1 { - bignum.free(one); bignum.free(y2); bignum.free(u); bignum.free(dy2) - bignum.free(v); bignum.free(vinv) - if sign == 1 { - // x = 0 but sign demands odd -> invalid - bignum.free(x2) - return null - } - return x2 // x = 0 - } - - // x = x2 ^ ((p+1)/4) - exp = exp_p_plus_1_over_4(p) - x = bignum.mod_pow(x2, exp, p) - bignum.free(exp) - - // verify x^2 == x2 - xx = fmul(x, x, p) - chk = fsub(xx, x2, p) - bignum.free(xx) - if bignum.is_zero(chk) != 1 { - bignum.free(chk) - bignum.free(one); bignum.free(y2); bignum.free(u); bignum.free(dy2) - bignum.free(v); bignum.free(vinv); bignum.free(x2); bignum.free(x) - return null - } - bignum.free(chk) - - if bit0(x) != sign { - nx = fsub(p, x, p) // -x mod p - bignum.free(x); x = nx - } - - bignum.free(one); bignum.free(y2); bignum.free(u); bignum.free(dy2) - bignum.free(v); bignum.free(vinv); bignum.free(x2) - return x -} - -// (p + 1) / 4 -exp_p_plus_1_over_4(p: ptr) -> ptr { - one = bignum.from_int(1) - four = bignum.from_int(4) - pp1 = bignum.add(p, one) - r = bignum.divide(pp1, four) - bignum.free(one); bignum.free(four); bignum.free(pp1) - return r -} - -// Encode a point to 57 bytes: little-endian y (56 bytes) with the low bit of x -// placed in bit 7 of byte 56. -pt_encode(q: *Pt, p: ptr) -> ptr { - zinv = finv(q.z, p) - x = fmul(q.x, zinv, p) - y = fmul(q.y, zinv, p) - out = bytes.new(57) - ybytes = to_le56(y) - bytes.copy_from_bytes(out, 0, ybytes, 0, 56) - bytes.set(out, 56, 0) - if bit0(x) == 1 { - bytes.set(out, 56, 0x80) - } - bignum.free(zinv); bignum.free(x); bignum.free(y) - bytes.free(ybytes) - return out -} - -// Decode 57 bytes to a point; returns null on an invalid encoding. -pt_decode(b: ptr, p: ptr, d: ptr) -> *Pt { - // sign bit lives in bit 7 of byte 56; the rest of byte 56 must be 0. - last = bytes.get(b, 56) - if (last & 0x7F) != 0 { - return null as *Pt - } - sign = (last >> 7) & 1 - ybody = bytes.new(56) - bytes.copy_from_bytes(ybody, 0, b, 0, 56) - y = from_le(bytes.view(ybody)[0..56]) - bytes.free(ybody) - if bignum.compare(y, p) >= 0 { - bignum.free(y) - return null as *Pt - } - x = recover_x(y, sign, p, d) - if x == null { - bignum.free(y) - return null as *Pt - } - z = bignum.from_int(1) - return pt_new(x, y, z) -} - -// ---- hashing helpers ---- -// SHAKE256(data, out_len) over a bytes buffer -> out_len-byte bytes. -// NOTE: sha3.final_bytes already frees the context internally, so we must NOT -// also call free_ctx (that would double-free and segfault). -shake(b: byte[], out_len: int) -> ptr { +// SHAKE256(b) with out_len bytes of output, or null. +fn shake(b: byte[], out_len: int) -> ptr { ctx, err = sha3.new("shake256") - // Same as ed25519.sha512: sha3.new answers (null, "allocation failed"), - // and update_bytes cannot take a null context (#1947). if err != "" { return null } sha3.update(ctx, b) - out = sha3.final_bytes(ctx, out_len) - return out + return sha3.final_bytes(ctx, out_len) +} + +fn wipe_free(b: @frees ptr) { + bytes.wipe_free(b) } // dom4 prefix for pure Ed448 (phflag = 0, empty context): // "SigEd448" || 0x00 || 0x00 (10 bytes) -write_dom4(buf: ptr, off: int) -> int { +fn write_dom4(buf: ptr, off: int) -> int { // "SigEd448" = 53 69 67 45 64 34 34 38 bytes.set(buf, off + 0, 0x53) bytes.set(buf, off + 1, 0x69) @@ -367,46 +69,27 @@ write_dom4(buf: ptr, off: int) -> int { return 10 } -// reduce a little-endian byte buffer mod L -> *BN -reduce_mod_L(b: ptr, len: int) -> ptr { - n = from_le(bytes.view(b)[0..len]) - L = order_L() - r = bignum.mod(n, L) - bignum.free(n); bignum.free(L) - return r -} - -// Derive (s, prefix) from a 57-byte seed: h = SHAKE256(seed, 114); s from the -// first 57 bytes, pruned; prefix = h[57..114]. Returns the secret scalar *BN -// and writes the 57-byte prefix into `prefix_out`. -derive_secret(seed: ptr, prefix_out: ptr) -> ptr { +// The secret scalar s (pruned SHAKE256(seed, 114)[0..57]) into s_le and the +// prefix (bytes 57..114) into prefix; both 57-byte buffers. +fn expand_seed(seed: ptr, s_le: ptr, prefix: ptr) { h = shake(bytes.view(seed)[0..57], 114) - s_le = bytes.new(57) bytes.copy_from_bytes(s_le, 0, h, 0, 57) // prune: clear low 2 bits of byte 0; clear byte 56; set bit 7 of byte 55. bytes.set(s_le, 0, bytes.get(s_le, 0) & 252) bytes.set(s_le, 56, 0) bytes.set(s_le, 55, bytes.get(s_le, 55) | 128) - s = from_le(bytes.view(s_le)[0..57]) - bytes.copy_from_bytes(prefix_out, 0, h, 57, 57) - bytes.free(h); bytes.free(s_le) - return s + bytes.copy_from_bytes(prefix, 0, h, 57, 57) + wipe_free(h) } -// ---- public API ---- - // Derive the 57-byte public key from a 57-byte seed. publickey(seed: ptr) -> ptr { - p = field_p(); d = curve_d(p) - B = base_point() - + s_le = bytes.new(57) prefix = bytes.new(57) - s = derive_secret(seed, prefix) - A = pt_mul(s, B, p, d) - pub = pt_encode(A, p) - - bytes.free(prefix); bignum.free(s) - pt_free(A); pt_free(B); bignum.free(p); bignum.free(d) + expand_seed(seed, s_le, prefix) + pub = ecc.ed_scalar_mult_base(ecc.ED448, s_le) + wipe_free(s_le) + wipe_free(prefix) return pub } @@ -414,53 +97,40 @@ publickey(seed: ptr) -> ptr { sign(seed: ptr, msg: byte[]) -> ptr { if msg.len < 0 { panic("ed448.sign: the `msg` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } int msg_len = msg.len - p = field_p(); d = curve_d(p) - B = base_point() - L = order_L() - + s_le = bytes.new(57) prefix = bytes.new(57) - s = derive_secret(seed, prefix) - A = pt_mul(s, B, p, d) - Aenc = pt_encode(A, p) + expand_seed(seed, s_le, prefix) + aenc = ecc.ed_scalar_mult_base(ecc.ED448, s_le) - // r = SHAKE256(dom4 || prefix || M, 114) mod L + // r = SHAKE256(dom4 || prefix || M, 114) mod L; R = r B rin_len = 10 + 57 + msg_len rin = bytes.new(rin_len) off = write_dom4(rin, 0) bytes.copy_from_bytes(rin, off, prefix, 0, 57) if msg_len > 0 { bytes.copy_from_slice(rin, off + 57, msg[0..msg_len]) } rh = shake(bytes.view(rin)[0..rin_len], 114) - r = reduce_mod_L(rh, 114) + r = ecc.ed_reduce(ecc.ED448, rh) + renc = ecc.ed_scalar_mult_base(ecc.ED448, r) - // R = r*B - R = pt_mul(r, B, p, d) - Renc = pt_encode(R, p) - - // k = SHAKE256(dom4 || Renc || Aenc || M, 114) mod L + // k = SHAKE256(dom4 || R || A || M, 114) mod L kin_len = 10 + 57 + 57 + msg_len kin = bytes.new(kin_len) off2 = write_dom4(kin, 0) - bytes.copy_from_bytes(kin, off2, Renc, 0, 57) - bytes.copy_from_bytes(kin, off2 + 57, Aenc, 0, 57) + bytes.copy_from_bytes(kin, off2, renc, 0, 57) + bytes.copy_from_bytes(kin, off2 + 57, aenc, 0, 57) if msg_len > 0 { bytes.copy_from_slice(kin, off2 + 114, msg[0..msg_len]) } kh = shake(bytes.view(kin)[0..kin_len], 114) - k = reduce_mod_L(kh, 114) + k = ecc.ed_reduce(ecc.ED448, kh) - // S = (r + k*s) mod L - ks = bignum.multiply(k, s) - rks = bignum.add(r, ks) - S = bignum.mod(rks, L) - Senc = to_le57(S) + // S = (r + k s) mod L + s = ecc.ed_sign_s(ecc.ED448, r, k, s_le) sig = bytes.new(114) - bytes.copy_from_bytes(sig, 0, Renc, 0, 57) - bytes.copy_from_bytes(sig, 57, Senc, 0, 57) + bytes.copy_from_bytes(sig, 0, renc, 0, 57) + bytes.copy_from_bytes(sig, 57, s, 0, 57) - bytes.free(prefix); bytes.free(Aenc); bytes.free(rin); bytes.free(rh) - bytes.free(Renc); bytes.free(kin); bytes.free(kh); bytes.free(Senc) - bignum.free(s); bignum.free(r); bignum.free(k); bignum.free(ks); bignum.free(rks); bignum.free(S) - pt_free(A); pt_free(R); pt_free(B) - bignum.free(p); bignum.free(d); bignum.free(L) + wipe_free(s_le); wipe_free(prefix); wipe_free(rin); wipe_free(rh); wipe_free(r) + bytes.wipe_free(aenc); bytes.wipe_free(renc); bytes.wipe_free(kin); bytes.wipe_free(kh); bytes.wipe_free(k); bytes.wipe_free(s) return sig } @@ -468,63 +138,24 @@ sign(seed: ptr, msg: byte[]) -> ptr { // Returns 1 if valid, 0 otherwise. verify(public: ptr, msg: byte[], sig: ptr) -> int { if msg.len < 0 { panic("ed448.verify: the `msg` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } + if sig == null || bytes.length(sig) != 114 || public == null || bytes.length(public) != 57 { return 0 } int msg_len = msg.len - p = field_p(); d = curve_d(p) - B = base_point() - L = order_L() - - Renc = bytes.new(57); bytes.copy_from_bytes(Renc, 0, sig, 0, 57) - Senc = bytes.new(57); bytes.copy_from_bytes(Senc, 0, sig, 57, 57) - S = from_le(bytes.view(Senc)[0..57]) - - ok = 1 - if bignum.compare(S, L) >= 0 { ok = 0 } - - A = pt_decode(public, p, d) - R = pt_decode(Renc, p, d) - if A == null as *Pt { ok = 0 } - if R == null as *Pt { ok = 0 } - - result = 0 - if ok == 1 { - // k = SHAKE256(dom4 || Renc || public || M, 114) mod L - kin_len = 10 + 57 + 57 + msg_len - kin = bytes.new(kin_len) - off = write_dom4(kin, 0) - bytes.copy_from_bytes(kin, off, Renc, 0, 57) - bytes.copy_from_bytes(kin, off + 57, public, 0, 57) - if msg_len > 0 { bytes.copy_from_slice(kin, off + 114, msg[0..msg_len]) } - kh = shake(bytes.view(kin)[0..kin_len], 114) - k = reduce_mod_L(kh, 114) + renc = bytes.new(57) + bytes.copy_from_bytes(renc, 0, sig, 0, 57) + senc = bytes.new(57) + bytes.copy_from_bytes(senc, 0, sig, 57, 57) - // check: S*B == R + k*A - sB = pt_mul(S, B, p, d) - kA = pt_mul(k, A, p, d) - rhs = pt_add(R, kA, p, d) - - lenc = pt_encode(sB, p) - renc = pt_encode(rhs, p) - if bytes_eq(lenc, renc) == 1 { result = 1 } - - bytes.free(kin); bytes.free(kh); bignum.free(k) - pt_free(sB); pt_free(kA); pt_free(rhs) - bytes.free(lenc); bytes.free(renc) - } - - bytes.free(Renc); bytes.free(Senc); bignum.free(S) - if A != null as *Pt { pt_free(A) } - if R != null as *Pt { pt_free(R) } - bignum.free(p); bignum.free(d); bignum.free(L) - pt_free(B) - return result -} + // k = SHAKE256(dom4 || R || A || M, 114) mod L + kin_len = 10 + 57 + 57 + msg_len + kin = bytes.new(kin_len) + off = write_dom4(kin, 0) + bytes.copy_from_bytes(kin, off, renc, 0, 57) + bytes.copy_from_bytes(kin, off + 57, public, 0, 57) + if msg_len > 0 { bytes.copy_from_slice(kin, off + 114, msg[0..msg_len]) } + kh = shake(bytes.view(kin)[0..kin_len], 114) + k = ecc.ed_reduce(ecc.ED448, kh) -bytes_eq(a: ptr, b: ptr) -> int { - if bytes.length(a) != bytes.length(b) { return 0 } - i = 0 - while i < bytes.length(a) { - if bytes.get(a, i) != bytes.get(b, i) { return 0 } - i = i + 1 - } - return 1 + ok = ecc.ed_verify(ecc.ED448, public, renc, senc, k) + bytes.wipe_free(renc); bytes.wipe_free(senc); bytes.wipe_free(kin); bytes.wipe_free(kh); bytes.wipe_free(k) + return ok } diff --git a/std/cryptography/gost3411_2012/module.ae b/std/cryptography/gost3411_2012/module.ae index 4c04443ec..2b625daf0 100644 --- a/std/cryptography/gost3411_2012/module.ae +++ b/std/cryptography/gost3411_2012/module.ae @@ -9,6 +9,7 @@ import std.bits import std.bytes +import std.mem extern malloc(size: int) -> ptr extern free(p: ptr) @@ -780,15 +781,15 @@ finalize_to_bytes(c: *GostCtx) -> ptr { } free_internals(c: *GostCtx) { - if c.IV != null { bytes.free(c.IV); c.IV = null } - if c.N != null { bytes.free(c.N); c.N = null } - if c.Sigma != null { bytes.free(c.Sigma); c.Sigma = null } - if c.Ki != null { bytes.free(c.Ki); c.Ki = null } - if c.m != null { bytes.free(c.m); c.m = null } - if c.h != null { bytes.free(c.h); c.h = null } - if c.tmp != null { bytes.free(c.tmp); c.tmp = null } - if c.block != null { bytes.free(c.block); c.block = null } - if c.zero != null { bytes.free(c.zero); c.zero = null } + if c.IV != null { bytes.wipe_free(c.IV); c.IV = null } + if c.N != null { bytes.wipe_free(c.N); c.N = null } + if c.Sigma != null { bytes.wipe_free(c.Sigma); c.Sigma = null } + if c.Ki != null { bytes.wipe_free(c.Ki); c.Ki = null } + if c.m != null { bytes.wipe_free(c.m); c.m = null } + if c.h != null { bytes.wipe_free(c.h); c.h = null } + if c.tmp != null { bytes.wipe_free(c.tmp); c.tmp = null } + if c.block != null { bytes.wipe_free(c.block); c.block = null } + if c.zero != null { bytes.wipe_free(c.zero); c.zero = null } } final_bytes(ctx: ptr) -> ptr { @@ -798,9 +799,10 @@ final_bytes(ctx: ptr) -> ptr { if c.variant == "gost3411_2012_256" { res = bytes.new(32) bytes.copy_from_bytes(res, 0, out, 32, 32) - bytes.free(out) + bytes.wipe_free(out) s = bytes.finish(res, 32) free_internals(c) + mem.wipe(ctx, sizeof(GostCtx)) free(ctx) ret = bytes.new(32) @@ -809,6 +811,7 @@ final_bytes(ctx: ptr) -> ptr { } else { s = bytes.finish(out, 64) free_internals(c) + mem.wipe(ctx, sizeof(GostCtx)) free(ctx) ret = bytes.new(64) @@ -846,9 +849,10 @@ final_hex(ctx: ptr) -> string { bytes.set(hexbuf, i * 2 + 1, c_lo) i = i + 1 } - bytes.free(out) + bytes.wipe_free(out) s = bytes.finish(hexbuf, len * 2) free_internals(c) + mem.wipe(ctx, sizeof(GostCtx)) free(ctx) return s } @@ -859,6 +863,7 @@ free_ctx(ctx: ptr) { } c = ctx as *GostCtx free_internals(c) + mem.wipe(ctx, sizeof(GostCtx)) free(ctx) } diff --git a/std/cryptography/haraka256/module.ae b/std/cryptography/haraka256/module.ae index 606f57183..6d71e4691 100644 --- a/std/cryptography/haraka256/module.ae +++ b/std/cryptography/haraka256/module.ae @@ -11,6 +11,7 @@ // Import with: import std.cryptography.haraka256 import std.bytes +import std.mem extern malloc(size: int) -> ptr extern free(p: ptr) @@ -287,31 +288,31 @@ finalize_to_bytes(c: *Haraka256Ctx) -> ptr { free_internals(c: *Haraka256Ctx) { if c.buf != null { - bytes.free(c.buf) + bytes.wipe_free(c.buf) c.buf = null } if c.s_scratch != null { - bytes.free(c.s_scratch) + bytes.wipe_free(c.s_scratch) c.s_scratch = null } if c.t_scratch != null { - bytes.free(c.t_scratch) + bytes.wipe_free(c.t_scratch) c.t_scratch = null } if c.s1_0 != null { - bytes.free(c.s1_0) + bytes.wipe_free(c.s1_0) c.s1_0 = null } if c.s1_1 != null { - bytes.free(c.s1_1) + bytes.wipe_free(c.s1_1) c.s1_1 = null } if c.s2_0 != null { - bytes.free(c.s2_0) + bytes.wipe_free(c.s2_0) c.s2_0 = null } if c.s2_1 != null { - bytes.free(c.s2_1) + bytes.wipe_free(c.s2_1) c.s2_1 = null } } @@ -322,11 +323,13 @@ final_bytes(ctx: ptr) -> ptr { if out == null { // wrong-length input — refuse, per BC's exact-32-bytes contract. free_internals(c) + mem.wipe(ctx, sizeof(Haraka256Ctx)) free(ctx) return null } s = bytes.finish(out, 32) free_internals(c) + mem.wipe(ctx, sizeof(Haraka256Ctx)) free(ctx) res = bytes.new(32) bytes.copy_from_string(res, 0, s, 32) @@ -339,6 +342,7 @@ final_hex(ctx: ptr) -> string { if out == null { // wrong-length input — refuse, per BC's exact-32-bytes contract. free_internals(c) + mem.wipe(ctx, sizeof(Haraka256Ctx)) free(ctx) return "" } @@ -360,9 +364,10 @@ final_hex(ctx: ptr) -> string { bytes.set(hexbuf, i * 2 + 1, c_lo) i = i + 1 } - bytes.free(out) + bytes.wipe_free(out) s = bytes.finish(hexbuf, 64) free_internals(c) + mem.wipe(ctx, sizeof(Haraka256Ctx)) free(ctx) return s } @@ -373,6 +378,7 @@ free_ctx(ctx: ptr) { } c = ctx as *Haraka256Ctx free_internals(c) + mem.wipe(ctx, sizeof(Haraka256Ctx)) free(ctx) } diff --git a/std/cryptography/haraka512/module.ae b/std/cryptography/haraka512/module.ae index 5f5e1877f..82ac3962d 100644 --- a/std/cryptography/haraka512/module.ae +++ b/std/cryptography/haraka512/module.ae @@ -11,6 +11,7 @@ // Import with: import std.cryptography.haraka512 import std.bytes +import std.mem extern malloc(size: int) -> ptr extern free(p: ptr) @@ -339,47 +340,47 @@ finalize_to_bytes(c: *Haraka512Ctx) -> ptr { free_internals(c: *Haraka512Ctx) { if c.buf != null { - bytes.free(c.buf) + bytes.wipe_free(c.buf) c.buf = null } if c.s_scratch != null { - bytes.free(c.s_scratch) + bytes.wipe_free(c.s_scratch) c.s_scratch = null } if c.t_scratch != null { - bytes.free(c.t_scratch) + bytes.wipe_free(c.t_scratch) c.t_scratch = null } if c.s1_0 != null { - bytes.free(c.s1_0) + bytes.wipe_free(c.s1_0) c.s1_0 = null } if c.s1_1 != null { - bytes.free(c.s1_1) + bytes.wipe_free(c.s1_1) c.s1_1 = null } if c.s1_2 != null { - bytes.free(c.s1_2) + bytes.wipe_free(c.s1_2) c.s1_2 = null } if c.s1_3 != null { - bytes.free(c.s1_3) + bytes.wipe_free(c.s1_3) c.s1_3 = null } if c.s2_0 != null { - bytes.free(c.s2_0) + bytes.wipe_free(c.s2_0) c.s2_0 = null } if c.s2_1 != null { - bytes.free(c.s2_1) + bytes.wipe_free(c.s2_1) c.s2_1 = null } if c.s2_2 != null { - bytes.free(c.s2_2) + bytes.wipe_free(c.s2_2) c.s2_2 = null } if c.s2_3 != null { - bytes.free(c.s2_3) + bytes.wipe_free(c.s2_3) c.s2_3 = null } } @@ -390,11 +391,13 @@ final_bytes(ctx: ptr) -> ptr { if out == null { // wrong-length input — refuse, per BC's exact-64-bytes contract. free_internals(c) + mem.wipe(ctx, sizeof(Haraka512Ctx)) free(ctx) return null } s = bytes.finish(out, 32) free_internals(c) + mem.wipe(ctx, sizeof(Haraka512Ctx)) free(ctx) res = bytes.new(32) bytes.copy_from_string(res, 0, s, 32) @@ -407,6 +410,7 @@ final_hex(ctx: ptr) -> string { if out == null { // wrong-length input — refuse, per BC's exact-64-bytes contract. free_internals(c) + mem.wipe(ctx, sizeof(Haraka512Ctx)) free(ctx) return "" } @@ -428,9 +432,10 @@ final_hex(ctx: ptr) -> string { bytes.set(hexbuf, i * 2 + 1, c_lo) i = i + 1 } - bytes.free(out) + bytes.wipe_free(out) s = bytes.finish(hexbuf, 64) free_internals(c) + mem.wipe(ctx, sizeof(Haraka512Ctx)) free(ctx) return s } @@ -441,6 +446,7 @@ free_ctx(ctx: ptr) { } c = ctx as *Haraka512Ctx free_internals(c) + mem.wipe(ctx, sizeof(Haraka512Ctx)) free(ctx) } diff --git a/std/cryptography/hkdf/module.ae b/std/cryptography/hkdf/module.ae index bf13d2bb5..c2abc581c 100644 --- a/std/cryptography/hkdf/module.ae +++ b/std/cryptography/hkdf/module.ae @@ -79,7 +79,7 @@ hkdf_extract(algo: string, salt: byte[], ikm: byte[]) -> ptr { } prk = hmac.bytes(algo, use_salt, ikm) if made != null { - bytes.free(made) + bytes.wipe_free(made) } return prk } @@ -128,12 +128,12 @@ hkdf_expand(algo: string, prk: byte[], info: byte[], out_len: int) -> ptr { bytes.set(tin, off, counter & 0xFF) ti = hmac.bytes(algo, prk, bytes.view(tin)[0..tlen]) - bytes.free(tin) + bytes.wipe_free(tin) if ti == null { if prev != null { - bytes.free(prev) + bytes.wipe_free(prev) } - bytes.free(out) + bytes.wipe_free(out) return null } @@ -147,14 +147,14 @@ hkdf_expand(algo: string, prk: byte[], info: byte[], out_len: int) -> ptr { produced = produced + take if prev != null { - bytes.free(prev) + bytes.wipe_free(prev) } prev = ti prev_len = hl counter = counter + 1 } if prev != null { - bytes.free(prev) + bytes.wipe_free(prev) } return out } @@ -170,6 +170,6 @@ hkdf(algo: string, salt: byte[], ikm: byte[], info: byte[], out_len: int) -> ptr return null } okm = hkdf_expand(algo, bytes.view(prk), info, out_len) - bytes.free(prk) + bytes.wipe_free(prk) return okm } diff --git a/std/cryptography/hmac/module.ae b/std/cryptography/hmac/module.ae index 13c21ef71..e732cf23d 100644 --- a/std/cryptography/hmac/module.ae +++ b/std/cryptography/hmac/module.ae @@ -11,7 +11,9 @@ // intermediate digest are all full of NUL bytes — so every byte path // is a length-carrying byte[] slice (#2301): keys and messages come in as // slices, and the internal blocks are fed to sha2.update as bounded views -// of their std.bytes buffers. +// of their std.bytes buffers. Each of them (K0, the pads, the inner digest) +// is a key or a digest of one, so each is freed with bytes.wipe_free, and +// sha2 wipes its own state (#3402). // // HMAC(K, m) = H( (K0 ^ opad) || H( (K0 ^ ipad) || m ) ) // @@ -26,6 +28,7 @@ import std.bits import std.bytes +import std.mem import std.cryptography.sha2 extern malloc(size: int) -> ptr @@ -87,7 +90,7 @@ make_k0(algo: string, key: byte[], block_size: int) -> ptr { // K0 = H(key) (binary-safe), padded with zeros to B. hctx, herr = sha2.new(algo) if herr != "" { - bytes.free(k0) + bytes.wipe_free(k0) return null } sha2.update(hctx, key) @@ -98,7 +101,7 @@ make_k0(algo: string, key: byte[], block_size: int) -> ptr { bytes.set(k0, i, bytes.get(digest, i)) i = i + 1 } - bytes.free(digest) + bytes.wipe_free(digest) } else { i = 0 while i < key_len { @@ -124,6 +127,7 @@ new(algo: string, key: byte[]) -> { } k0 = make_k0(algo, key, bs) if k0 == null { + mem.wipe(ctx, sizeof(HmacCtx)) free(ctx) return null, "unknown algorithm" } @@ -138,18 +142,19 @@ new(algo: string, key: byte[]) -> { bytes.set(opad, i, (kb ^ 0x5c) & 255) i = i + 1 } - bytes.free(k0) + bytes.wipe_free(k0) // Start the inner hash and feed it the ipad block. inner, ierr = sha2.new(algo) if ierr != "" { - bytes.free(ipad) - bytes.free(opad) + bytes.wipe_free(ipad) + bytes.wipe_free(opad) + mem.wipe(ctx, sizeof(HmacCtx)) free(ctx) return null, ierr } sha2.update(inner, bytes.view(ipad)[0..bs]) - bytes.free(ipad) + bytes.wipe_free(ipad) ctx.inner = inner ctx.opad = opad @@ -178,18 +183,20 @@ final_bytes(ctx: ptr) -> ptr { outer, oerr = sha2.new(c.algo) if oerr != "" { - bytes.free(inner_digest) - bytes.free(c.opad) + bytes.wipe_free(inner_digest) + bytes.wipe_free(c.opad) + mem.wipe(ctx, sizeof(HmacCtx)) free(ctx) return null } sha2.update(outer, bytes.view(c.opad)[0..c.block_size]) sha2.update(outer, bytes.view(inner_digest)[0..idl]) - bytes.free(inner_digest) + bytes.wipe_free(inner_digest) result = sha2.final_bytes(outer) - bytes.free(c.opad) + bytes.wipe_free(c.opad) c.opad = null + mem.wipe(ctx, sizeof(HmacCtx)) free(ctx) return result } @@ -201,7 +208,7 @@ final_hex(ctx: ptr) -> string { return "" } s = bytes_to_hex(res) - bytes.free(res) + bytes.wipe_free(res) return s } @@ -216,9 +223,10 @@ free_ctx(ctx: ptr) { c.inner = null } if c.opad != null { - bytes.free(c.opad) + bytes.wipe_free(c.opad) c.opad = null } + mem.wipe(ctx, sizeof(HmacCtx)) free(ctx) } diff --git a/std/cryptography/isap/module.ae b/std/cryptography/isap/module.ae index 9a284f4a2..7ed9b9462 100644 --- a/std/cryptography/isap/module.ae +++ b/std/cryptography/isap/module.ae @@ -9,6 +9,7 @@ import std.bits import std.bytes +import std.mem extern malloc(size: int) -> ptr extern free(p: ptr) @@ -128,7 +129,7 @@ finalize_to_bytes(c: *IsapCtx) -> ptr { free_internals(c: *IsapCtx) { if c.block != null { - bytes.free(c.block) + bytes.wipe_free(c.block) c.block = null } } @@ -138,6 +139,7 @@ final_bytes(ctx: ptr) -> ptr { out = finalize_to_bytes(c) s = bytes.finish(out, 32) free_internals(c) + mem.wipe(ctx, sizeof(IsapCtx)) free(ctx) res = bytes.new(32) bytes.copy_from_string(res, 0, s, 32) @@ -165,9 +167,10 @@ final_hex(ctx: ptr) -> string { bytes.set(hexbuf, i * 2 + 1, c_lo) i = i + 1 } - bytes.free(out) + bytes.wipe_free(out) s = bytes.finish(hexbuf, 64) free_internals(c) + mem.wipe(ctx, sizeof(IsapCtx)) free(ctx) return s } @@ -178,6 +181,7 @@ free_ctx(ctx: ptr) { } c = ctx as *IsapCtx free_internals(c) + mem.wipe(ctx, sizeof(IsapCtx)) free(ctx) } diff --git a/std/cryptography/md2/module.ae b/std/cryptography/md2/module.ae index 59954a205..363e14b38 100644 --- a/std/cryptography/md2/module.ae +++ b/std/cryptography/md2/module.ae @@ -18,6 +18,7 @@ // ONLY to abandon a ctx before finalizing (never after). import std.bytes +import std.mem extern malloc(size: int) -> ptr extern free(p: ptr) @@ -90,7 +91,7 @@ transform(c: *Md2Ctx, block: ptr) { // Save new state bytes.copy_from_bytes(c.state, 0, x, 0, 16) - bytes.free(x) + bytes.wipe_free(x) // Update checksum t_chk = bytes.get(c.checksum, 15) @@ -176,15 +177,15 @@ finalize_to_bytes(c: *Md2Ctx) -> ptr { free_internals(c: *Md2Ctx) { if c.state != null { - bytes.free(c.state) + bytes.wipe_free(c.state) c.state = null } if c.checksum != null { - bytes.free(c.checksum) + bytes.wipe_free(c.checksum) c.checksum = null } if c.buffer != null { - bytes.free(c.buffer) + bytes.wipe_free(c.buffer) c.buffer = null } } @@ -196,6 +197,7 @@ final_bytes(ctx: ptr) -> ptr { out = finalize_to_bytes(c) s = bytes.finish(out, 16) free_internals(c) + mem.wipe(ctx, sizeof(Md2Ctx)) free(ctx) res = bytes.new(16) bytes.copy_from_string(res, 0, s, 16) @@ -225,9 +227,10 @@ final_hex(ctx: ptr) -> string { bytes.set(hexbuf, i * 2 + 1, c_lo) i = i + 1 } - bytes.free(out) + bytes.wipe_free(out) s = bytes.finish(hexbuf, 32) free_internals(c) + mem.wipe(ctx, sizeof(Md2Ctx)) free(ctx) return s } @@ -241,6 +244,7 @@ free_ctx(ctx: ptr) { } c = ctx as *Md2Ctx free_internals(c) + mem.wipe(ctx, sizeof(Md2Ctx)) free(ctx) } diff --git a/std/cryptography/md4/module.ae b/std/cryptography/md4/module.ae index 2503d1bca..dfa9f8897 100644 --- a/std/cryptography/md4/module.ae +++ b/std/cryptography/md4/module.ae @@ -9,6 +9,7 @@ import std.bits import std.bytes +import std.mem extern malloc(size: int) -> ptr extern free(p: ptr) @@ -204,7 +205,7 @@ finalize_to_bytes(c: *Md4Ctx) -> ptr { free_internals(c: *Md4Ctx) { if c.block != null { - bytes.free(c.block) + bytes.wipe_free(c.block) c.block = null } } @@ -214,6 +215,7 @@ final_bytes(ctx: ptr) -> ptr { out = finalize_to_bytes(c) s = bytes.finish(out, 16) free_internals(c) + mem.wipe(ctx, sizeof(Md4Ctx)) free(ctx) res = bytes.new(16) bytes.copy_from_string(res, 0, s, 16) @@ -241,9 +243,10 @@ final_hex(ctx: ptr) -> string { bytes.set(hexbuf, i * 2 + 1, c_lo) i = i + 1 } - bytes.free(out) + bytes.wipe_free(out) s = bytes.finish(hexbuf, 32) free_internals(c) + mem.wipe(ctx, sizeof(Md4Ctx)) free(ctx) return s } @@ -254,6 +257,7 @@ free_ctx(ctx: ptr) { } c = ctx as *Md4Ctx free_internals(c) + mem.wipe(ctx, sizeof(Md4Ctx)) free(ctx) } diff --git a/std/cryptography/md5/module.ae b/std/cryptography/md5/module.ae index 1d8ff45f3..1bcc22f82 100644 --- a/std/cryptography/md5/module.ae +++ b/std/cryptography/md5/module.ae @@ -9,6 +9,7 @@ import std.bits import std.bytes +import std.mem extern malloc(size: int) -> ptr extern free(p: ptr) @@ -226,7 +227,7 @@ finalize_to_bytes(c: *Md5Ctx) -> ptr { free_internals(c: *Md5Ctx) { if c.block != null { - bytes.free(c.block) + bytes.wipe_free(c.block) c.block = null } } @@ -236,6 +237,7 @@ final_bytes(ctx: ptr) -> ptr { out = finalize_to_bytes(c) s = bytes.finish(out, 16) free_internals(c) + mem.wipe(ctx, sizeof(Md5Ctx)) free(ctx) res = bytes.new(16) bytes.copy_from_string(res, 0, s, 16) @@ -263,9 +265,10 @@ final_hex(ctx: ptr) -> string { bytes.set(hexbuf, i * 2 + 1, c_lo) i = i + 1 } - bytes.free(out) + bytes.wipe_free(out) s = bytes.finish(hexbuf, 32) free_internals(c) + mem.wipe(ctx, sizeof(Md5Ctx)) free(ctx) return s } @@ -276,6 +279,7 @@ free_ctx(ctx: ptr) { } c = ctx as *Md5Ctx free_internals(c) + mem.wipe(ctx, sizeof(Md5Ctx)) free(ctx) } diff --git a/std/cryptography/mlkem/module.ae b/std/cryptography/mlkem/module.ae index d27e9dd13..fc224742b 100644 --- a/std/cryptography/mlkem/module.ae +++ b/std/cryptography/mlkem/module.ae @@ -62,6 +62,7 @@ import std.bits import std.bytes import std.cryptography import std.cryptography.sha3 +import std.mem exports( mlkem512_keygen, mlkem512_encaps, mlkem512_decaps, @@ -243,6 +244,8 @@ poly_new() -> int[] { return make([]int, N) } poly_free(p: @frees int[]) { + // A poly may be a secret (s, e): wiped before it is freed (#3414). + mem.wipe(p as ptr, (p.len * 4) as long) free(p) } pg(p: int[], i: int) -> int { @@ -257,6 +260,7 @@ polyvec_new(k: int) -> int[] { return make([]int, N * k) } polyvec_free(p: @frees int[]) { + mem.wipe(p as ptr, (p.len * 4) as long) free(p) } // Coefficient i of poly j in a flat polyvec. @@ -824,13 +828,13 @@ get_noise(pv: int[], j: int, seed: ptr, seed_off: int, nonce: int, eta: int) { out_len = 192 } out = sha3.shake256_bytes(bytes.view(in_buf)[0..SYM + 1], out_len) - bytes.free(in_buf) + bytes.wipe_free(in_buf) if eta == 2 { cbd_eta2(pv, j, out) } else { cbd_eta3(pv, j, out) } - bytes.free(out) + bytes.wipe_free(out) } // ---- Matrix A generation via SHAKE128 rejection sampling ---- @@ -850,7 +854,7 @@ gen_matrix_poly(dest: int[], dj: int, seed: ptr, x: int, y: int) { // 3 blocks = 504 bytes gives ~336 candidates; pull extra to be safe. squeeze_len = 504 + 168 * 4 buf = sha3.shake128_bytes(bytes.view(in_buf)[0..SYM + 2], squeeze_len) - bytes.free(in_buf) + bytes.wipe_free(in_buf) base = dj * N ctr = 0 @@ -859,7 +863,7 @@ gen_matrix_poly(dest: int[], dj: int, seed: ptr, x: int, y: int) { if pos + 3 > squeeze_len { // Astronomically unlikely with this much output (≈784 candidates // for 256 needed). Stop; remaining coeffs stay zero. - bytes.free(buf) + bytes.wipe_free(buf) return } b0 = bytes.get(buf, pos + 0) & 0xFF @@ -882,7 +886,7 @@ gen_matrix_poly(dest: int[], dj: int, seed: ptr, x: int, y: int) { } } } - bytes.free(buf) + bytes.wipe_free(buf) } // Generate the k*k matrix into `mat` (a flat int[] of N*k*k). @@ -919,7 +923,7 @@ indcpa_keygen(p: *MlkemParams, d: ptr, kp: ptr) { } bytes.set(g_in, SYM, k & 0xFF) gbuf = sha3.sha3_512_bytes(bytes.view(g_in)[0..SYM + 1]) // 64 bytes; frees own ctx - bytes.free(g_in) + bytes.wipe_free(g_in) // rho = gbuf[0..32], sigma = gbuf[32..64] rho = bytes.new(SYM) @@ -930,7 +934,7 @@ indcpa_keygen(p: *MlkemParams, d: ptr, kp: ptr) { bytes.set(sigma, i, bytes.get(gbuf, SYM + i) & 0xFF) i = i + 1 } - bytes.free(gbuf) + bytes.wipe_free(gbuf) // Matrix A (non-transposed) mat = make([]int, N * k * k) @@ -989,8 +993,8 @@ indcpa_keygen(p: *MlkemParams, d: ptr, kp: ptr) { i = i + 1 } - bytes.free(rho) - bytes.free(sigma) + bytes.wipe_free(rho) + bytes.wipe_free(sigma) free(mat) polyvec_free(skpv) polyvec_free(e) @@ -1102,7 +1106,7 @@ indcpa_encrypt(p: *MlkemParams, pk: ptr, pk_off: int, msg: ptr, msg_off: int, co compress_poly_128(v_pv, 0, out, out_off + p.polyvec_compressed) } - bytes.free(rho) + bytes.wipe_free(rho) free(mat) polyvec_free(pkpv) polyvec_free(kp_pv) @@ -1185,7 +1189,7 @@ keygen_internal(p: *MlkemParams, d: ptr, z: ptr) -> (ptr, ptr) { bytes.set(dk, hoff + i, bytes.get(hbuf, i) & 0xFF) i = i + 1 } - bytes.free(hbuf) + bytes.wipe_free(hbuf) // z at the very end zoff = p.dk_bytes - SYM i = 0 @@ -1213,7 +1217,7 @@ encaps_internal(p: *MlkemParams, ek: ptr, m: ptr) -> (ptr, ptr) { bytes.set(buf, SYM + i, bytes.get(hbuf, i) & 0xFF) i = i + 1 } - bytes.free(hbuf) + bytes.wipe_free(hbuf) // (K, r) = G(buf) kr = sha3.sha3_512_bytes(bytes.view(buf)[0..2 * SYM]) // 64 bytes @@ -1236,9 +1240,9 @@ encaps_internal(p: *MlkemParams, ek: ptr, m: ptr) -> (ptr, ptr) { i = i + 1 } - bytes.free(buf) - bytes.free(kr) - bytes.free(coins) + bytes.wipe_free(buf) + bytes.wipe_free(kr) + bytes.wipe_free(coins) return ct, key } @@ -1312,13 +1316,13 @@ decaps_internal(p: *MlkemParams, dk: ptr, ct: ptr) -> ptr { i = i + 1 } - bytes.free(mprime) - bytes.free(buf) - bytes.free(kr) - bytes.free(coins) - bytes.free(cmp) - bytes.free(j_in) - bytes.free(reject) + bytes.wipe_free(mprime) + bytes.wipe_free(buf) + bytes.wipe_free(kr) + bytes.wipe_free(coins) + bytes.wipe_free(cmp) + bytes.wipe_free(j_in) + bytes.wipe_free(reject) return key } @@ -1373,7 +1377,7 @@ decaps_check(p: *MlkemParams, dk: ptr, ct: ptr) -> string { diff = diff | (bytes.get(h, i) ^ bytes.get(dk, hoff + i)) i = i + 1 } - bytes.free(h) + bytes.wipe_free(h) if diff != 0 { return "mlkem: decapsulation key fails the hash check" } @@ -1382,15 +1386,11 @@ decaps_check(p: *MlkemParams, dk: ptr, ct: ptr) -> string { // 32 fresh bytes from the OS CSPRNG, or null and the error. KeyGen and Encaps // return an error when the CSPRNG fails (FIPS 203 Algorithms 19 and 20); a -// zero-filled seed would make the key or the shared secret predictable. +// zero-filled seed would make the key or the shared secret predictable. The +// seed goes from the OS into the buffer (cryptography.random_buffer), never +// through a string. rand32() -> (ptr, string) { - s, n, err = cryptography.random_bytes(SYM) - if err != "" { - return null, err - } - b = bytes.new(SYM) - bytes.copy_from_string(b, 0, s, n) - return b, "" + return cryptography.random_buffer(SYM) } // ---- Checked ML-KEM (shared by the per-variant API) ---- @@ -1403,13 +1403,13 @@ keygen_k(k: int) -> (ptr, ptr, string) { } z, zerr = rand32() if zerr != "" { - bytes.free(d) + bytes.wipe_free(d) return null, null, zerr } p = params(k) ek, dk = keygen_internal(&p, d, z) - bytes.free(d) - bytes.free(z) + bytes.wipe_free(d) + bytes.wipe_free(z) return ek, dk, "" } @@ -1437,7 +1437,7 @@ encaps_k(k: int, ek: ptr) -> (ptr, ptr, string) { return null, null, merr } ct, key = encaps_internal(&p, ek, m) - bytes.free(m) + bytes.wipe_free(m) return ct, key, "" } diff --git a/std/cryptography/module.ae b/std/cryptography/module.ae index c7b2c98d5..f872b3514 100644 --- a/std/cryptography/module.ae +++ b/std/cryptography/module.ae @@ -26,6 +26,7 @@ import std.string import std.bytes +import std.mem import std.encoding import std.fs import std.cryptography.hmac @@ -51,7 +52,7 @@ exports( sha1_hex, sha256_hex, sha256_file, hash_hex, hash_supported, md4_hex, md5_hex, hmac_sha256_hex, hmac_sha256_bytes, md4_bytes, md5_bytes, sha1_bytes, sha256_bytes, hash_bytes, - random_bytes, random_hex, random_base64, + random_bytes, random_buffer, random_hex, random_base64, digest_new, digest_update, digest_final_hex, digest_final_bytes, digest_free ) @@ -472,6 +473,37 @@ random_bytes(n: int) -> { return owned, out_n, "" } +// Draw `n` cryptographically-secure random bytes into a fresh std.bytes +// buffer of length n (caller frees). Returns (null, error) on failure, +// never a buffer of zeros. Same OS sources as random_bytes. +// +// This is the shape for key material: the bytes go from the OS straight +// into the buffer. Copying random_bytes' string into a buffer with +// bytes.copy_from_string makes its C side ask whether the source is a +// string by reading the source's first bytes, the key's own: a branch on +// the secret, and a misread when they look like a string header. +random_buffer(n: int) -> (ptr, string) { + if n < 0 { + return null, "random_buffer: negative length" + } + ok = cryptography_random_bytes_raw(n) + if ok == 0 { + return null, "random_bytes failed" + } + raw = cryptography_get_random_bytes() + out_n = cryptography_get_random_bytes_length() + if out_n != n { + cryptography_release_random_bytes() + return null, "random_bytes returned short" + } + b = bytes.from_ptr(raw, n) + cryptography_release_random_bytes() + if b == null { + return null, "random_buffer: allocation failed" + } + return b, "" +} + // Draw `n` cryptographically-secure random bytes and return them as a // lowercase-hex string (2*`n` chars). Returns ("", err) on failure. // Composes random_bytes + per-byte hex emission; same OS-CSPRNG source @@ -697,6 +729,7 @@ digest_final_hex(ctx: ptr) -> { inner = w.inner if kind == 0 { out = cryptography_digest_final_hex_raw(inner) + mem.wipe(ctx, sizeof(DigestCtx)) free(ctx) if out == 0 { return "", "digest finalize failed" @@ -711,6 +744,7 @@ digest_final_hex(ctx: ptr) -> { if kind == 2 { out = sha2.final_hex(inner) } if kind == 3 { out = md5.final_hex(inner) } if kind == 4 { out = md4.final_hex(inner) } + mem.wipe(ctx, sizeof(DigestCtx)) free(ctx) if string.length(out) == 0 { return "", "digest finalize failed" @@ -733,6 +767,7 @@ digest_final_bytes(ctx: ptr) -> { dlen = w.dlen if kind == 0 { ok = cryptography_digest_final_bytes_raw(inner) + mem.wipe(ctx, sizeof(DigestCtx)) free(ctx) if ok == 0 { return "", 0, "digest finalize failed" @@ -751,6 +786,7 @@ digest_final_bytes(ctx: ptr) -> { if kind == 2 { buf = sha2.final_bytes(inner) } if kind == 3 { buf = md5.final_bytes(inner) } if kind == 4 { buf = md4.final_bytes(inner) } + mem.wipe(ctx, sizeof(DigestCtx)) free(ctx) if buf == null { return "", 0, "digest finalize failed" @@ -772,5 +808,6 @@ digest_free(ctx: @frees ptr) { } else { pure_digest_free(w.kind, w.inner) } + mem.wipe(ctx, sizeof(DigestCtx)) free(ctx) } diff --git a/std/cryptography/p256/module.ae b/std/cryptography/p256/module.ae index 83940c4c0..d399b1699 100644 --- a/std/cryptography/p256/module.ae +++ b/std/cryptography/p256/module.ae @@ -2,394 +2,94 @@ // Portions copyright (c) 2000-2026 The Legion of the Bouncy Castle Inc. (https://www.bouncycastle.org) // Portions copyright (c) 2026 Aether Developers. // -// std.cryptography.p256 — NIST P-256 (secp256r1 / prime256v1) in pure -// Aether, on top of std.bignum and std.cryptography.sha2. NO externs to -// OpenSSL. +// std.cryptography.p256: NIST P-256 (secp256r1 / prime256v1) in pure +// Aether, constant time. NO externs to OpenSSL. // -// Short-Weierstrass curve y^2 = x^3 - 3x + b over GF(p). Point arithmetic -// is in Jacobian coordinates (X:Y:Z) with affine (x,y) = (X/Z^2, Y/Z^3). +// Short-Weierstrass curve y^2 = x^3 - 3x + b over GF(p), +// p = 2^256 - 2^224 + 2^192 + 2^96 - 1. The arithmetic is +// std.cryptography.ecc: fixed-width Montgomery field elements with no +// allocation, complete projective addition formulas, a fixed-window scalar +// multiplication that reads every table entry, and RFC 6979 nonces. // // scalar_mult_base(k32) -> (x32, y32) k*G (public key point) +// scalar_mult(k32, px32, py32) -> (x32, y32) k*P // ecdh(priv32, pubx32, puby32) -> shared_x32 priv * pub, x only -// ecdsa_sign(priv32, hash32, k32) -> (r32, s32) k = per-message nonce -// ecdsa_verify(pubx, puby, hash32, r32, s32) -> int 1 = valid +// ecdsa_sign(priv32, hash) -> (r32, s32) RFC 6979 nonce +// ecdsa_sign_hedged(priv32, hash, extra) -> (r32, s32) RFC 6979 3.6, extra mixed in +// ecdsa_verify(pubx, puby, hash, r32, s32) -> int 1 = valid +// public_key_valid(pubx32, puby32) -> int 1 = a point of the curve +// private_key_valid(priv32) -> int 1 = in [1, n-1] // // 32-byte big-endian scalars / coordinates (the SEC1 / NIST wire order). // -// NOTE: correctness-first port over the variable-time std.bignum — not -// constant-time/side-channel-hardened, and ecdsa_sign takes the nonce `k` -// as a parameter (caller supplies a CSPRNG value or an RFC 6979 derivation). -// Validated against a known ECDH vector and a self-consistent sign/verify -// round-trip plus a NIST FIPS 186 verify vector. +// `hash` is the message digest, of any length: its leftmost 256 bits are the +// integer signed (FIPS 186-5). The nonce is RFC 6979's, derived with +// HMAC-SHA-224/256/384/512 when the digest is 28/32/48/64 bytes long and +// HMAC-SHA-256 otherwise, so signing the same digest with the same key gives +// the same signature, and no caller-supplied randomness can leak the key. +// +// Constant time: sign, ecdh and the scalar multiplications run the same +// instructions and touch the same memory whatever the private key and nonce +// are (checked under Valgrind by tests/integration/crypto_ct_valgrind). +// ecdsa_verify works on public values only and is not constant time. +// +// Errors: a private key or scalar that is not 32 bytes, a private key outside +// [1, n-1], or a public point that is not on the curve (or has a coordinate +// >= p) gives null (or (null, null)); ecdsa_verify gives 0. +// +// Measured with the default `ae build` (-O2) on an x86-64 Windows desktop, +// per operation: sign 415 us, verify 470 us, ecdh 365 us (the bignum +// implementation this replaced: 4050, 7850 and 3900 us). import std.bytes -import std.bignum +import std.cryptography.ecc exports( - scalar_mult_base, scalar_mult, ecdh, ecdsa_sign, ecdsa_verify + scalar_mult_base, scalar_mult, ecdh, ecdsa_sign, ecdsa_sign_hedged, ecdsa_verify, + public_key_valid, private_key_valid ) -extern malloc(size: int) -> ptr -@extern("free") libc_free(p: ptr) -extern string_char_at_n(str: string, known_length: int, index: int) -> int -extern strlen(s: string) -> int - -// ---- curve parameters ---- -P_HEX() -> string { return "ffffffff00000001000000000000000000000000ffffffffffffffffffffffff" } -B_HEX() -> string { return "5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604b" } -N_HEX() -> string { return "ffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551" } -GX_HEX() -> string { return "6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296" } -GY_HEX() -> string { return "4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5" } - -hexv(c: int) -> int { - if c >= 48 { if c <= 57 { return c - 48 } } - if c >= 97 { if c <= 102 { return c - 87 } } - if c >= 65 { if c <= 70 { return c - 55 } } - return 0 -} -bn_hex(h: string) -> ptr { - n = strlen(h) - b = bytes.new(n / 2) - i = 0 - while i < n { - hi = hexv(string_char_at_n(h, n, i)) - lo = hexv(string_char_at_n(h, n, i + 1)) - bytes.set(b, i / 2, hi * 16 + lo) - i = i + 2 - } - r = bignum.from_bytes_unsigned(b, n / 2) - bytes.free(b) - return r -} - -// ---- field ops mod p ---- -// Reduction mod p. This used to fold the NIST prime's special form -// (2^256 = 2^224 - 2^192 - 2^96 + 1 mod p) with shifts and adds, written -// when std.bignum's `mod` was a bit-serial long division with several heap -// allocations per bit and one verify cost about 1.4 s. The division is -// Knuth's Algorithm D now (#2595), and the generic `mod` is faster than the -// fold, which allocated about a dozen bignums per pass: the 262 Wycheproof -// P1363 vectors verify in 3.8 s with it, 21.3 s with the fold. Pinned by the -// FIPS 186 KAT in tests/integration/crypto_p256 and the Wycheproof ECDSA -// vectors. -fn fmod(v: ptr, p: ptr) -> ptr { - return bignum.mod(v, p) -} - -fadd(a: ptr, b: ptr, p: ptr) -> ptr { s = bignum.add(a, b); r = fmod(s, p); bignum.free(s); return r } -fsub(a: ptr, b: ptr, p: ptr) -> ptr { d = bignum.subtract(a, b); r = fmod(d, p); bignum.free(d); return r } -fmul(a: ptr, b: ptr, p: ptr) -> ptr { m = bignum.multiply(a, b); r = fmod(m, p); bignum.free(m); return r } -finv(a: ptr, p: ptr) -> ptr { - two = bignum.from_int(2) - e = bignum.subtract(p, two) - r = bignum.mod_pow(a, e, p) - bignum.free(two); bignum.free(e) - return r -} - -// 32-byte big-endian encode / decode. -to_be32(nn: ptr) -> ptr { - be = bignum.to_bytes_unsigned(nn) - blen = bytes.length(be) - out = bytes.new(32) - i = 0 - while i < 32 { bytes.set(out, i, 0); i = i + 1 } - // right-align big-endian bytes - i = 0 - while i < blen { - idx = 32 - blen + i - if idx >= 0 { bytes.set(out, idx, bytes.get(be, i)) } - i = i + 1 - } - bytes.free(be) - return out -} -from_be32(b: ptr) -> ptr { - return bignum.from_bytes_unsigned(b, 32) -} - -// ---- Jacobian point (X:Y:Z) ---- -struct EcPt { x: ptr y: ptr z: ptr } -pt_new(x: @keep ptr, y: @keep ptr, z: @keep ptr) -> *EcPt { q = malloc(sizeof(EcPt)) as *EcPt; q.x = x; q.y = y; q.z = z; return q } -pt_free(q: @frees *EcPt) { bignum.free(q.x); bignum.free(q.y); bignum.free(q.z); libc_free(q) } - -// Point at infinity: Z = 0. -pt_infinity() -> *EcPt { return pt_new(bignum.from_int(1), bignum.from_int(1), bignum.from_int(0)) } -is_infinity(q: *EcPt) -> int { return bignum.is_zero(q.z) } - -// Affine -> Jacobian (Z = 1). -affine_to_jac(x: ptr, y: ptr) -> *EcPt { return pt_new(bignum.clone(x), bignum.clone(y), bignum.from_int(1)) } - -// Point doubling (Jacobian), a = -3: standard "dbl-2009-l" with a=-3 form. -pt_double(q: *EcPt, p: ptr) -> *EcPt { - if is_infinity(q) == 1 { return pt_infinity() } - // delta = Z^2; gamma = Y^2; beta = X*gamma - delta = fmul(q.z, q.z, p) - gamma = fmul(q.y, q.y, p) - beta = fmul(q.x, gamma, p) - // alpha = 3*(X-delta)*(X+delta) (uses a=-3) - xmd = fsub(q.x, delta, p) - xpd = fadd(q.x, delta, p) - prod = fmul(xmd, xpd, p) - three = bignum.from_int(3) - alpha = fmul(three, prod, p) - // X3 = alpha^2 - 8*beta - a2 = fmul(alpha, alpha, p) - eight = bignum.from_int(8) - eb = fmul(eight, beta, p) - X3 = fsub(a2, eb, p) - // Z3 = (Y+Z)^2 - gamma - delta - ypz = fadd(q.y, q.z, p) - ypz2 = fmul(ypz, ypz, p) - t1 = fsub(ypz2, gamma, p) - Z3 = fsub(t1, delta, p) - // Y3 = alpha*(4*beta - X3) - 8*gamma^2 - four = bignum.from_int(4) - fb = fmul(four, beta, p) - fbx = fsub(fb, X3, p) - ay = fmul(alpha, fbx, p) - g2 = fmul(gamma, gamma, p) - eg2 = fmul(eight, g2, p) - Y3 = fsub(ay, eg2, p) - - bignum.free(delta); bignum.free(gamma); bignum.free(beta) - bignum.free(xmd); bignum.free(xpd); bignum.free(prod); bignum.free(three); bignum.free(alpha) - bignum.free(a2); bignum.free(eight); bignum.free(eb) - bignum.free(ypz); bignum.free(ypz2); bignum.free(t1) - bignum.free(four); bignum.free(fb); bignum.free(fbx); bignum.free(ay); bignum.free(g2); bignum.free(eg2) - return pt_new(X3, Y3, Z3) -} - -// Point addition (Jacobian), "add-2007-bl". Handles infinity operands and the -// doubling case (falls back to pt_double). -pt_add(a: *EcPt, b: *EcPt, p: ptr) -> *EcPt { - if is_infinity(a) == 1 { return pt_new(bignum.clone(b.x), bignum.clone(b.y), bignum.clone(b.z)) } - if is_infinity(b) == 1 { return pt_new(bignum.clone(a.x), bignum.clone(a.y), bignum.clone(a.z)) } - // U1 = X1*Z2^2; U2 = X2*Z1^2 - z1z1 = fmul(a.z, a.z, p) - z2z2 = fmul(b.z, b.z, p) - U1 = fmul(a.x, z2z2, p) - U2 = fmul(b.x, z1z1, p) - // S1 = Y1*Z2^3; S2 = Y2*Z1^3 - z2z2z2 = fmul(z2z2, b.z, p) - z1z1z1 = fmul(z1z1, a.z, p) - S1 = fmul(a.y, z2z2z2, p) - S2 = fmul(b.y, z1z1z1, p) - - H = fsub(U2, U1, p) - rr = fsub(S2, S1, p) - - if bignum.is_zero(H) == 1 { - if bignum.is_zero(rr) == 1 { - // a == b: double - bignum.free(z1z1); bignum.free(z2z2); bignum.free(U1); bignum.free(U2) - bignum.free(z2z2z2); bignum.free(z1z1z1); bignum.free(S1); bignum.free(S2) - bignum.free(H); bignum.free(rr) - return pt_double(a, p) - } - // a == -b: infinity - bignum.free(z1z1); bignum.free(z2z2); bignum.free(U1); bignum.free(U2) - bignum.free(z2z2z2); bignum.free(z1z1z1); bignum.free(S1); bignum.free(S2) - bignum.free(H); bignum.free(rr) - return pt_infinity() - } - - // HH = H^2; HHH = H*HH; V = U1*HH - HH = fmul(H, H, p) - HHH = fmul(H, HH, p) - V = fmul(U1, HH, p) - // X3 = r^2 - HHH - 2*V - r2 = fmul(rr, rr, p) - two = bignum.from_int(2) - twoV = fmul(two, V, p) - t = fsub(r2, HHH, p) - X3 = fsub(t, twoV, p) - // Y3 = r*(V - X3) - S1*HHH - vmx = fsub(V, X3, p) - rvmx = fmul(rr, vmx, p) - s1h = fmul(S1, HHH, p) - Y3 = fsub(rvmx, s1h, p) - // Z3 = Z1*Z2*H - z1z2 = fmul(a.z, b.z, p) - Z3 = fmul(z1z2, H, p) - - bignum.free(z1z1); bignum.free(z2z2); bignum.free(U1); bignum.free(U2) - bignum.free(z2z2z2); bignum.free(z1z1z1); bignum.free(S1); bignum.free(S2) - bignum.free(H); bignum.free(rr); bignum.free(HH); bignum.free(HHH); bignum.free(V) - bignum.free(r2); bignum.free(two); bignum.free(twoV); bignum.free(t) - bignum.free(vmx); bignum.free(rvmx); bignum.free(s1h); bignum.free(z1z2) - return pt_new(X3, Y3, Z3) -} - -// k * P (double-and-add over bits of the scalar k, a *BN). -jac_mul(k: ptr, P: *EcPt, p: ptr) -> *EcPt { - q = pt_infinity() - nbits = bignum.bit_length(k) - i = nbits - 1 - while i >= 0 { - dbl = pt_double(q, p) - pt_free(q) - q = dbl - // bignum.test_bit reads the limb in place. Reading each scalar bit as - // `bit0(shift_right(k, i))` made a 256-bit allocation and a full - // `mod 2` per bit, ~256 times per scalar multiplication: one P-256 - // verify cost ~2 s and a depth-4 all-ECDSA chain (Cloudflare's) - // ~13 s, long enough to trip a peer's handshake deadline. Every - // scalar reaching here is reduced mod n, so it is non-negative. - if bignum.test_bit(k, i) == 1 { - sum = pt_add(q, P, p) - pt_free(q) - q = sum - } - i = i - 1 - } - return q -} - -bit0(n: ptr) -> int { - if bignum.is_zero(n) == 1 { return 0 } - two = bignum.from_int(2) - r = bignum.mod(n, two) - b = 0 - if bignum.is_zero(r) != 1 { b = 1 } - bignum.free(two); bignum.free(r) - return b -} - -// Jacobian -> affine (x, y) as two *BN. Returns (null, null) for infinity. -jac_to_affine(q: *EcPt, p: ptr) -> (ptr, ptr) { - if is_infinity(q) == 1 { - return null, null - } - zinv = finv(q.z, p) - zinv2 = fmul(zinv, zinv, p) - zinv3 = fmul(zinv2, zinv, p) - x = fmul(q.x, zinv2, p) - y = fmul(q.y, zinv3, p) - bignum.free(zinv); bignum.free(zinv2); bignum.free(zinv3) - return x, y -} - -base_point() -> *EcPt { return affine_to_jac_hex(GX_HEX(), GY_HEX()) } -affine_to_jac_hex(xh: string, yh: string) -> *EcPt { - x = bn_hex(xh); y = bn_hex(yh) - q = pt_new(x, y, bignum.from_int(1)) - return q -} - -// ---- public API ---- - -// k * G as affine (x, y) 32-byte big-endian pair. +// k * G as affine (x, y) 32-byte big-endian pair; (null, null) if k is a +// multiple of the group order. scalar_mult_base(k: ptr) -> (ptr, ptr) { - p = bn_hex(P_HEX()) - G = base_point() - kn = from_be32(k) - Q = jac_mul(kn, G, p) - x, y = jac_to_affine(Q, p) - ox = to_be32(x); oy = to_be32(y) - bignum.free(x); bignum.free(y) - pt_free(Q); pt_free(G); bignum.free(kn); bignum.free(p) - return ox, oy + return ecc.scalar_mult_base(ecc.P256, k) } -// k * (px, py) as affine (x, y) 32-byte big-endian pair. +// k * (px, py) as affine (x, y) 32-byte big-endian pair; (null, null) for a +// point not on the curve. scalar_mult(k: ptr, px: ptr, py: ptr) -> (ptr, ptr) { - p = bn_hex(P_HEX()) - bx = from_be32(px); by = from_be32(py) - P = pt_new(bx, by, bignum.from_int(1)) - kn = from_be32(k) - Q = jac_mul(kn, P, p) - x, y = jac_to_affine(Q, p) - ox = to_be32(x); oy = to_be32(y) - bignum.free(x); bignum.free(y) - pt_free(Q); pt_free(P); bignum.free(kn); bignum.free(p) - return ox, oy + return ecc.scalar_mult(ecc.P256, k, px, py) } -// ECDH: priv * pub, returns the shared x-coordinate (32 bytes BE). +// ECDH: priv * pub, returns the shared x-coordinate (32 bytes BE), or null. ecdh(priv: ptr, pubx: ptr, puby: ptr) -> ptr { - sx, sy = scalar_mult(priv, pubx, puby) - bytes.free(sy) - return sx + return ecc.ecdh(ecc.P256, priv, pubx, puby) } -// ECDSA sign: r = (k*G).x mod n; s = k^-1 (z + r*d) mod n. `hash` is the -// 32-byte message hash, `d`=priv, `k`=per-message nonce (both 32 BE). -// Returns (r, s) 32-byte BE. (r or s == 0 is a degenerate nonce; caller -// should retry with a fresh k — returned as all-zero here.) -ecdsa_sign(priv: ptr, hash: ptr, k: ptr) -> (ptr, ptr) { - p = bn_hex(P_HEX()) - n = bn_hex(N_HEX()) - G = base_point() - - kn = from_be32(k) - Q = jac_mul(kn, G, p) - qx, qy = jac_to_affine(Q, p) - r = bignum.mod(qx, n) - - d = from_be32(priv) - z = from_be32(hash) - zmod = bignum.mod(z, n) - - kinv = bignum.mod_inverse(kn, n) - rd = bignum.multiply(r, d) - zrd = bignum.add(zmod, rd) - ks = bignum.multiply(kinv, zrd) - s = bignum.mod(ks, n) - - or = to_be32(r); os = to_be32(s) +// ECDSA signature (r, s), 32 bytes each, of the digest `hash` under `priv`. +ecdsa_sign(priv: ptr, hash: ptr) -> (ptr, ptr) { + return ecc.ecdsa_sign(ecc.P256, priv, hash) +} - bignum.free(qx); bignum.free(qy); bignum.free(r); bignum.free(d) - bignum.free(z); bignum.free(zmod); bignum.free(kinv); bignum.free(rd) - bignum.free(zrd); bignum.free(ks); bignum.free(s); bignum.free(kn) - pt_free(Q); pt_free(G); bignum.free(p); bignum.free(n) - return or, os +// The same with a hedged nonce: `extra` (fresh random bytes) mixed into the +// RFC 6979 seed, as ecc.ecdsa_sign_hedged describes (#3376). +ecdsa_sign_hedged(priv: ptr, hash: ptr, extra: ptr) -> (ptr, ptr) { + return ecc.ecdsa_sign_hedged(ecc.P256, priv, hash, extra) } -// ECDSA verify: returns 1 if (r,s) is a valid signature of `hash` under -// public key (pubx, puby), else 0. +// 1 if (r, s) is a valid signature of `hash` under (pubx, puby), else 0. ecdsa_verify(pubx: ptr, puby: ptr, hash: ptr, r: ptr, s: ptr) -> int { - p = bn_hex(P_HEX()) - n = bn_hex(N_HEX()) - G = base_point() - - rn = from_be32(r) - sn = from_be32(s) - zero = bignum.from_int(0) - - ok = 1 - // 1 <= r,s <= n-1 - if bignum.compare(rn, zero) <= 0 { ok = 0 } - if bignum.compare(sn, zero) <= 0 { ok = 0 } - if bignum.compare(rn, n) >= 0 { ok = 0 } - if bignum.compare(sn, n) >= 0 { ok = 0 } - - result = 0 - if ok == 1 { - z = from_be32(hash) - zmod = bignum.mod(z, n) - sinv = bignum.mod_inverse(sn, n) - // u1 = z*sinv mod n; u2 = r*sinv mod n - u1m = bignum.multiply(zmod, sinv); u1 = bignum.mod(u1m, n) - u2m = bignum.multiply(rn, sinv); u2 = bignum.mod(u2m, n) - - Pub = pt_new(from_be32(pubx), from_be32(puby), bignum.from_int(1)) - A = jac_mul(u1, G, p) - Bp = jac_mul(u2, Pub, p) - R = pt_add(A, Bp, p) - rx, ry = jac_to_affine(R, p) - if rx != null { - v = bignum.mod(rx, n) - if bignum.compare(v, rn) == 0 { result = 1 } - bignum.free(v); bignum.free(rx); bignum.free(ry) - } + return ecc.ecdsa_verify(ecc.P256, pubx, puby, hash, r, s) +} - bignum.free(z); bignum.free(zmod); bignum.free(sinv) - bignum.free(u1m); bignum.free(u1); bignum.free(u2m); bignum.free(u2) - pt_free(Pub); pt_free(A); pt_free(Bp); pt_free(R) - } +// 1 if (pubx, puby) is a point of P-256 with both coordinates below p. +public_key_valid(pubx: ptr, puby: ptr) -> int { + return ecc.public_key_valid(ecc.P256, pubx, puby) +} - bignum.free(rn); bignum.free(sn); bignum.free(zero) - pt_free(G); bignum.free(p); bignum.free(n) - return result +// 1 if priv is a 32-byte scalar in [1, n-1], the range a private key must be +// in (ecdh and ecdsa_sign refuse any other). A key drawn as 32 random bytes is +// outside it with probability about 2^-32: draw again. +private_key_valid(priv: ptr) -> int { + return ecc.private_key_valid(ecc.P256, priv) } diff --git a/std/cryptography/p384/module.ae b/std/cryptography/p384/module.ae index 4f6d84dfc..efa380625 100644 --- a/std/cryptography/p384/module.ae +++ b/std/cryptography/p384/module.ae @@ -2,388 +2,75 @@ // Portions copyright (c) 2000-2026 The Legion of the Bouncy Castle Inc. (https://www.bouncycastle.org) // Portions copyright (c) 2026 Aether Developers. // -// std.cryptography.p384 — NIST P-384 (secp384r1) in pure Aether, on top of -// std.bignum. NO externs to OpenSSL. +// std.cryptography.p384: NIST P-384 (secp384r1) in pure Aether, constant +// time. NO externs to OpenSSL. // -// Short-Weierstrass curve y^2 = x^3 - 3x + b over GF(p). Point arithmetic -// is in Jacobian coordinates (X:Y:Z) with affine (x,y) = (X/Z^2, Y/Z^3). +// Short-Weierstrass curve y^2 = x^3 - 3x + b over GF(p), +// p = 2^384 - 2^128 - 2^96 + 2^32 - 1, on std.cryptography.ecc (see +// std.cryptography.p256 for how the arithmetic is done). // // scalar_mult_base(k48) -> (x48, y48) k*G (public key point) // scalar_mult(k48, px48, py48) -> (x48, y48) k*P // ecdh(priv48, pubx48, puby48) -> shared_x48 priv * pub, x only -// ecdsa_sign(priv48, hash48, k48) -> (r48, s48) k = per-message nonce -// ecdsa_verify(pubx, puby, hash48, r48, s48) -> int 1 = valid +// ecdsa_sign(priv48, hash) -> (r48, s48) RFC 6979 nonce +// ecdsa_sign_hedged(priv48, hash, extra) -> (r48, s48) RFC 6979 3.6, extra mixed in +// ecdsa_verify(pubx, puby, hash, r48, s48) -> int 1 = valid +// public_key_valid(pubx48, puby48) -> int 1 = a point of the curve +// private_key_valid(priv48) -> int 1 = in [1, n-1] // // 48-byte big-endian scalars / coordinates (the SEC1 / NIST wire order). // -// NOTE: correctness-first port over the variable-time std.bignum — not -// constant-time/side-channel-hardened, and ecdsa_sign takes the nonce `k` -// as a parameter (caller supplies a CSPRNG value or an RFC 6979 derivation). -// Validated against the priv=2 -> 2G generator vector and an ECDSA -// sign/verify round-trip. +// `hash` is the message digest, of any length: its leftmost 384 bits are the +// integer signed (FIPS 186-5). The nonce is RFC 6979's, derived with +// HMAC-SHA-224/256/384/512 when the digest is 28/32/48/64 bytes long and +// HMAC-SHA-384 otherwise. +// +// Constant time in the private key and the nonce (sign, ecdh, scalar_mult*); +// ecdsa_verify is not, and needs not be. A bad key, scalar length or public +// point gives null (or (null, null)); ecdsa_verify gives 0. +// +// Measured with the default `ae build` (-O2) on an x86-64 Windows desktop, +// per operation: sign 1230 us, verify 1410 us, ecdh 1110 us (the bignum +// implementation this replaced: 7700, 15500 and 7400 us). import std.bytes -import std.bignum +import std.cryptography.ecc exports( - scalar_mult_base, scalar_mult, ecdh, ecdsa_sign, ecdsa_verify + scalar_mult_base, scalar_mult, ecdh, ecdsa_sign, ecdsa_sign_hedged, ecdsa_verify, + public_key_valid, private_key_valid ) -extern malloc(size: int) -> ptr -@extern("free") libc_free(p: ptr) -extern string_char_at_n(str: string, known_length: int, index: int) -> int -extern strlen(s: string) -> int - -// ---- curve parameters ---- -P_HEX() -> string { return "fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffeffffffff0000000000000000ffffffff" } -B_HEX() -> string { return "b3312fa7e23ee7e4988e056be3f82d19181d9c6efe8141120314088f5013875ac656398d8a2ed19d2a85c8edd3ec2aef" } -N_HEX() -> string { return "ffffffffffffffffffffffffffffffffffffffffffffffffc7634d81f4372ddf581a0db248b0a77aecec196accc52973" } -GX_HEX() -> string { return "aa87ca22be8b05378eb1c71ef320ad746e1d3b628ba79b9859f741e082542a385502f25dbf55296c3a545e3872760ab7" } -GY_HEX() -> string { return "3617de4a96262c6f5d9e98bf9292dc29f8f41dbd289a147ce9da3113b5f0b8c00a60b1ce1d7e819d7a431d7c90ea0e5f" } - -hexv(c: int) -> int { - if c >= 48 { if c <= 57 { return c - 48 } } - if c >= 97 { if c <= 102 { return c - 87 } } - if c >= 65 { if c <= 70 { return c - 55 } } - return 0 -} -bn_hex(h: string) -> ptr { - n = strlen(h) - b = bytes.new(n / 2) - i = 0 - while i < n { - hi = hexv(string_char_at_n(h, n, i)) - lo = hexv(string_char_at_n(h, n, i + 1)) - bytes.set(b, i / 2, hi * 16 + lo) - i = i + 2 - } - r = bignum.from_bytes_unsigned(b, n / 2) - bytes.free(b) - return r -} - -// ---- field ops mod p ---- -// Reduction mod p. This used to fold the NIST prime's special form -// (2^384 = 2^128 + 2^96 - 2^32 + 1 mod p) with shifts and adds, written when -// std.bignum's `mod` was a bit-serial long division with several heap -// allocations per bit and one verify cost about 1.85 s. The division is -// Knuth's Algorithm D now (#2595), and the generic `mod` is faster than the -// fold: the 280 Wycheproof P1363 vectors verify in 8.5 s with it, 17.0 s -// with the fold. Pinned by the crypto_tls13_cert_p384 and Wycheproof P-384 -// vectors. -fn fmod(v: ptr, p: ptr) -> ptr { - return bignum.mod(v, p) -} - -fadd(a: ptr, b: ptr, p: ptr) -> ptr { s = bignum.add(a, b); r = fmod(s, p); bignum.free(s); return r } -fsub(a: ptr, b: ptr, p: ptr) -> ptr { d = bignum.subtract(a, b); r = fmod(d, p); bignum.free(d); return r } -fmul(a: ptr, b: ptr, p: ptr) -> ptr { m = bignum.multiply(a, b); r = fmod(m, p); bignum.free(m); return r } -finv(a: ptr, p: ptr) -> ptr { - two = bignum.from_int(2) - e = bignum.subtract(p, two) - r = bignum.mod_pow(a, e, p) - bignum.free(two); bignum.free(e) - return r -} - -// 48-byte big-endian encode / decode. -to_be48(nn: ptr) -> ptr { - be = bignum.to_bytes_unsigned(nn) - blen = bytes.length(be) - out = bytes.new(48) - i = 0 - while i < 48 { bytes.set(out, i, 0); i = i + 1 } - // right-align big-endian bytes - i = 0 - while i < blen { - idx = 48 - blen + i - if idx >= 0 { bytes.set(out, idx, bytes.get(be, i)) } - i = i + 1 - } - bytes.free(be) - return out -} -from_be48(b: ptr) -> ptr { - return bignum.from_bytes_unsigned(b, 48) -} - -// ---- Jacobian point (X:Y:Z) ---- -struct P384Pt { x: ptr y: ptr z: ptr } -pt_new(x: @keep ptr, y: @keep ptr, z: @keep ptr) -> *P384Pt { q = malloc(sizeof(P384Pt)) as *P384Pt; q.x = x; q.y = y; q.z = z; return q } -pt_free(q: @frees *P384Pt) { bignum.free(q.x); bignum.free(q.y); bignum.free(q.z); libc_free(q) } - -// Point at infinity: Z = 0. -pt_infinity() -> *P384Pt { return pt_new(bignum.from_int(1), bignum.from_int(1), bignum.from_int(0)) } -is_infinity(q: *P384Pt) -> int { return bignum.is_zero(q.z) } - -// Affine -> Jacobian (Z = 1). -affine_to_jac(x: ptr, y: ptr) -> *P384Pt { return pt_new(bignum.clone(x), bignum.clone(y), bignum.from_int(1)) } - -// Point doubling (Jacobian), a = -3: standard "dbl-2009-l" with a=-3 form. -pt_double(q: *P384Pt, p: ptr) -> *P384Pt { - if is_infinity(q) == 1 { return pt_infinity() } - // delta = Z^2; gamma = Y^2; beta = X*gamma - delta = fmul(q.z, q.z, p) - gamma = fmul(q.y, q.y, p) - beta = fmul(q.x, gamma, p) - // alpha = 3*(X-delta)*(X+delta) (uses a=-3) - xmd = fsub(q.x, delta, p) - xpd = fadd(q.x, delta, p) - prod = fmul(xmd, xpd, p) - three = bignum.from_int(3) - alpha = fmul(three, prod, p) - // X3 = alpha^2 - 8*beta - a2 = fmul(alpha, alpha, p) - eight = bignum.from_int(8) - eb = fmul(eight, beta, p) - X3 = fsub(a2, eb, p) - // Z3 = (Y+Z)^2 - gamma - delta - ypz = fadd(q.y, q.z, p) - ypz2 = fmul(ypz, ypz, p) - t1 = fsub(ypz2, gamma, p) - Z3 = fsub(t1, delta, p) - // Y3 = alpha*(4*beta - X3) - 8*gamma^2 - four = bignum.from_int(4) - fb = fmul(four, beta, p) - fbx = fsub(fb, X3, p) - ay = fmul(alpha, fbx, p) - g2 = fmul(gamma, gamma, p) - eg2 = fmul(eight, g2, p) - Y3 = fsub(ay, eg2, p) - - bignum.free(delta); bignum.free(gamma); bignum.free(beta) - bignum.free(xmd); bignum.free(xpd); bignum.free(prod); bignum.free(three); bignum.free(alpha) - bignum.free(a2); bignum.free(eight); bignum.free(eb) - bignum.free(ypz); bignum.free(ypz2); bignum.free(t1) - bignum.free(four); bignum.free(fb); bignum.free(fbx); bignum.free(ay); bignum.free(g2); bignum.free(eg2) - return pt_new(X3, Y3, Z3) -} - -// Point addition (Jacobian), "add-2007-bl". Handles infinity operands and the -// doubling case (falls back to pt_double). -pt_add(a: *P384Pt, b: *P384Pt, p: ptr) -> *P384Pt { - if is_infinity(a) == 1 { return pt_new(bignum.clone(b.x), bignum.clone(b.y), bignum.clone(b.z)) } - if is_infinity(b) == 1 { return pt_new(bignum.clone(a.x), bignum.clone(a.y), bignum.clone(a.z)) } - // U1 = X1*Z2^2; U2 = X2*Z1^2 - z1z1 = fmul(a.z, a.z, p) - z2z2 = fmul(b.z, b.z, p) - U1 = fmul(a.x, z2z2, p) - U2 = fmul(b.x, z1z1, p) - // S1 = Y1*Z2^3; S2 = Y2*Z1^3 - z2z2z2 = fmul(z2z2, b.z, p) - z1z1z1 = fmul(z1z1, a.z, p) - S1 = fmul(a.y, z2z2z2, p) - S2 = fmul(b.y, z1z1z1, p) - - H = fsub(U2, U1, p) - rr = fsub(S2, S1, p) - - if bignum.is_zero(H) == 1 { - if bignum.is_zero(rr) == 1 { - // a == b: double - bignum.free(z1z1); bignum.free(z2z2); bignum.free(U1); bignum.free(U2) - bignum.free(z2z2z2); bignum.free(z1z1z1); bignum.free(S1); bignum.free(S2) - bignum.free(H); bignum.free(rr) - return pt_double(a, p) - } - // a == -b: infinity - bignum.free(z1z1); bignum.free(z2z2); bignum.free(U1); bignum.free(U2) - bignum.free(z2z2z2); bignum.free(z1z1z1); bignum.free(S1); bignum.free(S2) - bignum.free(H); bignum.free(rr) - return pt_infinity() - } - - // HH = H^2; HHH = H*HH; V = U1*HH - HH = fmul(H, H, p) - HHH = fmul(H, HH, p) - V = fmul(U1, HH, p) - // X3 = r^2 - HHH - 2*V - r2 = fmul(rr, rr, p) - two = bignum.from_int(2) - twoV = fmul(two, V, p) - t = fsub(r2, HHH, p) - X3 = fsub(t, twoV, p) - // Y3 = r*(V - X3) - S1*HHH - vmx = fsub(V, X3, p) - rvmx = fmul(rr, vmx, p) - s1h = fmul(S1, HHH, p) - Y3 = fsub(rvmx, s1h, p) - // Z3 = Z1*Z2*H - z1z2 = fmul(a.z, b.z, p) - Z3 = fmul(z1z2, H, p) - - bignum.free(z1z1); bignum.free(z2z2); bignum.free(U1); bignum.free(U2) - bignum.free(z2z2z2); bignum.free(z1z1z1); bignum.free(S1); bignum.free(S2) - bignum.free(H); bignum.free(rr); bignum.free(HH); bignum.free(HHH); bignum.free(V) - bignum.free(r2); bignum.free(two); bignum.free(twoV); bignum.free(t) - bignum.free(vmx); bignum.free(rvmx); bignum.free(s1h); bignum.free(z1z2) - return pt_new(X3, Y3, Z3) -} - -// k * P (double-and-add over bits of the scalar k, a *BN). -jac_mul(k: ptr, P: *P384Pt, p: ptr) -> *P384Pt { - q = pt_infinity() - nbits = bignum.bit_length(k) - i = nbits - 1 - while i >= 0 { - dbl = pt_double(q, p) - pt_free(q) - q = dbl - ki = bignum.shift_right(k, i) - if bit0(ki) == 1 { - sum = pt_add(q, P, p) - pt_free(q) - q = sum - } - bignum.free(ki) - i = i - 1 - } - return q -} - -bit0(n: ptr) -> int { - if bignum.is_zero(n) == 1 { return 0 } - two = bignum.from_int(2) - r = bignum.mod(n, two) - b = 0 - if bignum.is_zero(r) != 1 { b = 1 } - bignum.free(two); bignum.free(r) - return b -} - -// Jacobian -> affine (x, y) as two *BN. Returns (null, null) for infinity. -jac_to_affine(q: *P384Pt, p: ptr) -> (ptr, ptr) { - if is_infinity(q) == 1 { - return null, null - } - zinv = finv(q.z, p) - zinv2 = fmul(zinv, zinv, p) - zinv3 = fmul(zinv2, zinv, p) - x = fmul(q.x, zinv2, p) - y = fmul(q.y, zinv3, p) - bignum.free(zinv); bignum.free(zinv2); bignum.free(zinv3) - return x, y -} - -base_point() -> *P384Pt { return affine_to_jac_hex(GX_HEX(), GY_HEX()) } -affine_to_jac_hex(xh: string, yh: string) -> *P384Pt { - x = bn_hex(xh); y = bn_hex(yh) - q = pt_new(x, y, bignum.from_int(1)) - return q -} - -// ---- public API ---- - -// k * G as affine (x, y) 48-byte big-endian pair. scalar_mult_base(k: ptr) -> (ptr, ptr) { - p = bn_hex(P_HEX()) - G = base_point() - kn = from_be48(k) - Q = jac_mul(kn, G, p) - x, y = jac_to_affine(Q, p) - ox = to_be48(x); oy = to_be48(y) - bignum.free(x); bignum.free(y) - pt_free(Q); pt_free(G); bignum.free(kn); bignum.free(p) - return ox, oy + return ecc.scalar_mult_base(ecc.P384, k) } -// k * (px, py) as affine (x, y) 48-byte big-endian pair. scalar_mult(k: ptr, px: ptr, py: ptr) -> (ptr, ptr) { - p = bn_hex(P_HEX()) - bx = from_be48(px); by = from_be48(py) - P = pt_new(bx, by, bignum.from_int(1)) - kn = from_be48(k) - Q = jac_mul(kn, P, p) - x, y = jac_to_affine(Q, p) - ox = to_be48(x); oy = to_be48(y) - bignum.free(x); bignum.free(y) - pt_free(Q); pt_free(P); bignum.free(kn); bignum.free(p) - return ox, oy + return ecc.scalar_mult(ecc.P384, k, px, py) } -// ECDH: priv * pub, returns the shared x-coordinate (48 bytes BE). ecdh(priv: ptr, pubx: ptr, puby: ptr) -> ptr { - sx, sy = scalar_mult(priv, pubx, puby) - bytes.free(sy) - return sx + return ecc.ecdh(ecc.P384, priv, pubx, puby) } -// ECDSA sign: r = (k*G).x mod n; s = k^-1 (z + r*d) mod n. `hash` is the -// 48-byte message hash, `d`=priv, `k`=per-message nonce (both 48 BE). -// Returns (r, s) 48-byte BE. -ecdsa_sign(priv: ptr, hash: ptr, k: ptr) -> (ptr, ptr) { - p = bn_hex(P_HEX()) - n = bn_hex(N_HEX()) - G = base_point() - - kn = from_be48(k) - Q = jac_mul(kn, G, p) - qx, qy = jac_to_affine(Q, p) - r = bignum.mod(qx, n) - - d = from_be48(priv) - z = from_be48(hash) - zmod = bignum.mod(z, n) - - kinv = bignum.mod_inverse(kn, n) - rd = bignum.multiply(r, d) - zrd = bignum.add(zmod, rd) - ks = bignum.multiply(kinv, zrd) - s = bignum.mod(ks, n) - - or = to_be48(r); os = to_be48(s) +ecdsa_sign(priv: ptr, hash: ptr) -> (ptr, ptr) { + return ecc.ecdsa_sign(ecc.P384, priv, hash) +} - bignum.free(qx); bignum.free(qy); bignum.free(r); bignum.free(d) - bignum.free(z); bignum.free(zmod); bignum.free(kinv); bignum.free(rd) - bignum.free(zrd); bignum.free(ks); bignum.free(s); bignum.free(kn) - pt_free(Q); pt_free(G); bignum.free(p); bignum.free(n) - return or, os +// The same with a hedged nonce: `extra` (fresh random bytes) mixed into the +// RFC 6979 seed, as ecc.ecdsa_sign_hedged describes (#3376). +ecdsa_sign_hedged(priv: ptr, hash: ptr, extra: ptr) -> (ptr, ptr) { + return ecc.ecdsa_sign_hedged(ecc.P384, priv, hash, extra) } -// ECDSA verify: returns 1 if (r,s) is a valid signature of `hash` under -// public key (pubx, puby), else 0. ecdsa_verify(pubx: ptr, puby: ptr, hash: ptr, r: ptr, s: ptr) -> int { - p = bn_hex(P_HEX()) - n = bn_hex(N_HEX()) - G = base_point() - - rn = from_be48(r) - sn = from_be48(s) - zero = bignum.from_int(0) - - ok = 1 - // 1 <= r,s <= n-1 - if bignum.compare(rn, zero) <= 0 { ok = 0 } - if bignum.compare(sn, zero) <= 0 { ok = 0 } - if bignum.compare(rn, n) >= 0 { ok = 0 } - if bignum.compare(sn, n) >= 0 { ok = 0 } - - result = 0 - if ok == 1 { - z = from_be48(hash) - zmod = bignum.mod(z, n) - sinv = bignum.mod_inverse(sn, n) - // u1 = z*sinv mod n; u2 = r*sinv mod n - u1m = bignum.multiply(zmod, sinv); u1 = bignum.mod(u1m, n) - u2m = bignum.multiply(rn, sinv); u2 = bignum.mod(u2m, n) - - Pub = pt_new(from_be48(pubx), from_be48(puby), bignum.from_int(1)) - A = jac_mul(u1, G, p) - Bp = jac_mul(u2, Pub, p) - R = pt_add(A, Bp, p) - rx, ry = jac_to_affine(R, p) - if rx != null { - v = bignum.mod(rx, n) - if bignum.compare(v, rn) == 0 { result = 1 } - bignum.free(v); bignum.free(rx); bignum.free(ry) - } + return ecc.ecdsa_verify(ecc.P384, pubx, puby, hash, r, s) +} - bignum.free(z); bignum.free(zmod); bignum.free(sinv) - bignum.free(u1m); bignum.free(u1); bignum.free(u2m); bignum.free(u2) - pt_free(Pub); pt_free(A); pt_free(Bp); pt_free(R) - } +public_key_valid(pubx: ptr, puby: ptr) -> int { + return ecc.public_key_valid(ecc.P384, pubx, puby) +} - bignum.free(rn); bignum.free(sn); bignum.free(zero) - pt_free(G); bignum.free(p); bignum.free(n) - return result +private_key_valid(priv: ptr) -> int { + return ecc.private_key_valid(ecc.P384, priv) } diff --git a/std/cryptography/p521/module.ae b/std/cryptography/p521/module.ae index 35d25215e..ec13e611b 100644 --- a/std/cryptography/p521/module.ae +++ b/std/cryptography/p521/module.ae @@ -2,381 +2,79 @@ // Portions copyright (c) 2000-2026 The Legion of the Bouncy Castle Inc. (https://www.bouncycastle.org) // Portions copyright (c) 2026 Aether Developers. // -// std.cryptography.p521 — NIST P-521 (secp521r1) in pure Aether, on top of -// std.bignum. NO externs to OpenSSL. +// std.cryptography.p521: NIST P-521 (secp521r1) in pure Aether, constant +// time. NO externs to OpenSSL. // -// Short-Weierstrass curve y^2 = x^3 - 3x + b over GF(p), p = 2^521 - 1. -// Point arithmetic is in Jacobian coordinates (X:Y:Z) with affine -// (x,y) = (X/Z^2, Y/Z^3). +// Short-Weierstrass curve y^2 = x^3 - 3x + b over GF(p), p = 2^521 - 1, +// on std.cryptography.ecc (see std.cryptography.p256 for how the +// arithmetic is done). // // scalar_mult_base(k66) -> (x66, y66) k*G (public key point) // scalar_mult(k66, px66, py66) -> (x66, y66) k*P // ecdh(priv66, pubx66, puby66) -> shared_x66 priv * pub, x only -// ecdsa_sign(priv66, hash66, k66) -> (r66, s66) k = per-message nonce -// ecdsa_verify(pubx, puby, hash66, r66, s66) -> int 1 = valid +// ecdsa_sign(priv66, hash) -> (r66, s66) RFC 6979 nonce +// ecdsa_sign_hedged(priv66, hash, extra) -> (r66, s66) RFC 6979 3.6, extra mixed in +// ecdsa_verify(pubx, puby, hash, r66, s66) -> int 1 = valid +// public_key_valid(pubx66, puby66) -> int 1 = a point of the curve +// private_key_valid(priv66) -> int 1 = in [1, n-1] // -// 66-byte big-endian scalars / coordinates. P-521 is 521 bits, so the top -// byte of a 66-byte buffer holds only the low bit; bignum's arbitrary-width -// from/to_bytes_unsigned handle this and to_be66 right-aligns into 66 bytes. +// 66-byte big-endian scalars / coordinates; the top byte holds only the +// value's bit 520. // -// NOTE: correctness-first port over the variable-time std.bignum — not -// constant-time/side-channel-hardened, and ecdsa_sign takes the nonce `k` -// as a parameter (caller supplies a CSPRNG value or an RFC 6979 derivation). -// Validated against the priv=2 -> 2G generator vector and an ECDSA -// sign/verify round-trip. +// `hash` is the message digest itself, of any length: pass the 64 bytes of +// a SHA-512 digest as they are. Its leftmost 521 bits are the integer +// signed (FIPS 186-5), so a 64-byte digest is used whole, and a longer +// buffer is truncated: a digest zero-padded to 66 bytes is a different +// number, not the same one. The nonce is RFC 6979's, derived with +// HMAC-SHA-224/256/384/512 when the digest is 28/32/48/64 bytes long and +// HMAC-SHA-512 otherwise. +// +// Constant time in the private key and the nonce (sign, ecdh, scalar_mult*); +// ecdsa_verify is not, and needs not be. A bad key, scalar length or public +// point gives null (or (null, null)); ecdsa_verify gives 0. +// +// Measured with the default `ae build` (-O2) on an x86-64 Windows desktop, +// per operation: sign 3040 us, verify 3400 us, ecdh 2920 us (the bignum +// implementation this replaced: 15000, 28600 and 14800 us). import std.bytes -import std.bignum +import std.cryptography.ecc exports( - scalar_mult_base, scalar_mult, ecdh, ecdsa_sign, ecdsa_verify + scalar_mult_base, scalar_mult, ecdh, ecdsa_sign, ecdsa_sign_hedged, ecdsa_verify, + public_key_valid, private_key_valid ) -extern malloc(size: int) -> ptr -@extern("free") libc_free(p: ptr) -extern string_char_at_n(str: string, known_length: int, index: int) -> int -extern strlen(s: string) -> int - -// ---- curve parameters ---- -P_HEX() -> string { return "01ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" } -B_HEX() -> string { return "0051953eb9618e1c9a1f929a21a0b68540eea2da725b99b315f3b8b489918ef109e156193951ec7e937b1652c0bd3bb1bf073573df883d2c34f1ef451fd46b503f00" } -N_HEX() -> string { return "01fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffa51868783bf2f966b7fcc0148f709a5d03bb5c9b8899c47aebb6fb71e91386409" } -GX_HEX() -> string { return "00c6858e06b70404e9cd9e3ecb662395b4429c648139053fb521f828af606b4d3dbaa14b5e77efe75928fe1dc127a2ffa8de3348b3c1856a429bf97e7e31c2e5bd66" } -GY_HEX() -> string { return "011839296a789a3bc0045c8a5fb42c7d1bd998f54449579b446817afbd17273e662c97ee72995ef42640c550b9013fad0761353c7086a272c24088be94769fd16650" } - -hexv(c: int) -> int { - if c >= 48 { if c <= 57 { return c - 48 } } - if c >= 97 { if c <= 102 { return c - 87 } } - if c >= 65 { if c <= 70 { return c - 55 } } - return 0 -} -bn_hex(h: string) -> ptr { - n = strlen(h) - b = bytes.new(n / 2) - i = 0 - while i < n { - hi = hexv(string_char_at_n(h, n, i)) - lo = hexv(string_char_at_n(h, n, i + 1)) - bytes.set(b, i / 2, hi * 16 + lo) - i = i + 2 - } - r = bignum.from_bytes_unsigned(b, n / 2) - bytes.free(b) - return r -} - -// ---- field ops mod p ---- -fadd(a: ptr, b: ptr, p: ptr) -> ptr { s = bignum.add(a, b); r = bignum.mod(s, p); bignum.free(s); return r } -fsub(a: ptr, b: ptr, p: ptr) -> ptr { d = bignum.subtract(a, b); r = bignum.mod(d, p); bignum.free(d); return r } -fmul(a: ptr, b: ptr, p: ptr) -> ptr { m = bignum.multiply(a, b); r = bignum.mod(m, p); bignum.free(m); return r } -finv(a: ptr, p: ptr) -> ptr { - two = bignum.from_int(2) - e = bignum.subtract(p, two) - r = bignum.mod_pow(a, e, p) - bignum.free(two); bignum.free(e) - return r -} - -// 66-byte big-endian encode / decode. P-521 coordinates fit in 521 bits, so -// to_bytes_unsigned yields up to 66 bytes; we right-align (zero-pad on the -// left) into a fixed 66-byte buffer. -to_be66(nn: ptr) -> ptr { - be = bignum.to_bytes_unsigned(nn) - blen = bytes.length(be) - out = bytes.new(66) - i = 0 - while i < 66 { bytes.set(out, i, 0); i = i + 1 } - // right-align big-endian bytes - i = 0 - while i < blen { - idx = 66 - blen + i - if idx >= 0 { bytes.set(out, idx, bytes.get(be, i)) } - i = i + 1 - } - bytes.free(be) - return out -} -from_be66(b: ptr) -> ptr { - return bignum.from_bytes_unsigned(b, 66) -} - -// ---- Jacobian point (X:Y:Z) ---- -struct P521Pt { x: ptr y: ptr z: ptr } -pt_new(x: @keep ptr, y: @keep ptr, z: @keep ptr) -> *P521Pt { q = malloc(sizeof(P521Pt)) as *P521Pt; q.x = x; q.y = y; q.z = z; return q } -pt_free(q: @frees *P521Pt) { bignum.free(q.x); bignum.free(q.y); bignum.free(q.z); libc_free(q) } - -// Point at infinity: Z = 0. -pt_infinity() -> *P521Pt { return pt_new(bignum.from_int(1), bignum.from_int(1), bignum.from_int(0)) } -is_infinity(q: *P521Pt) -> int { return bignum.is_zero(q.z) } - -// Affine -> Jacobian (Z = 1). -affine_to_jac(x: ptr, y: ptr) -> *P521Pt { return pt_new(bignum.clone(x), bignum.clone(y), bignum.from_int(1)) } - -// Point doubling (Jacobian), a = -3: standard "dbl-2009-l" with a=-3 form. -pt_double(q: *P521Pt, p: ptr) -> *P521Pt { - if is_infinity(q) == 1 { return pt_infinity() } - // delta = Z^2; gamma = Y^2; beta = X*gamma - delta = fmul(q.z, q.z, p) - gamma = fmul(q.y, q.y, p) - beta = fmul(q.x, gamma, p) - // alpha = 3*(X-delta)*(X+delta) (uses a=-3) - xmd = fsub(q.x, delta, p) - xpd = fadd(q.x, delta, p) - prod = fmul(xmd, xpd, p) - three = bignum.from_int(3) - alpha = fmul(three, prod, p) - // X3 = alpha^2 - 8*beta - a2 = fmul(alpha, alpha, p) - eight = bignum.from_int(8) - eb = fmul(eight, beta, p) - X3 = fsub(a2, eb, p) - // Z3 = (Y+Z)^2 - gamma - delta - ypz = fadd(q.y, q.z, p) - ypz2 = fmul(ypz, ypz, p) - t1 = fsub(ypz2, gamma, p) - Z3 = fsub(t1, delta, p) - // Y3 = alpha*(4*beta - X3) - 8*gamma^2 - four = bignum.from_int(4) - fb = fmul(four, beta, p) - fbx = fsub(fb, X3, p) - ay = fmul(alpha, fbx, p) - g2 = fmul(gamma, gamma, p) - eg2 = fmul(eight, g2, p) - Y3 = fsub(ay, eg2, p) - - bignum.free(delta); bignum.free(gamma); bignum.free(beta) - bignum.free(xmd); bignum.free(xpd); bignum.free(prod); bignum.free(three); bignum.free(alpha) - bignum.free(a2); bignum.free(eight); bignum.free(eb) - bignum.free(ypz); bignum.free(ypz2); bignum.free(t1) - bignum.free(four); bignum.free(fb); bignum.free(fbx); bignum.free(ay); bignum.free(g2); bignum.free(eg2) - return pt_new(X3, Y3, Z3) -} - -// Point addition (Jacobian), "add-2007-bl". Handles infinity operands and the -// doubling case (falls back to pt_double). -pt_add(a: *P521Pt, b: *P521Pt, p: ptr) -> *P521Pt { - if is_infinity(a) == 1 { return pt_new(bignum.clone(b.x), bignum.clone(b.y), bignum.clone(b.z)) } - if is_infinity(b) == 1 { return pt_new(bignum.clone(a.x), bignum.clone(a.y), bignum.clone(a.z)) } - // U1 = X1*Z2^2; U2 = X2*Z1^2 - z1z1 = fmul(a.z, a.z, p) - z2z2 = fmul(b.z, b.z, p) - U1 = fmul(a.x, z2z2, p) - U2 = fmul(b.x, z1z1, p) - // S1 = Y1*Z2^3; S2 = Y2*Z1^3 - z2z2z2 = fmul(z2z2, b.z, p) - z1z1z1 = fmul(z1z1, a.z, p) - S1 = fmul(a.y, z2z2z2, p) - S2 = fmul(b.y, z1z1z1, p) - - H = fsub(U2, U1, p) - rr = fsub(S2, S1, p) - - if bignum.is_zero(H) == 1 { - if bignum.is_zero(rr) == 1 { - // a == b: double - bignum.free(z1z1); bignum.free(z2z2); bignum.free(U1); bignum.free(U2) - bignum.free(z2z2z2); bignum.free(z1z1z1); bignum.free(S1); bignum.free(S2) - bignum.free(H); bignum.free(rr) - return pt_double(a, p) - } - // a == -b: infinity - bignum.free(z1z1); bignum.free(z2z2); bignum.free(U1); bignum.free(U2) - bignum.free(z2z2z2); bignum.free(z1z1z1); bignum.free(S1); bignum.free(S2) - bignum.free(H); bignum.free(rr) - return pt_infinity() - } - - // HH = H^2; HHH = H*HH; V = U1*HH - HH = fmul(H, H, p) - HHH = fmul(H, HH, p) - V = fmul(U1, HH, p) - // X3 = r^2 - HHH - 2*V - r2 = fmul(rr, rr, p) - two = bignum.from_int(2) - twoV = fmul(two, V, p) - t = fsub(r2, HHH, p) - X3 = fsub(t, twoV, p) - // Y3 = r*(V - X3) - S1*HHH - vmx = fsub(V, X3, p) - rvmx = fmul(rr, vmx, p) - s1h = fmul(S1, HHH, p) - Y3 = fsub(rvmx, s1h, p) - // Z3 = Z1*Z2*H - z1z2 = fmul(a.z, b.z, p) - Z3 = fmul(z1z2, H, p) - - bignum.free(z1z1); bignum.free(z2z2); bignum.free(U1); bignum.free(U2) - bignum.free(z2z2z2); bignum.free(z1z1z1); bignum.free(S1); bignum.free(S2) - bignum.free(H); bignum.free(rr); bignum.free(HH); bignum.free(HHH); bignum.free(V) - bignum.free(r2); bignum.free(two); bignum.free(twoV); bignum.free(t) - bignum.free(vmx); bignum.free(rvmx); bignum.free(s1h); bignum.free(z1z2) - return pt_new(X3, Y3, Z3) -} - -// k * P (double-and-add over bits of the scalar k, a *BN). -jac_mul(k: ptr, P: *P521Pt, p: ptr) -> *P521Pt { - q = pt_infinity() - nbits = bignum.bit_length(k) - i = nbits - 1 - while i >= 0 { - dbl = pt_double(q, p) - pt_free(q) - q = dbl - ki = bignum.shift_right(k, i) - if bit0(ki) == 1 { - sum = pt_add(q, P, p) - pt_free(q) - q = sum - } - bignum.free(ki) - i = i - 1 - } - return q -} - -bit0(n: ptr) -> int { - if bignum.is_zero(n) == 1 { return 0 } - two = bignum.from_int(2) - r = bignum.mod(n, two) - b = 0 - if bignum.is_zero(r) != 1 { b = 1 } - bignum.free(two); bignum.free(r) - return b -} - -// Jacobian -> affine (x, y) as two *BN. Returns (null, null) for infinity. -jac_to_affine(q: *P521Pt, p: ptr) -> (ptr, ptr) { - if is_infinity(q) == 1 { - return null, null - } - zinv = finv(q.z, p) - zinv2 = fmul(zinv, zinv, p) - zinv3 = fmul(zinv2, zinv, p) - x = fmul(q.x, zinv2, p) - y = fmul(q.y, zinv3, p) - bignum.free(zinv); bignum.free(zinv2); bignum.free(zinv3) - return x, y -} - -base_point() -> *P521Pt { return affine_to_jac_hex(GX_HEX(), GY_HEX()) } -affine_to_jac_hex(xh: string, yh: string) -> *P521Pt { - x = bn_hex(xh); y = bn_hex(yh) - q = pt_new(x, y, bignum.from_int(1)) - return q -} - -// ---- public API ---- - -// k * G as affine (x, y) 66-byte big-endian pair. scalar_mult_base(k: ptr) -> (ptr, ptr) { - p = bn_hex(P_HEX()) - G = base_point() - kn = from_be66(k) - Q = jac_mul(kn, G, p) - x, y = jac_to_affine(Q, p) - ox = to_be66(x); oy = to_be66(y) - bignum.free(x); bignum.free(y) - pt_free(Q); pt_free(G); bignum.free(kn); bignum.free(p) - return ox, oy + return ecc.scalar_mult_base(ecc.P521, k) } -// k * (px, py) as affine (x, y) 66-byte big-endian pair. scalar_mult(k: ptr, px: ptr, py: ptr) -> (ptr, ptr) { - p = bn_hex(P_HEX()) - bx = from_be66(px); by = from_be66(py) - P = pt_new(bx, by, bignum.from_int(1)) - kn = from_be66(k) - Q = jac_mul(kn, P, p) - x, y = jac_to_affine(Q, p) - ox = to_be66(x); oy = to_be66(y) - bignum.free(x); bignum.free(y) - pt_free(Q); pt_free(P); bignum.free(kn); bignum.free(p) - return ox, oy + return ecc.scalar_mult(ecc.P521, k, px, py) } -// ECDH: priv * pub, returns the shared x-coordinate (66 bytes BE). ecdh(priv: ptr, pubx: ptr, puby: ptr) -> ptr { - sx, sy = scalar_mult(priv, pubx, puby) - bytes.free(sy) - return sx + return ecc.ecdh(ecc.P521, priv, pubx, puby) } -// ECDSA sign: r = (k*G).x mod n; s = k^-1 (z + r*d) mod n. `hash` is the -// 66-byte message hash, `d`=priv, `k`=per-message nonce (both 66 BE). -// Returns (r, s) 66-byte BE. -ecdsa_sign(priv: ptr, hash: ptr, k: ptr) -> (ptr, ptr) { - p = bn_hex(P_HEX()) - n = bn_hex(N_HEX()) - G = base_point() - - kn = from_be66(k) - Q = jac_mul(kn, G, p) - qx, qy = jac_to_affine(Q, p) - r = bignum.mod(qx, n) - - d = from_be66(priv) - z = from_be66(hash) - zmod = bignum.mod(z, n) - - kinv = bignum.mod_inverse(kn, n) - rd = bignum.multiply(r, d) - zrd = bignum.add(zmod, rd) - ks = bignum.multiply(kinv, zrd) - s = bignum.mod(ks, n) - - or = to_be66(r); os = to_be66(s) +ecdsa_sign(priv: ptr, hash: ptr) -> (ptr, ptr) { + return ecc.ecdsa_sign(ecc.P521, priv, hash) +} - bignum.free(qx); bignum.free(qy); bignum.free(r); bignum.free(d) - bignum.free(z); bignum.free(zmod); bignum.free(kinv); bignum.free(rd) - bignum.free(zrd); bignum.free(ks); bignum.free(s); bignum.free(kn) - pt_free(Q); pt_free(G); bignum.free(p); bignum.free(n) - return or, os +// The same with a hedged nonce: `extra` (fresh random bytes) mixed into the +// RFC 6979 seed, as ecc.ecdsa_sign_hedged describes (#3376). +ecdsa_sign_hedged(priv: ptr, hash: ptr, extra: ptr) -> (ptr, ptr) { + return ecc.ecdsa_sign_hedged(ecc.P521, priv, hash, extra) } -// ECDSA verify: returns 1 if (r,s) is a valid signature of `hash` under -// public key (pubx, puby), else 0. ecdsa_verify(pubx: ptr, puby: ptr, hash: ptr, r: ptr, s: ptr) -> int { - p = bn_hex(P_HEX()) - n = bn_hex(N_HEX()) - G = base_point() - - rn = from_be66(r) - sn = from_be66(s) - zero = bignum.from_int(0) - - ok = 1 - // 1 <= r,s <= n-1 - if bignum.compare(rn, zero) <= 0 { ok = 0 } - if bignum.compare(sn, zero) <= 0 { ok = 0 } - if bignum.compare(rn, n) >= 0 { ok = 0 } - if bignum.compare(sn, n) >= 0 { ok = 0 } - - result = 0 - if ok == 1 { - z = from_be66(hash) - zmod = bignum.mod(z, n) - sinv = bignum.mod_inverse(sn, n) - // u1 = z*sinv mod n; u2 = r*sinv mod n - u1m = bignum.multiply(zmod, sinv); u1 = bignum.mod(u1m, n) - u2m = bignum.multiply(rn, sinv); u2 = bignum.mod(u2m, n) - - Pub = pt_new(from_be66(pubx), from_be66(puby), bignum.from_int(1)) - A = jac_mul(u1, G, p) - Bp = jac_mul(u2, Pub, p) - R = pt_add(A, Bp, p) - rx, ry = jac_to_affine(R, p) - if rx != null { - v = bignum.mod(rx, n) - if bignum.compare(v, rn) == 0 { result = 1 } - bignum.free(v); bignum.free(rx); bignum.free(ry) - } + return ecc.ecdsa_verify(ecc.P521, pubx, puby, hash, r, s) +} - bignum.free(z); bignum.free(zmod); bignum.free(sinv) - bignum.free(u1m); bignum.free(u1); bignum.free(u2m); bignum.free(u2) - pt_free(Pub); pt_free(A); pt_free(Bp); pt_free(R) - } +public_key_valid(pubx: ptr, puby: ptr) -> int { + return ecc.public_key_valid(ecc.P521, pubx, puby) +} - bignum.free(rn); bignum.free(sn); bignum.free(zero) - pt_free(G); bignum.free(p); bignum.free(n) - return result +private_key_valid(priv: ptr) -> int { + return ecc.private_key_valid(ecc.P521, priv) } diff --git a/std/cryptography/pbkdf2/module.ae b/std/cryptography/pbkdf2/module.ae index fa9347d37..bd56c3b15 100644 --- a/std/cryptography/pbkdf2/module.ae +++ b/std/cryptography/pbkdf2/module.ae @@ -71,7 +71,7 @@ derive_block(algo: string, password: byte[], salt: byte[], iterations: int, i: i bytes.set(sin, salt_len + 3, i & 0xFF) u = hmac.bytes(algo, password, bytes.view(sin)[0..sin_len]) - bytes.free(sin) + bytes.wipe_free(sin) if u == null { return 0 } @@ -84,9 +84,9 @@ derive_block(algo: string, password: byte[], salt: byte[], iterations: int, i: i j = 1 while j < iterations { next = hmac.bytes(algo, password, bytes.view(u)[0..hl]) - bytes.free(u) + bytes.wipe_free(u) if next == null { - bytes.free(acc) + bytes.wipe_free(acc) return 0 } u = next @@ -97,10 +97,10 @@ derive_block(algo: string, password: byte[], salt: byte[], iterations: int, i: i } j = j + 1 } - bytes.free(u) + bytes.wipe_free(u) bytes.copy_from_bytes(out, out_off, acc, 0, take) - bytes.free(acc) + bytes.wipe_free(acc) return 1 } @@ -128,7 +128,7 @@ pbkdf2(algo: string, password: byte[], salt: byte[], iterations: int, dk_len: in } ok = derive_block(algo, password, salt, iterations, i, out, produced, take) if ok == 0 { - bytes.free(out) + bytes.wipe_free(out) return null } produced = produced + take diff --git a/std/cryptography/pem/module.ae b/std/cryptography/pem/module.ae index 974e78aa4..6b99fbaed 100644 --- a/std/cryptography/pem/module.ae +++ b/std/cryptography/pem/module.ae @@ -20,6 +20,7 @@ // import std.cryptography.pem // // der, label, err = pem.parse(pem_text) // first PEM block in the text +// der, label, err = pem.parse_at(pem_text, from) // the first at or after `from` // text, err = pem.encode(label, der_bytes) // // `der` is a std.bytes (caller frees with bytes.free); `label` is the @@ -31,7 +32,7 @@ import std.bytes import std.strbuilder exports( - parse, encode, + parse, parse_at, encode, base64_decode, base64_encode ) @@ -55,38 +56,53 @@ b64_val(c: int) -> int { // Returns (bytes, err). On malformed input returns (null, err). base64_decode(s: string) -> (ptr, string) { n = string.length(s) - out = bytes.new(0) + return decode_range(s, n, 0, n) +} + +// The base64 in text[start, end) decoded, read in place, into a buffer of +// exactly its final size (#3382): the text of a private key is decoded +// here, and a buffer that grew would have left the key's bytes in the +// blocks it moved out of. Whitespace is skipped; '=' ends a group's data +// bits. (bytes, "") or (null, why). +fn decode_range(text: string, tlen: int, start: int, end: int) -> (ptr, string) { + // First the length, and every character checked. outlen = 0 - acc = 0 // accumulated bits - nbits = 0 // number of bits in acc - i = 0 - while i < n { - c = string_char_at_n(s, n, i) + nbits = 0 + i = start + while i < end { + c = string_char_at_n(text, tlen, i) i = i + 1 - // skip whitespace - if c == 32 {} else { - if c == 9 {} else { - if c == 10 {} else { - if c == 13 {} else { - if c == 61 { - // '=' padding — stop consuming data bits - nbits = 0 - } else { - v = b64_val(c) - if v < 0 { - bytes.free(out) - return null, "pem: invalid base64 character" - } - acc = (acc << 6) | v - nbits = nbits + 6 - if nbits >= 8 { - nbits = nbits - 8 - byteval = (acc >> nbits) & 0xFF - bytes.set(out, outlen, byteval) - outlen = outlen + 1 - } - } - } } } } + if c == 61 { + nbits = 0 + } else if c != 32 && c != 9 && c != 10 && c != 13 { + if b64_val(c) < 0 { return null, "pem: invalid base64 character" } + nbits = nbits + 6 + if nbits >= 8 { + nbits = nbits - 8 + outlen = outlen + 1 + } + } + } + out = bytes.new(outlen) + if outlen > 0 { bytes.set(out, outlen - 1, 0) } + o = 0 + acc = 0 + nbits = 0 + i = start + while i < end { + c = string_char_at_n(text, tlen, i) + i = i + 1 + if c == 61 { + nbits = 0 + } else if c != 32 && c != 9 && c != 10 && c != 13 { + acc = ((acc << 6) | b64_val(c)) & 0xFFFF + nbits = nbits + 6 + if nbits >= 8 { + nbits = nbits - 8 + bytes.set(out, o, (acc >> nbits) & 0xFF) + o = o + 1 + } + } } return out, "" } @@ -139,9 +155,17 @@ emit_b64_char(sb: ptr, alpha: string, v: int) { // Parse the first PEM block in `text`. Returns (der_bytes, label, "") on // success, (null, "", err) otherwise. Whitespace before/after is tolerated. parse(text: string) -> (ptr, string, string) { - _n = string.length(text) + return parse_at(text, 0) +} + +// parse, for the first block that begins at or after offset `from`. The +// base64 body is decoded where it lies in `text`, never copied out of it +// as a string (#3382): a string cannot be wiped, and the body of a private +// key's block is the key. +parse_at(text: string, from: int) -> (ptr, string, string) { + tlen = string.length(text) begin = "-----BEGIN " - bidx = string.index_of(text, begin) + bidx = string.index_of_from(text, begin, from) if bidx < 0 { return null, "", "pem: no BEGIN marker" } @@ -174,8 +198,7 @@ parse(text: string) -> (ptr, string, string) { if string.equals(begin_label, end_label) != 1 { return null, "", "pem: BEGIN/END label mismatch" } - body = string.substring(text, body_start, end_tok_idx) - der, derr = base64_decode(body) + der, derr = decode_range(text, tlen, body_start, end_tok_idx) if string.equals(derr, "") != 1 { return null, "", derr } diff --git a/std/cryptography/photon_beetle/module.ae b/std/cryptography/photon_beetle/module.ae index ad8327302..271bf21cf 100644 --- a/std/cryptography/photon_beetle/module.ae +++ b/std/cryptography/photon_beetle/module.ae @@ -8,6 +8,7 @@ // Import with: import std.cryptography.photon_beetle import std.bytes +import std.mem extern malloc(size: int) -> ptr extern free(p: ptr) @@ -292,11 +293,11 @@ finalize_to_bytes(c: *PhotonBeetleCtx) -> ptr { } free_internals(c: *PhotonBeetleCtx) { - if c.state != null { bytes.free(c.state); c.state = null } - if c.state_2d != null { bytes.free(c.state_2d); c.state_2d = null } - if c.block != null { bytes.free(c.block); c.block = null } - if c.temp_row != null { bytes.free(c.temp_row); c.temp_row = null } - if c.temp_mix != null { bytes.free(c.temp_mix); c.temp_mix = null } + if c.state != null { bytes.wipe_free(c.state); c.state = null } + if c.state_2d != null { bytes.wipe_free(c.state_2d); c.state_2d = null } + if c.block != null { bytes.wipe_free(c.block); c.block = null } + if c.temp_row != null { bytes.wipe_free(c.temp_row); c.temp_row = null } + if c.temp_mix != null { bytes.wipe_free(c.temp_mix); c.temp_mix = null } } final_bytes(ctx: ptr) -> ptr { @@ -304,6 +305,7 @@ final_bytes(ctx: ptr) -> ptr { out = finalize_to_bytes(c) s = bytes.finish(out, 32) free_internals(c) + mem.wipe(ctx, sizeof(PhotonBeetleCtx)) free(ctx) res = bytes.new(32) bytes.copy_from_string(res, 0, s, 32) @@ -331,9 +333,10 @@ final_hex(ctx: ptr) -> string { bytes.set(hexbuf, i * 2 + 1, c_lo) i = i + 1 } - bytes.free(out) + bytes.wipe_free(out) s = bytes.finish(hexbuf, 64) free_internals(c) + mem.wipe(ctx, sizeof(PhotonBeetleCtx)) free(ctx) return s } @@ -344,6 +347,7 @@ free_ctx(ctx: ptr) { } c = ctx as *PhotonBeetleCtx free_internals(c) + mem.wipe(ctx, sizeof(PhotonBeetleCtx)) free(ctx) } diff --git a/std/cryptography/ripemd128/module.ae b/std/cryptography/ripemd128/module.ae index 9b90a4a0c..d6983a5f3 100644 --- a/std/cryptography/ripemd128/module.ae +++ b/std/cryptography/ripemd128/module.ae @@ -23,6 +23,7 @@ import std.bits import std.bytes +import std.mem extern malloc(size: int) -> ptr extern free(p: ptr) @@ -355,7 +356,7 @@ emit_le32(out: ptr, off: int, v: long) { free_internals(c: *Rmd128Ctx) { if c.block != null { - bytes.free(c.block) + bytes.wipe_free(c.block) c.block = null } } @@ -367,6 +368,7 @@ final_bytes(ctx: ptr) -> ptr { out = finalize_to_bytes(c) s = bytes.finish(out, 16) free_internals(c) + mem.wipe(ctx, sizeof(Rmd128Ctx)) free(ctx) res = bytes.new(16) bytes.copy_from_string(res, 0, s, 16) @@ -396,9 +398,10 @@ final_hex(ctx: ptr) -> string { bytes.set(hexbuf, i * 2 + 1, c_lo) i = i + 1 } - bytes.free(out) + bytes.wipe_free(out) s = bytes.finish(hexbuf, 32) free_internals(c) + mem.wipe(ctx, sizeof(Rmd128Ctx)) free(ctx) return s } @@ -412,6 +415,7 @@ free_ctx(ctx: ptr) { } c = ctx as *Rmd128Ctx free_internals(c) + mem.wipe(ctx, sizeof(Rmd128Ctx)) free(ctx) } diff --git a/std/cryptography/ripemd160/module.ae b/std/cryptography/ripemd160/module.ae index e0efabc81..5a1376ba3 100644 --- a/std/cryptography/ripemd160/module.ae +++ b/std/cryptography/ripemd160/module.ae @@ -29,6 +29,7 @@ import std.bits import std.bytes +import std.mem extern malloc(size: int) -> ptr extern free(p: ptr) @@ -408,7 +409,7 @@ emit_le32(out: ptr, off: int, v: long) { free_internals(c: *Rmd160Ctx) { if c.block != null { - bytes.free(c.block) + bytes.wipe_free(c.block) c.block = null } } @@ -419,6 +420,7 @@ final_bytes(ctx: ptr) -> ptr { out = finalize_to_bytes(c) s = bytes.finish(out, 20) free_internals(c) + mem.wipe(ctx, sizeof(Rmd160Ctx)) free(ctx) res = bytes.new(20) bytes.copy_from_string(res, 0, s, 20) @@ -447,9 +449,10 @@ final_hex(ctx: ptr) -> string { bytes.set(hexbuf, i * 2 + 1, c_lo) i = i + 1 } - bytes.free(out) + bytes.wipe_free(out) s = bytes.finish(hexbuf, 40) free_internals(c) + mem.wipe(ctx, sizeof(Rmd160Ctx)) free(ctx) return s } @@ -463,6 +466,7 @@ free_ctx(ctx: ptr) { } c = ctx as *Rmd160Ctx free_internals(c) + mem.wipe(ctx, sizeof(Rmd160Ctx)) free(ctx) } diff --git a/std/cryptography/ripemd256/module.ae b/std/cryptography/ripemd256/module.ae index 8fe9b6a24..97d903026 100644 --- a/std/cryptography/ripemd256/module.ae +++ b/std/cryptography/ripemd256/module.ae @@ -23,6 +23,7 @@ import std.bits import std.bytes +import std.mem extern malloc(size: int) -> ptr extern free(p: ptr) @@ -369,7 +370,7 @@ emit_le32(out: ptr, off: int, v: long) { free_internals(c: *Rmd256Ctx) { if c.block != null { - bytes.free(c.block) + bytes.wipe_free(c.block) c.block = null } } @@ -381,6 +382,7 @@ final_bytes(ctx: ptr) -> ptr { out = finalize_to_bytes(c) s = bytes.finish(out, 32) free_internals(c) + mem.wipe(ctx, sizeof(Rmd256Ctx)) free(ctx) res = bytes.new(32) bytes.copy_from_string(res, 0, s, 32) @@ -410,9 +412,10 @@ final_hex(ctx: ptr) -> string { bytes.set(hexbuf, i * 2 + 1, c_lo) i = i + 1 } - bytes.free(out) + bytes.wipe_free(out) s = bytes.finish(hexbuf, 64) free_internals(c) + mem.wipe(ctx, sizeof(Rmd256Ctx)) free(ctx) return s } @@ -426,6 +429,7 @@ free_ctx(ctx: ptr) { } c = ctx as *Rmd256Ctx free_internals(c) + mem.wipe(ctx, sizeof(Rmd256Ctx)) free(ctx) } diff --git a/std/cryptography/ripemd320/module.ae b/std/cryptography/ripemd320/module.ae index 5a6a3ba0d..bb45d0e0f 100644 --- a/std/cryptography/ripemd320/module.ae +++ b/std/cryptography/ripemd320/module.ae @@ -22,6 +22,7 @@ import std.bits import std.bytes +import std.mem extern malloc(size: int) -> ptr extern free(p: ptr) @@ -404,7 +405,7 @@ emit_le32(out: ptr, off: int, v: long) { free_internals(c: *Rmd320Ctx) { if c.block != null { - bytes.free(c.block) + bytes.wipe_free(c.block) c.block = null } } @@ -416,6 +417,7 @@ final_bytes(ctx: ptr) -> ptr { out = finalize_to_bytes(c) s = bytes.finish(out, 40) free_internals(c) + mem.wipe(ctx, sizeof(Rmd320Ctx)) free(ctx) res = bytes.new(40) bytes.copy_from_string(res, 0, s, 40) @@ -445,9 +447,10 @@ final_hex(ctx: ptr) -> string { bytes.set(hexbuf, i * 2 + 1, c_lo) i = i + 1 } - bytes.free(out) + bytes.wipe_free(out) s = bytes.finish(hexbuf, 80) free_internals(c) + mem.wipe(ctx, sizeof(Rmd320Ctx)) free(ctx) return s } @@ -461,6 +464,7 @@ free_ctx(ctx: ptr) { } c = ctx as *Rmd320Ctx free_internals(c) + mem.wipe(ctx, sizeof(Rmd320Ctx)) free(ctx) } diff --git a/std/cryptography/rsa/module.ae b/std/cryptography/rsa/module.ae index 48faecc02..45209b294 100644 --- a/std/cryptography/rsa/module.ae +++ b/std/cryptography/rsa/module.ae @@ -4,57 +4,78 @@ // // Portions copyright (c) 2026 Aether Developers. // -// std.cryptography.rsa — RSA, the first consumer of std.bignum. +// std.cryptography.rsa: RSA (RFC 8017) in pure Aether. // -// Pure Aether: the modular exponentiation is std.bignum.mod_pow, the key -// is parsed from PKCS#1 DER via std.cryptography.asn1, and PKCS#1 v1.5 -// padding is built byte-by-byte. No OpenSSL RSA — only the OS CSPRNG -// (std.cryptography.random_bytes) for type-2 encryption padding, which is -// a randomness source, not a reimplemented crypto algorithm. +// The public-key operation is std.bignum.mod_pow, the key is parsed from +// PKCS#1 DER via std.cryptography.asn1, and the paddings are built byte by +// byte. No OpenSSL RSA; the OS CSPRNG (std.cryptography.random_buffer) +// supplies the PKCS#1 v1.5 encryption padding and the blinding factors. // // Ported from Bouncy Castle's RsaCoreEngine + Pkcs1Encoding + // RsaDigestSigner (crypto/src/crypto/{engines,encodings,signers}/, MIT). // -// Scope (this slice): -// - key_from_components(n, e, d) / key_free -// - key_from_pkcs1_der(bytes) — parse an RSAPrivateKey SEQUENCE -// - raw encrypt/decrypt primitives (m^e mod n / c^d mod n) -// - PKCS#1 v1.5 encrypt (type 2) / decrypt -// - PKCS#1 v1.5 sign (type 1) / verify over a caller-supplied DigestInfo -// blob (keeps RSA hash-agnostic; the digest+DigestInfo prefix is built -// by the caller, so any std.cryptography digest composes). +// key_from_components(n, e, d) / key_from_bytes(n, e, d) / +// key_from_pkcs1_der(der) / key_free +// raw_encrypt (m^e mod n) / raw_decrypt (c^d mod n) +// PKCS#1 v1.5 encrypt (type 2) / decrypt, sign (type 1) / verify over a +// caller-supplied DigestInfo blob (any std.cryptography digest composes) +// OAEP (SHA-256, empty label) encrypt / decrypt, PSS (SHA-256) sign / verify // -// NOTE: this is the textbook construction for correctness and as the first -// bignum consumer. Constant-time decryption, OAEP, PSS, and Montgomery -// acceleration are follow-up slices (see docs/bignum-deferred-optimizations.md -// for the mod_pow speedup). +// The private-key operation (raw_decrypt, and through it decrypt_pkcs1, +// decrypt_oaep, sign_pkcs1, sign_pss) is constant time: Montgomery +// arithmetic on fixed-width limb vectors with masked reductions, and a +// fixed 4-bit window exponentiation over all of d's bytes that reads every +// table entry. The base is blinded with a fresh random r (c * r^e, then the +// result times r^-1). The two decryptions check their padding without an +// early exit or a branch on the plaintext, and only the final verdict (and +// the message length) is declassified. Loading a key is not constant time +// (the DER reader and std.bignum branch on the encoding). key_from_bytes and +// key_from_pkcs1_der copy d into the key byte by byte, without std.bignum, +// so their keys have no d bignum (RsaKey.d is null), and the DER copies d +// passes through are wiped as they are freed (#3382). +// tests/integration/crypto_ct_valgrind checks the private-key operations +// under Valgrind. +// +// A 2048-bit private-key operation takes about 11 ms with the default +// `ae build` (27 ms on std.bignum's variable-time mod_pow before #2600). import std.string import std.bignum import std.bytes +import std.mem import std.cryptography import std.cryptography.sha2 import std.cryptography.asn1 +import std.cryptography.ct exports( - key_from_components, key_from_pkcs1_der, key_free, + key_from_components, key_from_bytes, key_from_pkcs1_der, key_free, modulus_bytes, modulus_bits, raw_encrypt, raw_decrypt, encrypt_pkcs1, decrypt_pkcs1, sign_pkcs1, verify_pkcs1, encrypt_oaep, decrypt_oaep, - sign_pss, verify_pss + sign_pss, verify_pss, sign_pss_hash, verify_pss_hash ) extern malloc(size: int) -> ptr @extern("free") libc_free(p: ptr) -// An RSA key owns its component bignums (n, e, d). For a public-only key -// `d` is null; raw_decrypt / sign require a private key. +// An RSA key owns its component bignums (n, e, d). `d` is null for a +// public-only key, and for the keys key_from_bytes and key_from_pkcs1_der +// build: they copy the private exponent into `dbe` and never put it +// through std.bignum, whose numbers are freed without a wipe (#3382). +// raw_decrypt / sign require a private key, which `dbe` says. +// +// The private exponent is also held as `dbe`, exactly modulus_bytes(k) +// big-endian bytes: the private-key operation reads its 4-bit windows from +// there in constant time. Loading a key (the DER reader, std.bignum) is not +// constant time; every operation with it is. struct RsaKey { n: ptr // modulus (bignum) e: ptr // public exponent (bignum) d: ptr // private exponent (bignum) or null + dbe: ptr // private exponent as k big-endian bytes (std.bytes), or null } // Build a key from already-parsed bignums. The key TAKES OWNERSHIP of n, @@ -64,6 +85,60 @@ key_from_components(n: @keep ptr, e: @keep ptr, d: @keep ptr) -> ptr { k.n = n k.e = e k.d = d + k.dbe = null + if d != null { + k.dbe = to_fixed(d, (bignum.bit_length(n) + 7) / 8) + } + return k +} + +// Build a key from big-endian byte strings (as JWK and PKCS#11 carry them). +// `d` may be empty for a public key. The private exponent is copied into the +// key byte by byte, so a key built this way never puts d through std.bignum. +// Returns null when n or e is empty or d is longer than n. +key_from_bytes(n: byte[], e: byte[], d: byte[]) -> ptr { + if n.len < 1 || e.len < 1 || d.len < 0 { + return null + } + nb = bytes.from_slice(n) + nn = bignum.from_bytes_unsigned(nb, n.len) + bytes.wipe_free(nb) + eb = bytes.from_slice(e) + ee = bignum.from_bytes_unsigned(eb, e.len) + bytes.wipe_free(eb) + int klen = (bignum.bit_length(nn) + 7) / 8 + if d.len > klen { + // A longer d must be leading zeros; check them, publicly. + int i = 0 + while i < d.len - klen { + if d[i] != 0 { + bignum.free(nn) + bignum.free(ee) + return null + } + i = i + 1 + } + } + k = malloc(sizeof(RsaKey)) as *RsaKey + k.n = nn + k.e = ee + k.d = null + k.dbe = null + if d.len > 0 { + dbe = bytes.new(klen) + bytes.set(dbe, klen - 1, 0) + int j = 0 + while j < klen { + int src = d.len - klen + j + int v = 0 + if src >= 0 { + v = d[src] & 255 + } + bytes.set(dbe, j, v) + j = j + 1 + } + k.dbe = dbe + } return k } @@ -73,12 +148,21 @@ key_free(k: @frees ptr) { if kk.n != null { bignum.free(kk.n) } if kk.e != null { bignum.free(kk.e) } if kk.d != null { bignum.free(kk.d) } + if kk.dbe != null { wipe_free(kk.dbe) } libc_free(k) } +// Zero a std.bytes buffer, then free it. +fn wipe_free(b: @frees ptr) { + bytes.wipe_free(b) +} + // Parse a PKCS#1 RSAPrivateKey DER (SEQUENCE { version, n, e, d, p, q, -// dp, dq, qinv }). Returns (key, "") or (null, err). We keep n, e, d and -// free the CRT params (this slice doesn't use CRT). +// dp, dq, qinv }). Returns (key, "") or (null, err). The key is built as +// key_from_bytes builds one: n and e as numbers, d copied byte by byte into +// the key, never through std.bignum, whose numbers are freed as they are +// (#3382); the CRT parameters are not read (this slice does not use CRT). +// The SEQUENCE's copy and d's INTEGER content are wiped as they are freed. key_from_pkcs1_der(der: @keep ptr) -> (ptr, string) { r = asn1.reader_new(der) sub, body, serr = asn1.read_sequence(r) @@ -88,18 +172,41 @@ key_from_pkcs1_der(der: @keep ptr) -> (ptr, string) { } ver, verr = asn1.read_integer(sub) if string.equals(verr, "") != 1 { - asn1.reader_free(sub); bytes.free(body); asn1.reader_free(r) + asn1.reader_free(sub); wipe_free(body); asn1.reader_free(r) return null, verr } bignum.free(ver) - n, nerr = asn1.read_integer(sub) - e, eerr = asn1.read_integer(sub) - d, derr = asn1.read_integer(sub) - asn1.reader_free(sub); bytes.free(body); asn1.reader_free(r) - if string.equals(nerr, "") != 1 { return null, nerr } - if string.equals(eerr, "") != 1 { return null, eerr } - if string.equals(derr, "") != 1 { return null, derr } - return key_from_components(n, e, d), derr + nb, nerr = read_int_content(sub) + eb, eerr = read_int_content(sub) + db, derr = read_int_content(sub) + asn1.reader_free(sub); wipe_free(body); asn1.reader_free(r) + err = "" + if string.equals(nerr, "") != 1 { err = nerr } + if string.equals(err, "") == 1 && string.equals(eerr, "") != 1 { err = eerr } + if string.equals(err, "") == 1 && string.equals(derr, "") != 1 { err = derr } + k = null + if string.equals(err, "") == 1 { + // An INTEGER's content is two's complement; n, e and d are + // positive, their leading zero byte only a sign. + k = key_from_bytes(bytes.view(nb)[0..bytes.length(nb)], bytes.view(eb)[0..bytes.length(eb)], bytes.view(db)[0..bytes.length(db)]) + if k == null { err = "rsa: not an RSA private key (n, e or d)" } + } + if nb != null { bytes.wipe_free(nb) } + if eb != null { bytes.wipe_free(eb) } + if db != null { wipe_free(db) } + return k, err +} + +// The content of the next INTEGER in `r`, as bytes (caller frees, wiping +// a secret), or (null, why). +fn read_int_content(r: ptr) -> (ptr, string) { + c, err = asn1.read_tlv(r) + if string.equals(err, "") != 1 { return null, err } + if asn1.last_tag(r) != 0x02 { + wipe_free(c) + return null, "rsa: an RSAPrivateKey field is not an INTEGER" + } + return c, "" } // Byte length of the modulus (the RSA block size k). @@ -128,20 +235,423 @@ fn raw_encrypt_slice(k: ptr, m_bytes: byte[]) -> ptr { klen = modulus_bytes(k) owned = bytes.from_slice(m_bytes) m = bignum.from_bytes_unsigned(owned, m_bytes.len) - bytes.free(owned) + bytes.wipe_free(owned) c = bignum.mod_pow(m, kk.e, kk.n) out = to_fixed(c, klen) bignum.free(m); bignum.free(c) return out } +// c^d mod n with the private key, as k big-endian bytes (caller frees), or +// null for a public-only key or a c longer than k bytes. Constant time in d +// and in the result, and blinded: see private_op. raw_decrypt(k: ptr, c_bytes: ptr) -> ptr { kk = k as *RsaKey - klen = modulus_bytes(k) - c = bignum.from_bytes_unsigned(c_bytes, bytes.length(c_bytes)) - m = bignum.mod_pow(c, kk.d, kk.n) - out = to_fixed(m, klen) - bignum.free(c); bignum.free(m) + if kk.dbe == null || c_bytes == null { + return null + } + if bytes.length(c_bytes) > modulus_bytes(k) { + return null + } + return private_op(kk, bytes.view(c_bytes)) +} + +// ---- the private-key operation: constant time, blinded ---- +// +// Montgomery arithmetic on little-endian 32-bit limb vectors of the +// modulus's width, on the heap for the duration of one operation and viewed +// as unbounded uint32[]: CIOS multiplication with a masked final +// subtraction, so the instructions and memory touched depend only on the +// modulus's size. The exponentiation is a fixed 4-bit window from the top +// of d (as many windows as n has bytes, whatever the bit length of d is), +// reading all 16 table entries for each window and keeping one with a mask. +// +// The base is blinded as well: c is multiplied by r^e for a fresh random r +// and the result by r^-1, so the value raised to d is uniformly random and +// unrelated to the ciphertext an attacker chose. r^-1 comes from std.bignum's +// variable-time mod_inverse applied to r*b for another random b (then times +// b), so that inversion only ever sees a random number. + +struct RsaMont { + nl: int // limbs of n + m: ptr // n + m0inv: uint32 // -n^-1 mod 2^32 + one: ptr // R mod n, R = 2^(32 nl) + r2: ptr // R^2 mod n + t: ptr // nl + 2 limbs of scratch for mont_mul +} + +fn limbs_new(nl: int) -> ptr { + p = malloc(nl * 4) + v = p as uint32[] + int i = 0 + while i < nl { + v[i] = 0 + i = i + 1 + } + return p +} + +// The limbs zeroed with mem.wipe, then freed: a zeroing loop before the free +// is a dead store the compiler may drop (#3414). +fn limbs_wipe_free(p: @frees ptr, nl: int) { + mem.wipe(p, (nl * 4) as long) + libc_free(p) +} + +fn limbs_copy(r: ptr, a: ptr, nl: int) { + rv = r as uint32[] + av = a as uint32[] + int i = 0 + while i < nl { + rv[i] = av[i] + i = i + 1 + } +} + +// a = the big-endian bytes b, zero-extended to nl limbs (b.len <= 4 nl). +fn limbs_from_be(a: ptr, nl: int, b: byte[]) { + v = a as uint32[] + int i = 0 + while i < nl { + v[i] = 0 + i = i + 1 + } + int n = b.len + i = 0 + while i < n { + int limb = i / 4 + if limb < nl { + v[limb] = v[limb] | (((b[n - 1 - i] & 255) as uint32) << ((i % 4) * 8)) + } + i = i + 1 + } +} + +// The low len bytes of a as big-endian bytes in a fresh buffer. +fn limbs_to_be(a: ptr, nl: int, len: int) -> ptr { + v = a as uint32[] + out = bytes.new(len) + bytes.set(out, len - 1, 0) + int i = 0 + while i < len { + int limb = i / 4 + int bv = 0 + if limb < nl { + bv = ((v[limb] >> ((i % 4) * 8)) & 255) as int + } + bytes.set(out, len - 1 - i, bv) + i = i + 1 + } + return out +} + +// a = 2a mod m (a < m). +fn mont_dbl(M: *RsaMont, a: ptr) { + int n = M.nl + av = a as uint32[] + t = M.t as uint32[] + mv = M.m as uint32[] + uint32 cin = 0 + int i = 0 + while i < n { + uint32 w = av[i] + t[i] = (w << 1) | cin + cin = w >> 31 + i = i + 1 + } + uint64 bw = 0 + i = 0 + while i < n { + uint64 d = (t[i] as uint64) - (mv[i] as uint64) - bw + av[i] = d as uint32 + bw = d >> 63 + i = i + 1 + } + uint32 mask = 0 - (((1 - cin) as uint32) & (bw as uint32)) + i = 0 + while i < n { + av[i] = av[i] ^ (mask & (av[i] ^ t[i])) + i = i + 1 + } +} + +// r = a * b / R mod n (CIOS); a < R, b < n. r may alias a or b. +fn mont_mul(M: *RsaMont, r: ptr, a: ptr, b: ptr) { + int n = M.nl + t = M.t as uint32[] + av = a as uint32[] + bv = b as uint32[] + mv = M.m as uint32[] + rv = r as uint32[] + uint32 m0inv = M.m0inv + int i = 0 + while i < n + 2 { + t[i] = 0 + i = i + 1 + } + i = 0 + while i < n { + uint64 bi = bv[i] as uint64 + uint64 c = 0 + int j = 0 + while j < n { + uint64 s = (t[j] as uint64) + (av[j] as uint64) * bi + c + t[j] = s as uint32 + c = s >> 32 + j = j + 1 + } + uint64 s2 = (t[n] as uint64) + c + t[n] = s2 as uint32 + t[n + 1] = (s2 >> 32) as uint32 + uint64 mm = ((t[0] * m0inv) as uint32) as uint64 + uint64 s3 = (t[0] as uint64) + mm * (mv[0] as uint64) + c = s3 >> 32 + j = 1 + while j < n { + uint64 s4 = (t[j] as uint64) + mm * (mv[j] as uint64) + c + t[j - 1] = s4 as uint32 + c = s4 >> 32 + j = j + 1 + } + uint64 s5 = (t[n] as uint64) + c + t[n - 1] = s5 as uint32 + t[n] = t[n + 1] + ((s5 >> 32) as uint32) + i = i + 1 + } + uint64 bw = 0 + i = 0 + while i < n { + uint64 d = (t[i] as uint64) - (mv[i] as uint64) - bw + rv[i] = d as uint32 + bw = d >> 63 + i = i + 1 + } + uint32 keep = (bw as uint32) & ((((t[n] as uint64) - 1) >> 63) as uint32) + uint32 mask = 0 - keep + i = 0 + while i < n { + rv[i] = rv[i] ^ (mask & (rv[i] ^ t[i])) + i = i + 1 + } +} + +// The Montgomery context of the odd modulus whose big-endian bytes are nbe. +fn mont_new(nbe: byte[]) -> *RsaMont { + M = malloc(sizeof(RsaMont)) as *RsaMont + int nl = (nbe.len + 3) / 4 + M.nl = nl + M.m = limbs_new(nl) + limbs_from_be(M.m, nl, nbe) + M.t = limbs_new(nl + 2) + uint32 m0 = (M.m as uint32[])[0] + uint32 x = m0 + int it = 0 + while it < 5 { + x = x * (2 - m0 * x) + it = it + 1 + } + M.m0inv = 0 - x + M.one = limbs_new(nl) + ov = M.one as uint32[] + ov[0] = 1 + int k = 0 + while k < 32 * nl { + mont_dbl(M, M.one) + k = k + 1 + } + M.r2 = limbs_new(nl) + limbs_copy(M.r2, M.one, nl) + k = 0 + while k < 32 * nl { + mont_dbl(M, M.r2) + k = k + 1 + } + return M +} + +fn mont_free(M: @frees *RsaMont) { + int nl = M.nl + libc_free(M.m) + libc_free(M.one) + libc_free(M.r2) + limbs_wipe_free(M.t, nl + 2) + libc_free(M) +} + +// r = a^e (Montgomery form in and out) for a PUBLIC exponent e (big-endian +// bytes): square-and-multiply whose sequence depends on e only. +fn mont_pow_public(M: *RsaMont, r: ptr, a: ptr, e: ptr) { + int nl = M.nl + acc = limbs_new(nl) + limbs_copy(acc, M.one, nl) + int el = bytes.length(e) + int bi = 0 + while bi < el { + int ev = bytes.get(e, bi) & 255 + int j = 7 + while j >= 0 { + mont_mul(M, acc, acc, acc) + if ((ev >> j) & 1) == 1 { + mont_mul(M, acc, acc, a) + } + j = j - 1 + } + bi = bi + 1 + } + limbs_copy(r, acc, nl) + libc_free(acc) +} + +// r = a^d (Montgomery form in and out) for the SECRET exponent d (klen +// big-endian bytes): a fixed 4-bit window, every window four squarings and +// one multiplication by a table entry chosen with masks. +fn mont_pow_secret(M: *RsaMont, r: ptr, a: ptr, d: ptr, klen: int) { + int nl = M.nl + // 16 entries of nl limbs: entry j starts j * nl * 4 bytes into tbl. + tbl = limbs_new(16 * nl) + tv = tbl as uint32[] + limbs_copy(tbl, M.one, nl) + limbs_copy(tbl + nl * 4, a, nl) + int i = 2 + while i < 16 { + mont_mul(M, tbl + i * nl * 4, tbl + (i - 1) * nl * 4, a) + i = i + 1 + } + acc = limbs_new(nl) + limbs_copy(acc, M.one, nl) + sel = limbs_new(nl) + sv = sel as uint32[] + int bi = 0 + while bi < klen { + int kv = bytes.get(d, bi) & 255 + int half = 0 + while half < 2 { + int digit = (kv >> (4 - 4 * half)) & 15 + mont_mul(M, acc, acc, acc) + mont_mul(M, acc, acc, acc) + mont_mul(M, acc, acc, acc) + mont_mul(M, acc, acc, acc) + int w = 0 + while w < nl { + sv[w] = 0 + w = w + 1 + } + int j = 0 + while j < 16 { + uint32 mask = 0 - (ct.eq(j, digit) as uint32) + int base = j * nl + w = 0 + while w < nl { + sv[w] = sv[w] | (tv[base + w] & mask) + w = w + 1 + } + j = j + 1 + } + mont_mul(M, acc, acc, sel) + half = half + 1 + } + bi = bi + 1 + } + limbs_copy(r, acc, nl) + limbs_wipe_free(tbl, 16 * nl) + limbs_wipe_free(acc, nl) + limbs_wipe_free(sel, nl) +} + +// The blinding pair for one operation: (r^e, r^-1), both Montgomery form, +// for a fresh random r. (null, null) when the CSPRNG fails. +fn blinding(M: *RsaMont, kk: *RsaKey, ebe: ptr, klen: int) -> (ptr, ptr) { + int nl = M.nl + int tries = 0 + while tries < 8 { + rb, rerr = cryptography.random_buffer(klen) + bb, berr = cryptography.random_buffer(klen) + if rerr != "" || berr != "" { + if rb != null { wipe_free(rb) } + if bb != null { wipe_free(bb) } + return null, null + } + rl = limbs_new(nl) + limbs_from_be(rl, nl, bytes.view(rb)) + rm = limbs_new(nl) + mont_mul(M, rm, rl, M.r2) // r R + limbs_from_be(rl, nl, bytes.view(bb)) + bm = limbs_new(nl) + mont_mul(M, bm, rl, M.r2) // b R + wipe_free(rb) + wipe_free(bb) + // t = r b mod n, inverted by std.bignum: t is uniformly random. + tm = limbs_new(nl) + mont_mul(M, tm, rm, bm) // r b R + one_plain = limbs_new(nl) + opv = one_plain as uint32[] + opv[0] = 1 + mont_mul(M, tm, tm, one_plain) // r b + tbe = limbs_to_be(tm, nl, klen) + tbn = bignum.from_bytes_unsigned(tbe, klen) + wipe_free(tbe) + tinv = bignum.mod_inverse(tbn, kk.n) + bignum.free(tbn) + if tinv != null && bignum.is_zero(tinv) != 1 { + ibe = to_fixed(tinv, klen) + bignum.free(tinv) + limbs_from_be(tm, nl, bytes.view(ibe)) + wipe_free(ibe) + im = limbs_new(nl) + mont_mul(M, im, tm, M.r2) // (r b)^-1 R + mont_mul(M, im, im, bm) // r^-1 R + am = limbs_new(nl) + mont_pow_public(M, am, rm, ebe) // r^e R + limbs_wipe_free(rl, nl) + limbs_wipe_free(rm, nl) + limbs_wipe_free(bm, nl) + limbs_wipe_free(tm, nl) + libc_free(one_plain) + return am, im + } + if tinv != null { bignum.free(tinv) } + limbs_wipe_free(rl, nl) + limbs_wipe_free(rm, nl) + limbs_wipe_free(bm, nl) + limbs_wipe_free(tm, nl) + libc_free(one_plain) + tries = tries + 1 + } + return null, null +} + +// c^d mod n for the key's d, blinded and constant time; k bytes, or null +// when the CSPRNG fails. +fn private_op(kk: *RsaKey, c: byte[]) -> ptr { + int klen = (bignum.bit_length(kk.n) + 7) / 8 + nbe = bignum.to_bytes_unsigned(kk.n) + ebe = bignum.to_bytes_unsigned(kk.e) + M = mont_new(bytes.view(nbe)) + bytes.wipe_free(nbe) + int nl = M.nl + am, im = blinding(M, kk, ebe, klen) + bytes.wipe_free(ebe) + if am == null { + mont_free(M) + return null + } + x = limbs_new(nl) + limbs_from_be(x, nl, c) + mont_mul(M, x, x, M.r2) // c R (any c < R reduces) + mont_mul(M, x, x, am) // c r^e R + mont_pow_secret(M, x, x, kk.dbe, klen) // (c r^e)^d R = m r R + mont_mul(M, x, x, im) // m R + one_plain = limbs_new(nl) + opv = one_plain as uint32[] + opv[0] = 1 + mont_mul(M, x, x, one_plain) // m + out = limbs_to_be(x, nl, klen) + limbs_wipe_free(x, nl) + limbs_wipe_free(am, nl) + limbs_wipe_free(im, nl) + libc_free(one_plain) + mont_free(M) return out } @@ -162,7 +672,8 @@ to_fixed(n: ptr, width: int) -> ptr { } i = i + 1 } - bytes.free(mag) + // The magnitude of a private exponent too (key_from_components, #3382). + wipe_free(mag) return out } @@ -182,24 +693,25 @@ encrypt_pkcs1(k: ptr, msg: ptr) -> (ptr, string) { bytes.set(block, 0, 0x00) bytes.set(block, 1, 0x02) pslen = klen - mlen - 3 - // fill PS with nonzero random bytes - rnd, rn, rerr = cryptography.random_bytes(pslen * 2 + 16) - if string.equals(rerr, "") != 1 { - bytes.free(block) - return null, rerr - } + // fill PS with nonzero random bytes, drawing more until it is full filled = 0 - ri = 0 while filled < pslen { - rb = 0 - if ri < rn { rb = string_char_at_n(rnd, rn, ri) & 0xFF } - ri = ri + 1 - if rb != 0 { - bytes.set(block, 2 + filled, rb) - filled = filled + 1 + rnd, rerr = cryptography.random_buffer(pslen - filled + 16) + if rerr != "" { + bytes.wipe_free(block) + return null, rerr } - // if we run out of random, top up deterministically with a nonzero - if ri >= rn { ri = 0 } + rn = bytes.length(rnd) + ri = 0 + while ri < rn && filled < pslen { + rb = bytes.get(rnd, ri) & 0xFF + if rb != 0 { + bytes.set(block, 2 + filled, rb) + filled = filled + 1 + } + ri = ri + 1 + } + wipe_free(rnd) } bytes.set(block, 2 + pslen, 0x00) i = 0 @@ -208,48 +720,48 @@ encrypt_pkcs1(k: ptr, msg: ptr) -> (ptr, string) { i = i + 1 } c = raw_encrypt(k, block) - bytes.free(block) - string.free(rnd) - return c, rerr + wipe_free(block) + return c, "" } // Decrypt + strip PKCS#1 v1.5 type-2 padding. Returns (message, "") or -// (null, err). (Not constant-time — a hardening follow-up.) -decrypt_pkcs1(k: ptr, ct: ptr) -> (ptr, string) { +// (null, err). The padding check reads every byte of the block and folds +// the verdict into masks (a Bleichenbacher oracle needs only the timing of +// an early exit); the verdict and, on success, the message length are +// public once returned and go through ct.declassify. +decrypt_pkcs1(k: ptr, ct_bytes: ptr) -> (ptr, string) { klen = modulus_bytes(k) - if sig_in_range(k, ct) != 1 { + if sig_in_range(k, ct_bytes) != 1 { return null, "ciphertext out of range" } - block = raw_decrypt(k, ct) - // expect 0x00 0x02 PS 0x00 M - ok = 1 - if bytes.get(block, 0) != 0x00 { ok = 0 } - if bytes.get(block, 1) != 0x02 { ok = 0 } - // find the 0x00 separator after >= 8 padding bytes - sep = -1 - i = 2 + block = raw_decrypt(k, ct_bytes) + if block == null { + return null, "rsa: not a private key, or no randomness for blinding" + } + // expect 0x00 0x02 PS 0x00 M, PS at least 8 bytes + int good = ct.eq(bytes.get(block, 0) & 255, 0x00) & ct.eq(bytes.get(block, 1) & 255, 0x02) + int found = 0 + int sep = 0 + int i = 2 while i < klen { - if bytes.get(block, i) == 0x00 { - sep = i - i = klen - } else { - i = i + 1 - } + int is_zero = ct.eq(bytes.get(block, i) & 255, 0) + int first = is_zero & (1 - found) + sep = ct.select(first, i, sep) + found = found | is_zero + i = i + 1 } - if sep < 0 { ok = 0 } - if ok == 1 { if sep < 10 { ok = 0 } } // 2 prefix + >=8 PS - if ok != 1 { - bytes.free(block) + good = good & found & (1 - ct.lt(sep, 10)) // 2 prefix + >= 8 PS + if ct.declassify(good) != 1 { + wipe_free(block) return null, "rsa: PKCS#1 v1.5 decryption error" } + sep = ct.declassify(sep) mlen = klen - sep - 1 out = bytes.new(mlen) - j = 0 - while j < mlen { - bytes.set(out, j, bytes.get(block, sep + 1 + j)) - j = j + 1 + if mlen > 0 { + bytes.copy_from_bytes(out, 0, block, sep + 1, mlen) } - bytes.free(block) + wipe_free(block) return out, "" } @@ -269,7 +781,10 @@ sign_pkcs1(k: ptr, di: ptr) -> (ptr, string) { } block = build_emsa(di, klen) s = raw_decrypt(k, block) // signing = private-key op (c^d mod n) - bytes.free(block) + bytes.wipe_free(block) + if s == null { + return null, "rsa: not a private key, or no randomness for blinding" + } return s, "" } @@ -289,7 +804,7 @@ fn sig_in_range_slice(k: ptr, sig: byte[]) -> int { if sig.len != modulus_bytes(k) { return 0 } owned = bytes.from_slice(sig) s = bignum.from_bytes_unsigned(owned, sig.len) - bytes.free(owned) + bytes.wipe_free(owned) ok = 1 if bignum.compare(s, kk.n) >= 0 { ok = 0 } bignum.free(s) @@ -312,7 +827,7 @@ verify_pkcs1(k: ptr, di: ptr, sig: byte[]) -> int { i = i + 1 } } - bytes.free(recovered); bytes.free(expected) + bytes.wipe_free(recovered); bytes.wipe_free(expected) return ok } @@ -338,47 +853,14 @@ build_emsa(di: ptr, klen: int) -> ptr { // hLen for SHA-256. oaep_hlen() -> int { return 32 } sha256b(data: byte[]) -> ptr { - ctx, err = sha2.new("sha256") - // Same shape as ed25519.sha512 was: sha2.new answers - // (null, "allocation failed") and update_bytes cannot take a null - // context (#1942). - if err != "" { - return null - } - sha2.update(ctx, data) - return sha2.final_bytes(ctx) + return hashb("sha256", data) } // ---- MGF1 (RFC 8017 B.2.1) with SHA-256 ---- // mask = MGF1(seed, mask_len): for counter = 0,1,...: T ||= SHA256(seed || I2OSP(counter,4)). mgf1(seed: byte[], mask_len: int) -> ptr { if seed.len < 0 { panic("rsa.mgf1: the `seed` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } - int seed_len = seed.len - out = bytes.new(mask_len) - produced = 0 - counter = 0 - while produced < mask_len { - // input = seed || counter (4-byte BE) - inlen = seed_len + 4 - inb = bytes.new(inlen) - bytes.copy_from_slice(inb, 0, seed[0..seed_len]) - bytes.set(inb, seed_len + 0, (counter >> 24) & 0xFF) - bytes.set(inb, seed_len + 1, (counter >> 16) & 0xFF) - bytes.set(inb, seed_len + 2, (counter >> 8) & 0xFF) - bytes.set(inb, seed_len + 3, counter & 0xFF) - h = sha256b(bytes.view(inb)[0..inlen]) - bytes.free(inb) - // copy min(hLen, remaining) into out - hl = bytes.length(h) - remaining = mask_len - produced - take = hl - if remaining < take { take = remaining } - bytes.copy_from_bytes(out, produced, h, 0, take) - bytes.free(h) - produced = produced + take - counter = counter + 1 - } - return out + return mgf1_hash("sha256", seed, mask_len) } // XOR src into dst over `len` bytes (dst modified in place). @@ -407,7 +889,7 @@ encrypt_oaep(k: ptr, msg: byte[], seed: ptr) -> ptr { // lHash = SHA256("") (empty label) empty = bytes.new(0) lhash = sha256b(bytes.view(empty)[0..0]) - bytes.free(empty) + bytes.wipe_free(empty) // DB = lHash || PS(0x00..) || 0x01 || M (length k - hLen - 1) dblen = klen - hlen - 1 @@ -418,54 +900,59 @@ encrypt_oaep(k: ptr, msg: byte[], seed: ptr) -> ptr { ps_end = dblen - mlen - 1 // index of the 0x01 separator bytes.set(db, ps_end, 0x01) if mlen > 0 { bytes.copy_from_slice(db, ps_end + 1, msg[0..mlen]) } - bytes.free(lhash) + bytes.wipe_free(lhash) // dbMask = MGF1(seed, dblen); maskedDB = DB ^ dbMask dbmask = mgf1(bytes.view(seed)[0..hlen], dblen) xor_into(db, bytes.view(dbmask)[0..dblen]) - bytes.free(dbmask) + bytes.wipe_free(dbmask) // seedMask = MGF1(maskedDB, hLen); maskedSeed = seed ^ seedMask seedmask = mgf1(bytes.view(db)[0..dblen], hlen) mseed = bytes.new(hlen) bytes.copy_from_bytes(mseed, 0, seed, 0, hlen) xor_into(mseed, bytes.view(seedmask)[0..hlen]) - bytes.free(seedmask) + bytes.wipe_free(seedmask) // EM = 0x00 || maskedSeed || maskedDB em = bytes.new(klen) bytes.set(em, 0, 0) bytes.copy_from_bytes(em, 1, mseed, 0, hlen) bytes.copy_from_bytes(em, 1 + hlen, db, 0, dblen) - bytes.free(mseed); bytes.free(db) + bytes.wipe_free(mseed); bytes.wipe_free(db) ct = raw_encrypt(k, em) - bytes.free(em) + bytes.wipe_free(em) return ct } // Decrypt OAEP ciphertext (k bytes). Returns (plaintext, "") or -// (null, error) on any padding/format failure. -decrypt_oaep(k: ptr, ct: ptr) -> { +// (null, error) on any padding/format failure. Every check reads all of the +// block and folds into one mask (Manger's attack needs only to tell the +// leading-byte check from the others); the verdict and the message length +// go through ct.declassify. +decrypt_oaep(k: ptr, ct_bytes: ptr) -> { klen = modulus_bytes(k) hlen = oaep_hlen() - if bytes.length(ct) != klen { + if bytes.length(ct_bytes) != klen { return null, "ciphertext length mismatch" } - if sig_in_range(k, ct) != 1 { + if sig_in_range(k, ct_bytes) != 1 { return null, "ciphertext out of range" } if klen < 2 * hlen + 2 { return null, "modulus too small for OAEP" } - em = raw_decrypt(k, ct) + em = raw_decrypt(k, ct_bytes) + if em == null { + return null, "rsa: not a private key, or no randomness for blinding" + } empty = bytes.new(0) lhash = sha256b(bytes.view(empty)[0..0]) - bytes.free(empty) + bytes.wipe_free(empty) - bad = 0 - if (bytes.get(em, 0) & 0xFF) != 0 { bad = 1 } // leading byte must be 0x00 + int good = ct.eq(bytes.get(em, 0) & 0xFF, 0) // leading byte must be 0x00 // split: maskedSeed = em[1..1+hLen], maskedDB = em[1+hLen..k] dblen = klen - hlen - 1 @@ -477,70 +964,129 @@ decrypt_oaep(k: ptr, ct: ptr) -> { // seed = maskedSeed ^ MGF1(maskedDB, hLen) seedmask = mgf1(bytes.view(db)[0..dblen], hlen) xor_into(mseed, bytes.view(seedmask)[0..hlen]) - bytes.free(seedmask) + bytes.wipe_free(seedmask) // DB = maskedDB ^ MGF1(seed, dblen) dbmask = mgf1(bytes.view(mseed)[0..hlen], dblen) xor_into(db, bytes.view(dbmask)[0..dblen]) - bytes.free(dbmask) + wipe_free(dbmask) // DB = lHash' || PS || 0x01 || M ; check lHash' == lHash - i = 0 + int diff = 0 + int i = 0 while i < hlen { - if (bytes.get(db, i) & 0xFF) != (bytes.get(lhash, i) & 0xFF) { bad = 1 } + diff = diff | ((bytes.get(db, i) ^ bytes.get(lhash, i)) & 0xFF) i = i + 1 } - bytes.free(lhash) + bytes.wipe_free(lhash) + good = good & ct.eq(diff, 0) - // find the 0x01 separator after the zero padding - sep = -1 - j = hlen + // the 0x01 separator after zero padding: any other byte before it is bad + int found = 0 + int sep = 0 + int stray = 0 + int j = hlen while j < dblen { - bv = bytes.get(db, j) & 0xFF - if sep == -1 { - if bv == 0x01 { - sep = j - } else if bv != 0x00 { - bad = 1 // non-zero byte before the separator - } - } + int bv = bytes.get(db, j) & 0xFF + int is_one = ct.eq(bv, 0x01) + int is_zero = ct.eq(bv, 0x00) + int before = 1 - found + sep = ct.select(is_one & before, j, sep) + stray = stray | (before & (1 - is_one) & (1 - is_zero)) + found = found | is_one j = j + 1 } - if sep == -1 { bad = 1 } + good = good & found & (1 - stray) - if bad == 1 { - bytes.free(em); bytes.free(mseed); bytes.free(db) + if ct.declassify(good) != 1 { + wipe_free(em); wipe_free(mseed); wipe_free(db) return null, "decryption error" } + sep = ct.declassify(sep) mlen = dblen - sep - 1 out = bytes.new(mlen) if mlen > 0 { bytes.copy_from_bytes(out, 0, db, sep + 1, mlen) } - bytes.free(em); bytes.free(mseed); bytes.free(db) + wipe_free(em); wipe_free(mseed); wipe_free(db) return out, "" } -// ---- RSASSA-PSS (RFC 8017 §8.1 / §9.1), SHA-256 ---- +// ---- RSASSA-PSS (RFC 8017 §8.1 / §9.1) ---- // Sign the SHA-256 `mhash` (32 bytes) with `salt` (slen bytes, caller- // supplied for determinism). Returns the k-byte signature, or null on error. sign_pss(k: ptr, mhash: ptr, salt: byte[]) -> ptr { if salt.len < 0 { panic("rsa.sign_pss: the `salt` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } + return sign_pss_hash(k, "sha256", mhash, salt) +} + +// Verify a SHA-256 PSS signature (verify_pss_hash with "sha256"). +verify_pss(k: ptr, mhash: ptr, sig: byte[], slen: int) -> int { + if sig.len < 0 { panic("rsa.verify_pss: the `sig` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } + return verify_pss_hash(k, "sha256", mhash, sig, slen) +} + +// The digest length of `algo` ("sha256", "sha384", "sha512"; 32 otherwise). +fn pss_hlen(algo: string) -> int { + if algo == "sha384" { return 48 } + if algo == "sha512" { return 64 } + return 32 +} + +// The `algo` digest of `data` (fresh bytes), or null. +fn hashb(algo: string, data: byte[]) -> ptr { + ctx, err = sha2.new(algo) + if err != "" { return null } + sha2.update(ctx, data) + return sha2.final_bytes(ctx) +} + +// MGF1 (RFC 8017 B.2.1) with `algo`. +fn mgf1_hash(algo: string, seed: byte[], mask_len: int) -> ptr { + int seed_len = seed.len + out = bytes.new(mask_len) + produced = 0 + counter = 0 + while produced < mask_len { + inb = bytes.new(seed_len + 4) + bytes.copy_from_slice(inb, 0, seed[0..seed_len]) + bytes.set(inb, seed_len + 0, (counter >> 24) & 0xFF) + bytes.set(inb, seed_len + 1, (counter >> 16) & 0xFF) + bytes.set(inb, seed_len + 2, (counter >> 8) & 0xFF) + bytes.set(inb, seed_len + 3, counter & 0xFF) + h = hashb(algo, bytes.view(inb)[0..seed_len + 4]) + bytes.wipe_free(inb) + take = bytes.length(h) + if mask_len - produced < take { take = mask_len - produced } + bytes.copy_from_bytes(out, produced, h, 0, take) + bytes.wipe_free(h) + produced = produced + take + counter = counter + 1 + } + return out +} + +// Sign the `algo` digest `mhash` ("sha256", "sha384" or "sha512"; MGF1 +// with the same hash) with `salt` (slen bytes, caller-supplied for +// determinism). Returns the k-byte signature, or null on error. TLS 1.3's +// rsa_pss_rsae_sha384 and _sha512 use it (#3357). +sign_pss_hash(k: ptr, algo: string, mhash: ptr, salt: byte[]) -> ptr { + if salt.len < 0 { panic("rsa.sign_pss_hash: the `salt` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } int slen = salt.len klen = modulus_bytes(k) - hlen = oaep_hlen() + hlen = pss_hlen(algo) embits = modulus_bits(k) - 1 emlen = (embits + 7) / 8 if emlen < hlen + slen + 2 { return null } - // M' = 0x00*8 || mHash || salt ; H = SHA256(M') + // M' = 0x00*8 || mHash || salt ; H = Hash(M') mp_len = 8 + hlen + slen mp = bytes.new(mp_len) z = 0 while z < 8 { bytes.set(mp, z, 0); z = z + 1 } bytes.copy_from_bytes(mp, 8, mhash, 0, hlen) if slen > 0 { bytes.copy_from_slice(mp, 8 + hlen, salt[0..slen]) } - h = sha256b(bytes.view(mp)[0..mp_len]) - bytes.free(mp) + h = hashb(algo, bytes.view(mp)[0..mp_len]) + bytes.wipe_free(mp) // DB = PS(0x00) || 0x01 || salt (length emLen - hLen - 1) dblen = emlen - hlen - 1 @@ -551,9 +1097,9 @@ sign_pss(k: ptr, mhash: ptr, salt: byte[]) -> ptr { if slen > 0 { bytes.copy_from_slice(db, dblen - slen, salt[0..slen]) } // maskedDB = DB ^ MGF1(H, dblen) - dbmask = mgf1(bytes.view(h)[0..hlen], dblen) + dbmask = mgf1_hash(algo, bytes.view(h)[0..hlen], dblen) xor_into(db, bytes.view(dbmask)[0..dblen]) - bytes.free(dbmask) + bytes.wipe_free(dbmask) // clear the leftmost (8*emLen - embits) bits of maskedDB clear_bits = 8 * emlen - embits if clear_bits > 0 { @@ -566,25 +1112,26 @@ sign_pss(k: ptr, mhash: ptr, salt: byte[]) -> ptr { bytes.copy_from_bytes(em, 0, db, 0, dblen) bytes.copy_from_bytes(em, dblen, h, 0, hlen) bytes.set(em, emlen - 1, 0xbc) - bytes.free(db); bytes.free(h) + bytes.wipe_free(db); bytes.wipe_free(h) // sig = raw_decrypt(EM as integer) — but emLen may be < klen; left-pad. emfull = bytes.new(klen) z = 0 while z < klen { bytes.set(emfull, z, 0); z = z + 1 } bytes.copy_from_bytes(emfull, klen - emlen, em, 0, emlen) - bytes.free(em) + bytes.wipe_free(em) sig = raw_decrypt(k, emfull) - bytes.free(emfull) + bytes.wipe_free(emfull) return sig } -// Verify a PSS signature `sig` (k bytes) over `mhash` (32 bytes) with the +// Verify a PSS signature `sig` (k bytes) over `mhash` (the `algo` digest: +// "sha256", "sha384" or "sha512"; MGF1 with the same hash) with the // expected salt length `slen`. Returns 1 if valid, 0 otherwise. -verify_pss(k: ptr, mhash: ptr, sig: byte[], slen: int) -> int { - if sig.len < 0 { panic("rsa.verify_pss: the `sig` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } +verify_pss_hash(k: ptr, algo: string, mhash: ptr, sig: byte[], slen: int) -> int { + if sig.len < 0 { panic("rsa.verify_pss_hash: the `sig` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } klen = modulus_bytes(k) - hlen = oaep_hlen() + hlen = pss_hlen(algo) embits = modulus_bits(k) - 1 emlen = (embits + 7) / 8 if sig.len != klen { return 0 } @@ -596,7 +1143,7 @@ verify_pss(k: ptr, mhash: ptr, sig: byte[], slen: int) -> int { // take the low emLen bytes as EM em = bytes.new(emlen) bytes.copy_from_bytes(em, 0, emfull, klen - emlen, emlen) - bytes.free(emfull) + bytes.wipe_free(emfull) ok = 1 if (bytes.get(em, emlen - 1) & 0xFF) != 0xbc { ok = 0 } @@ -615,9 +1162,9 @@ verify_pss(k: ptr, mhash: ptr, sig: byte[], slen: int) -> int { } // DB = maskedDB ^ MGF1(H, dblen) - dbmask = mgf1(bytes.view(h)[0..hlen], dblen) + dbmask = mgf1_hash(algo, bytes.view(h)[0..hlen], dblen) xor_into(maskeddb, bytes.view(dbmask)[0..dblen]) - bytes.free(dbmask) + bytes.wipe_free(dbmask) if clear_bits > 0 { mask = (0xFF >> clear_bits) & 0xFF bytes.set(maskeddb, 0, bytes.get(maskeddb, 0) & mask) @@ -631,20 +1178,20 @@ verify_pss(k: ptr, mhash: ptr, sig: byte[], slen: int) -> int { } if (bytes.get(maskeddb, dblen - slen - 1) & 0xFF) != 0x01 { ok = 0 } - // salt = last slen bytes of DB; recompute H' = SHA256(0^8 || mHash || salt) + // salt = last slen bytes of DB; recompute H' = Hash(0^8 || mHash || salt) mp_len = 8 + hlen + slen mp = bytes.new(mp_len) z = 0 while z < 8 { bytes.set(mp, z, 0); z = z + 1 } bytes.copy_from_bytes(mp, 8, mhash, 0, hlen) if slen > 0 { bytes.copy_from_bytes(mp, 8 + hlen, maskeddb, dblen - slen, slen) } - hp = sha256b(bytes.view(mp)[0..mp_len]) - bytes.free(mp) + hp = hashb(algo, bytes.view(mp)[0..mp_len]) + bytes.wipe_free(mp) i = 0 while i < hlen { if (bytes.get(hp, i) & 0xFF) != (bytes.get(h, i) & 0xFF) { ok = 0 } i = i + 1 } - bytes.free(hp); bytes.free(h); bytes.free(maskeddb); bytes.free(em) + bytes.wipe_free(hp); bytes.wipe_free(h); bytes.wipe_free(maskeddb); bytes.wipe_free(em) return ok } diff --git a/std/cryptography/scrypt/module.ae b/std/cryptography/scrypt/module.ae index fceb763cd..13d138397 100644 --- a/std/cryptography/scrypt/module.ae +++ b/std/cryptography/scrypt/module.ae @@ -221,7 +221,7 @@ scrypt(pass: byte[], salt: byte[], n: int, r: int, p: int, dklen: int) -> ptr { xbuf = bytes.new(64) bx = bytes.new(block_bytes) if v == null { - bytes.free(b) + bytes.wipe_free(b) return null } @@ -268,14 +268,14 @@ scrypt(pass: byte[], salt: byte[], n: int, r: int, p: int, dklen: int) -> ptr { pi = pi + 1 } - bytes.free(v) - bytes.free(tmp) - bytes.free(xbuf) - bytes.free(bx) + bytes.wipe_free(v) + bytes.wipe_free(tmp) + bytes.wipe_free(xbuf) + bytes.wipe_free(bx) // DK = PBKDF2-HMAC-SHA256(P, B, 1, dkLen) out = pbkdf2.pbkdf2("sha256", pass, bytes.view(b)[0..total], 1, dklen) - bytes.free(b) + bytes.wipe_free(b) return out } @@ -302,6 +302,6 @@ scrypt_hex(pass: byte[], salt: byte[], n: int, r: int, p: int, dklen: int) -> st bytes.set(hexbuf, i * 2 + 1, c_lo) i = i + 1 } - bytes.free(out) + bytes.wipe_free(out) return bytes.finish(hexbuf, dklen * 2) } diff --git a/std/cryptography/secp256k1/module.ae b/std/cryptography/secp256k1/module.ae index 37cc1aa31..ec02d335c 100644 --- a/std/cryptography/secp256k1/module.ae +++ b/std/cryptography/secp256k1/module.ae @@ -2,372 +2,76 @@ // Portions copyright (c) 2000-2026 The Legion of the Bouncy Castle Inc. (https://www.bouncycastle.org) // Portions copyright (c) 2026 Aether Developers. // -// std.cryptography.secp256k1 — SEC2 secp256k1 (the Bitcoin / Ethereum -// curve) in pure Aether, on top of std.bignum. NO externs to OpenSSL. +// std.cryptography.secp256k1: SEC2 secp256k1 (the Bitcoin / Ethereum +// curve) in pure Aether, constant time. NO externs to OpenSSL. // -// Short-Weierstrass curve y^2 = x^3 + 7 over GF(p) (a = 0, b = 7). Point -// arithmetic is in Jacobian coordinates (X:Y:Z) with affine -// (x,y) = (X/Z^2, Y/Z^3). Because a = 0, the doubling formula drops the -// "-3x" / alpha-from-a term that NIST P-256 carries. +// Short-Weierstrass curve y^2 = x^3 + 7 over GF(p), p = 2^256 - 2^32 - 977 +// (a = 0, b = 7), on std.cryptography.ecc with the a = 0 complete formulas +// (see std.cryptography.p256 for how the arithmetic is done). // // scalar_mult_base(k32) -> (x32, y32) k*G (public key point) // scalar_mult(k32, px32, py32) -> (x32, y32) k*P // ecdh(priv32, pubx32, puby32) -> shared_x32 priv * pub, x only -// ecdsa_sign(priv32, hash32, k32) -> (r32, s32) k = per-message nonce -// ecdsa_verify(pubx, puby, hash32, r32, s32) -> int 1 = valid +// ecdsa_sign(priv32, hash) -> (r32, s32) RFC 6979 nonce +// ecdsa_sign_hedged(priv32, hash, extra) -> (r32, s32) RFC 6979 3.6, extra mixed in +// ecdsa_verify(pubx, puby, hash, r32, s32) -> int 1 = valid +// public_key_valid(pubx32, puby32) -> int 1 = a point of the curve +// private_key_valid(priv32) -> int 1 = in [1, n-1] // // 32-byte big-endian scalars / coordinates (the SEC1 wire order). // -// NOTE: correctness-first port over the variable-time std.bignum — not -// constant-time/side-channel-hardened, and ecdsa_sign takes the nonce `k` -// as a parameter (caller supplies a CSPRNG value or an RFC 6979 derivation). -// Validated against the priv=1 -> G and priv=2 -> 2G generator vectors, an -// ECDSA sign/verify round-trip, and an ECDH round-trip. +// `hash` is the message digest, of any length (its leftmost 256 bits are +// signed). The nonce is RFC 6979's, derived with HMAC-SHA-224/256/384/512 +// when the digest is 28/32/48/64 bytes long and HMAC-SHA-256 otherwise. s is +// returned as computed, not normalized to the lower half: a caller that +// needs Bitcoin's low-S form replaces s with n - s when s > n/2. +// +// Constant time in the private key and the nonce (sign, ecdh, scalar_mult*); +// ecdsa_verify is not, and needs not be. A bad key, scalar length or public +// point gives null (or (null, null)); ecdsa_verify gives 0. +// +// Measured with the default `ae build` (-O2) on an x86-64 Windows desktop, +// per operation: sign 340 us, verify 370 us, ecdh 285 us (the bignum +// implementation this replaced: 3800, 7850 and 3750 us). import std.bytes -import std.bignum +import std.cryptography.ecc exports( - scalar_mult_base, scalar_mult, ecdh, ecdsa_sign, ecdsa_verify + scalar_mult_base, scalar_mult, ecdh, ecdsa_sign, ecdsa_sign_hedged, ecdsa_verify, + public_key_valid, private_key_valid ) -extern malloc(size: int) -> ptr -@extern("free") libc_free(p: ptr) -extern string_char_at_n(str: string, known_length: int, index: int) -> int -extern strlen(s: string) -> int - -// ---- curve parameters ---- -P_HEX() -> string { return "fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f" } -N_HEX() -> string { return "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141" } -GX_HEX() -> string { return "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" } -GY_HEX() -> string { return "483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8" } - -hexv(c: int) -> int { - if c >= 48 { if c <= 57 { return c - 48 } } - if c >= 97 { if c <= 102 { return c - 87 } } - if c >= 65 { if c <= 70 { return c - 55 } } - return 0 -} -bn_hex(h: string) -> ptr { - n = strlen(h) - b = bytes.new(n / 2) - i = 0 - while i < n { - hi = hexv(string_char_at_n(h, n, i)) - lo = hexv(string_char_at_n(h, n, i + 1)) - bytes.set(b, i / 2, hi * 16 + lo) - i = i + 2 - } - r = bignum.from_bytes_unsigned(b, n / 2) - bytes.free(b) - return r -} - -// ---- field ops mod p ---- -fadd(a: ptr, b: ptr, p: ptr) -> ptr { s = bignum.add(a, b); r = bignum.mod(s, p); bignum.free(s); return r } -fsub(a: ptr, b: ptr, p: ptr) -> ptr { d = bignum.subtract(a, b); r = bignum.mod(d, p); bignum.free(d); return r } -fmul(a: ptr, b: ptr, p: ptr) -> ptr { m = bignum.multiply(a, b); r = bignum.mod(m, p); bignum.free(m); return r } -finv(a: ptr, p: ptr) -> ptr { - two = bignum.from_int(2) - e = bignum.subtract(p, two) - r = bignum.mod_pow(a, e, p) - bignum.free(two); bignum.free(e) - return r -} - -// 32-byte big-endian encode / decode. -to_be32(nn: ptr) -> ptr { - be = bignum.to_bytes_unsigned(nn) - blen = bytes.length(be) - out = bytes.new(32) - i = 0 - while i < 32 { bytes.set(out, i, 0); i = i + 1 } - i = 0 - while i < blen { - idx = 32 - blen + i - if idx >= 0 { bytes.set(out, idx, bytes.get(be, i)) } - i = i + 1 - } - bytes.free(be) - return out -} -from_be32(b: ptr) -> ptr { - return bignum.from_bytes_unsigned(b, 32) -} - -// ---- Jacobian point (X:Y:Z) ---- -struct Pt { x: ptr y: ptr z: ptr } -pt_new(x: @keep ptr, y: @keep ptr, z: @keep ptr) -> *Pt { q = malloc(sizeof(Pt)) as *Pt; q.x = x; q.y = y; q.z = z; return q } -pt_free(q: @frees *Pt) { bignum.free(q.x); bignum.free(q.y); bignum.free(q.z); libc_free(q) } - -// Point at infinity: Z = 0. -pt_infinity() -> *Pt { return pt_new(bignum.from_int(1), bignum.from_int(1), bignum.from_int(0)) } -is_infinity(q: *Pt) -> int { return bignum.is_zero(q.z) } - -// Point doubling (Jacobian) for a = 0 ("dbl-2009-l" specialized to a=0): -// A = X^2; B = Y^2; C = B^2 -// D = 2*((X+B)^2 - A - C) -// E = 3*A -// F = E^2 -// X3 = F - 2*D -// Y3 = E*(D - X3) - 8*C -// Z3 = 2*Y*Z -pt_double(q: *Pt, p: ptr) -> *Pt { - if is_infinity(q) == 1 { return pt_infinity() } - if bignum.is_zero(q.y) == 1 { return pt_infinity() } - - two = bignum.from_int(2) - three = bignum.from_int(3) - eight = bignum.from_int(8) - - A = fmul(q.x, q.x, p) - B = fmul(q.y, q.y, p) - C = fmul(B, B, p) - // D = 2*((X+B)^2 - A - C) - xpb = fadd(q.x, B, p) - xpb2 = fmul(xpb, xpb, p) - t0 = fsub(xpb2, A, p) - t1 = fsub(t0, C, p) - D = fmul(two, t1, p) - // E = 3*A ; F = E^2 - E = fmul(three, A, p) - F = fmul(E, E, p) - // X3 = F - 2*D - twoD = fmul(two, D, p) - X3 = fsub(F, twoD, p) - // Y3 = E*(D - X3) - 8*C - dmx = fsub(D, X3, p) - edmx = fmul(E, dmx, p) - eightC = fmul(eight, C, p) - Y3 = fsub(edmx, eightC, p) - // Z3 = 2*Y*Z - yz = fmul(q.y, q.z, p) - Z3 = fmul(two, yz, p) - - bignum.free(two); bignum.free(three); bignum.free(eight) - bignum.free(A); bignum.free(B); bignum.free(C) - bignum.free(xpb); bignum.free(xpb2); bignum.free(t0); bignum.free(t1); bignum.free(D) - bignum.free(E); bignum.free(F); bignum.free(twoD) - bignum.free(dmx); bignum.free(edmx); bignum.free(eightC) - bignum.free(yz) - return pt_new(X3, Y3, Z3) -} - -// Point addition (Jacobian), "add-2007-bl". Handles infinity operands and the -// doubling case (falls back to pt_double). -pt_add(a: *Pt, b: *Pt, p: ptr) -> *Pt { - if is_infinity(a) == 1 { return pt_new(bignum.clone(b.x), bignum.clone(b.y), bignum.clone(b.z)) } - if is_infinity(b) == 1 { return pt_new(bignum.clone(a.x), bignum.clone(a.y), bignum.clone(a.z)) } - // U1 = X1*Z2^2; U2 = X2*Z1^2 - z1z1 = fmul(a.z, a.z, p) - z2z2 = fmul(b.z, b.z, p) - U1 = fmul(a.x, z2z2, p) - U2 = fmul(b.x, z1z1, p) - // S1 = Y1*Z2^3; S2 = Y2*Z1^3 - z2z2z2 = fmul(z2z2, b.z, p) - z1z1z1 = fmul(z1z1, a.z, p) - S1 = fmul(a.y, z2z2z2, p) - S2 = fmul(b.y, z1z1z1, p) - - H = fsub(U2, U1, p) - rr = fsub(S2, S1, p) - - if bignum.is_zero(H) == 1 { - if bignum.is_zero(rr) == 1 { - bignum.free(z1z1); bignum.free(z2z2); bignum.free(U1); bignum.free(U2) - bignum.free(z2z2z2); bignum.free(z1z1z1); bignum.free(S1); bignum.free(S2) - bignum.free(H); bignum.free(rr) - return pt_double(a, p) - } - bignum.free(z1z1); bignum.free(z2z2); bignum.free(U1); bignum.free(U2) - bignum.free(z2z2z2); bignum.free(z1z1z1); bignum.free(S1); bignum.free(S2) - bignum.free(H); bignum.free(rr) - return pt_infinity() - } - - HH = fmul(H, H, p) - HHH = fmul(H, HH, p) - V = fmul(U1, HH, p) - r2 = fmul(rr, rr, p) - two = bignum.from_int(2) - twoV = fmul(two, V, p) - t = fsub(r2, HHH, p) - X3 = fsub(t, twoV, p) - vmx = fsub(V, X3, p) - rvmx = fmul(rr, vmx, p) - s1h = fmul(S1, HHH, p) - Y3 = fsub(rvmx, s1h, p) - z1z2 = fmul(a.z, b.z, p) - Z3 = fmul(z1z2, H, p) - - bignum.free(z1z1); bignum.free(z2z2); bignum.free(U1); bignum.free(U2) - bignum.free(z2z2z2); bignum.free(z1z1z1); bignum.free(S1); bignum.free(S2) - bignum.free(H); bignum.free(rr); bignum.free(HH); bignum.free(HHH); bignum.free(V) - bignum.free(r2); bignum.free(two); bignum.free(twoV); bignum.free(t) - bignum.free(vmx); bignum.free(rvmx); bignum.free(s1h); bignum.free(z1z2) - return pt_new(X3, Y3, Z3) -} - -// k * P (double-and-add over bits of the scalar k, a *BN). -jac_mul(k: ptr, P: *Pt, p: ptr) -> *Pt { - q = pt_infinity() - nbits = bignum.bit_length(k) - i = nbits - 1 - while i >= 0 { - dbl = pt_double(q, p) - pt_free(q) - q = dbl - ki = bignum.shift_right(k, i) - if bit0(ki) == 1 { - sum = pt_add(q, P, p) - pt_free(q) - q = sum - } - bignum.free(ki) - i = i - 1 - } - return q -} - -bit0(n: ptr) -> int { - if bignum.is_zero(n) == 1 { return 0 } - two = bignum.from_int(2) - r = bignum.mod(n, two) - b = 0 - if bignum.is_zero(r) != 1 { b = 1 } - bignum.free(two); bignum.free(r) - return b -} - -// Jacobian -> affine (x, y) as two *BN. Returns (null, null) for infinity. -jac_to_affine(q: *Pt, p: ptr) -> (ptr, ptr) { - if is_infinity(q) == 1 { - return null, null - } - zinv = finv(q.z, p) - zinv2 = fmul(zinv, zinv, p) - zinv3 = fmul(zinv2, zinv, p) - x = fmul(q.x, zinv2, p) - y = fmul(q.y, zinv3, p) - bignum.free(zinv); bignum.free(zinv2); bignum.free(zinv3) - return x, y -} - -base_point() -> *Pt { return affine_to_jac_hex(GX_HEX(), GY_HEX()) } -affine_to_jac_hex(xh: string, yh: string) -> *Pt { - x = bn_hex(xh); y = bn_hex(yh) - q = pt_new(x, y, bignum.from_int(1)) - return q -} - -// ---- public API ---- - -// k * G as affine (x, y) 32-byte big-endian pair. scalar_mult_base(k: ptr) -> (ptr, ptr) { - p = bn_hex(P_HEX()) - G = base_point() - kn = from_be32(k) - Q = jac_mul(kn, G, p) - x, y = jac_to_affine(Q, p) - ox = to_be32(x); oy = to_be32(y) - bignum.free(x); bignum.free(y) - pt_free(Q); pt_free(G); bignum.free(kn); bignum.free(p) - return ox, oy + return ecc.scalar_mult_base(ecc.SECP256K1, k) } -// k * (px, py) as affine (x, y) 32-byte big-endian pair. scalar_mult(k: ptr, px: ptr, py: ptr) -> (ptr, ptr) { - p = bn_hex(P_HEX()) - bx = from_be32(px); by = from_be32(py) - P = pt_new(bx, by, bignum.from_int(1)) - kn = from_be32(k) - Q = jac_mul(kn, P, p) - x, y = jac_to_affine(Q, p) - ox = to_be32(x); oy = to_be32(y) - bignum.free(x); bignum.free(y) - pt_free(Q); pt_free(P); bignum.free(kn); bignum.free(p) - return ox, oy + return ecc.scalar_mult(ecc.SECP256K1, k, px, py) } -// ECDH: priv * pub, returns the shared x-coordinate (32 bytes BE). ecdh(priv: ptr, pubx: ptr, puby: ptr) -> ptr { - sx, sy = scalar_mult(priv, pubx, puby) - bytes.free(sy) - return sx + return ecc.ecdh(ecc.SECP256K1, priv, pubx, puby) } -// ECDSA sign: r = (k*G).x mod n; s = k^-1 (z + r*d) mod n. -ecdsa_sign(priv: ptr, hash: ptr, k: ptr) -> (ptr, ptr) { - p = bn_hex(P_HEX()) - n = bn_hex(N_HEX()) - G = base_point() - - kn = from_be32(k) - Q = jac_mul(kn, G, p) - qx, qy = jac_to_affine(Q, p) - r = bignum.mod(qx, n) - - d = from_be32(priv) - z = from_be32(hash) - zmod = bignum.mod(z, n) - - kinv = bignum.mod_inverse(kn, n) - rd = bignum.multiply(r, d) - zrd = bignum.add(zmod, rd) - ks = bignum.multiply(kinv, zrd) - s = bignum.mod(ks, n) - - or = to_be32(r); os = to_be32(s) +ecdsa_sign(priv: ptr, hash: ptr) -> (ptr, ptr) { + return ecc.ecdsa_sign(ecc.SECP256K1, priv, hash) +} - bignum.free(qx); bignum.free(qy); bignum.free(r); bignum.free(d) - bignum.free(z); bignum.free(zmod); bignum.free(kinv); bignum.free(rd) - bignum.free(zrd); bignum.free(ks); bignum.free(s); bignum.free(kn) - pt_free(Q); pt_free(G); bignum.free(p); bignum.free(n) - return or, os +// The same with a hedged nonce: `extra` (fresh random bytes) mixed into the +// RFC 6979 seed, as ecc.ecdsa_sign_hedged describes (#3376). +ecdsa_sign_hedged(priv: ptr, hash: ptr, extra: ptr) -> (ptr, ptr) { + return ecc.ecdsa_sign_hedged(ecc.SECP256K1, priv, hash, extra) } -// ECDSA verify: returns 1 if (r,s) is a valid signature of `hash` under -// public key (pubx, puby), else 0. ecdsa_verify(pubx: ptr, puby: ptr, hash: ptr, r: ptr, s: ptr) -> int { - p = bn_hex(P_HEX()) - n = bn_hex(N_HEX()) - G = base_point() - - rn = from_be32(r) - sn = from_be32(s) - zero = bignum.from_int(0) - - ok = 1 - if bignum.compare(rn, zero) <= 0 { ok = 0 } - if bignum.compare(sn, zero) <= 0 { ok = 0 } - if bignum.compare(rn, n) >= 0 { ok = 0 } - if bignum.compare(sn, n) >= 0 { ok = 0 } - - result = 0 - if ok == 1 { - z = from_be32(hash) - zmod = bignum.mod(z, n) - sinv = bignum.mod_inverse(sn, n) - u1m = bignum.multiply(zmod, sinv); u1 = bignum.mod(u1m, n) - u2m = bignum.multiply(rn, sinv); u2 = bignum.mod(u2m, n) - - Pub = pt_new(from_be32(pubx), from_be32(puby), bignum.from_int(1)) - A = jac_mul(u1, G, p) - Bp = jac_mul(u2, Pub, p) - R = pt_add(A, Bp, p) - rx, ry = jac_to_affine(R, p) - if rx != null { - v = bignum.mod(rx, n) - if bignum.compare(v, rn) == 0 { result = 1 } - bignum.free(v); bignum.free(rx); bignum.free(ry) - } + return ecc.ecdsa_verify(ecc.SECP256K1, pubx, puby, hash, r, s) +} - bignum.free(z); bignum.free(zmod); bignum.free(sinv) - bignum.free(u1m); bignum.free(u1); bignum.free(u2m); bignum.free(u2) - pt_free(Pub); pt_free(A); pt_free(Bp); pt_free(R) - } +public_key_valid(pubx: ptr, puby: ptr) -> int { + return ecc.public_key_valid(ecc.SECP256K1, pubx, puby) +} - bignum.free(rn); bignum.free(sn); bignum.free(zero) - pt_free(G); bignum.free(p); bignum.free(n) - return result +private_key_valid(priv: ptr) -> int { + return ecc.private_key_valid(ecc.SECP256K1, priv) } diff --git a/std/cryptography/sha1/module.ae b/std/cryptography/sha1/module.ae index 1f1ffc14f..af16db983 100644 --- a/std/cryptography/sha1/module.ae +++ b/std/cryptography/sha1/module.ae @@ -9,6 +9,7 @@ import std.bits import std.bytes +import std.mem extern malloc(size: int) -> ptr extern free(p: ptr) @@ -251,7 +252,7 @@ finalize_to_bytes(c: *Sha1Ctx) -> ptr { free_internals(c: *Sha1Ctx) { if c.block != null { - bytes.free(c.block) + bytes.wipe_free(c.block) c.block = null } } @@ -261,6 +262,7 @@ final_bytes(ctx: ptr) -> ptr { out = finalize_to_bytes(c) s = bytes.finish(out, 20) free_internals(c) + mem.wipe(ctx, sizeof(Sha1Ctx)) free(ctx) res = bytes.new(20) bytes.copy_from_string(res, 0, s, 20) @@ -288,9 +290,10 @@ final_hex(ctx: ptr) -> string { bytes.set(hexbuf, i * 2 + 1, c_lo) i = i + 1 } - bytes.free(out) + bytes.wipe_free(out) s = bytes.finish(hexbuf, 40) free_internals(c) + mem.wipe(ctx, sizeof(Sha1Ctx)) free(ctx) return s } @@ -301,6 +304,7 @@ free_ctx(ctx: ptr) { } c = ctx as *Sha1Ctx free_internals(c) + mem.wipe(ctx, sizeof(Sha1Ctx)) free(ctx) } diff --git a/std/cryptography/sha2/module.ae b/std/cryptography/sha2/module.ae index d3d74479f..cd63dadea 100644 --- a/std/cryptography/sha2/module.ae +++ b/std/cryptography/sha2/module.ae @@ -26,6 +26,7 @@ import std.bits import std.bytes +import std.mem extern malloc(size: int) -> ptr extern free(p: ptr) @@ -378,6 +379,7 @@ new(algo: string) -> { ctx.h7 = 0x0eb72ddc81c52ca2 ctx.digest_len = 32 } else { + mem.wipe(ctx, sizeof(Sha2Ctx)) free(ctx) return null, "unknown algorithm" } @@ -534,20 +536,22 @@ emit_be64_words(c: *Sha2Ctx, out: ptr) { } } -// Finalize and return the raw digest bytes as an owned AetherString of -// digest_len bytes. FREES the context. +// Finalize and return the raw digest as a std.bytes buffer of digest_len +// bytes. FREES the context. +// +// The digest stays in the byte buffer it was written to. It used to go +// through a string (bytes.finish) and back (bytes.copy_from_string), whose C +// side asks whether its argument is a string by reading its first bytes, +// which here are the digest: a branch on HMAC's secret state, which the +// constant-time test found in RFC 6979's HMAC-DRBG (#2600), and a misread +// for any digest that starts like a string header. final_bytes(ctx: ptr) -> ptr { c = ctx as *Sha2Ctx out = finalize_to_bytes(c) - dl = c.digest_len - s = bytes.finish(out, dl) - free_internals(c) - free(ctx) - // Wrap the AetherString back into a bytes buffer so callers get a - // ptr (matching the requested signature). Length is dl. - res = bytes.new(dl) - bytes.copy_from_string(res, 0, s, dl) - return res + // SHA-512/224 serializes four whole 64-bit words: keep digest_len. + bytes.set_length(out, c.digest_len) + wipe_ctx_free(ctx) + return out } // Finalize and return the digest as lowercase hex. FREES the context. @@ -573,29 +577,38 @@ final_hex(ctx: ptr) -> string { bytes.set(hexbuf, i * 2 + 1, c_lo) i = i + 1 } - bytes.free(out) + bytes.wipe_free(out) s = bytes.finish(hexbuf, dl * 2) - free_internals(c) - free(ctx) + wipe_ctx_free(ctx) return s } free_internals(c: *Sha2Ctx) { if c.block != null { - bytes.free(c.block) + bytes.wipe_free(c.block) c.block = null } } +// Free a context, wiped first (#3402): its hash words and schedule are a +// digest and the last block of whatever it hashed, its buffer the block in +// progress, and an HMAC key, a private key's seed or a DRBG's state among +// what it hashed. mem.wipe, as a zeroing before a free is otherwise a dead +// store the C compiler may drop. +fn wipe_ctx_free(ctx: ptr) { + c = ctx as *Sha2Ctx + free_internals(c) + mem.wipe(ctx, sizeof(Sha2Ctx)) + free(ctx) +} + // Abandon a context without finalizing — the bail-out cleanup path. // NULL-safe. After final_hex / final_bytes the context is already freed. free_ctx(ctx: ptr) { if ctx == null { return } - c = ctx as *Sha2Ctx - free_internals(c) - free(ctx) + wipe_ctx_free(ctx) } // ---- One-shot convenience wrappers ---- diff --git a/std/cryptography/sha3/module.ae b/std/cryptography/sha3/module.ae index b792cd704..1dc08eec9 100644 --- a/std/cryptography/sha3/module.ae +++ b/std/cryptography/sha3/module.ae @@ -33,6 +33,7 @@ import std.bits import std.bytes +import std.mem extern malloc(size: int) -> ptr extern free(p: ptr) @@ -252,7 +253,8 @@ new(variant: string) -> { ctx.buf_len = 0 ok = config_variant(ctx, variant) if ok == 0 { - bytes.free(ctx.buf) + bytes.wipe_free(ctx.buf) + mem.wipe(ctx, sizeof(Sha3Ctx)) free(ctx) return null, "unknown sha3 variant" } @@ -332,11 +334,22 @@ squeeze(c: *Sha3Ctx, n: int) -> ptr { free_internals(c: *Sha3Ctx) { if c.buf != null { - bytes.free(c.buf) + bytes.wipe_free(c.buf) c.buf = null } } +// Free a context, wiped first (#3402): the lane state and the scratch are +// a function of whatever it absorbed, and the rate buffer the block in +// progress; Ed448's seed among what it absorbs. mem.wipe, as a zeroing +// before a free is otherwise a dead store the C compiler may drop. +fn wipe_ctx_free(ctx: ptr) { + c = ctx as *Sha3Ctx + free_internals(c) + mem.wipe(ctx, sizeof(Sha3Ctx)) + free(ctx) +} + // Abandon a context WITHOUT finalizing — the bail-out cleanup path. // NULL-safe. final_hex / final_bytes already free the ctx, so free_ctx // must be called ONLY when bailing out before a finalize. @@ -344,9 +357,7 @@ free_ctx(ctx: ptr) { if ctx == null { return } - c = ctx as *Sha3Ctx - free_internals(c) - free(ctx) + wipe_ctx_free(ctx) } // `out_len` is ignored for fixed digests (uses the variant's length) and @@ -359,13 +370,14 @@ final_bytes(ctx: ptr, out_len: int) -> ptr { n = out_len } do_pad(c) + // The output stays in the buffer it was squeezed into: it used to go + // through a string and back, and a secret (Ed448's expanded key and + // nonce are SHAKE256 output) does not belong in a string (#2600, the + // constant-time test's finding; see sha2.final_bytes). out = squeeze(c, n) - s = bytes.finish(out, n) - free_internals(c) - free(ctx) - res = bytes.new(n) - bytes.copy_from_string(res, 0, s, n) - return res + bytes.set_length(out, n) + wipe_ctx_free(ctx) + return out } // Finalize and return the digest as lowercase hex. FREES the ctx; do @@ -396,10 +408,9 @@ final_hex(ctx: ptr, out_len: int) -> string { bytes.set(hexbuf, i * 2 + 1, c_lo) i = i + 1 } - bytes.free(out) + bytes.wipe_free(out) s = bytes.finish(hexbuf, n * 2) - free_internals(c) - free(ctx) + wipe_ctx_free(ctx) return s } diff --git a/std/cryptography/shield/module.ae b/std/cryptography/shield/module.ae new file mode 100644 index 000000000..abfe37f7c --- /dev/null +++ b/std/cryptography/shield/module.ae @@ -0,0 +1,125 @@ +// MIT License (https://opensource.org/licenses/MIT) +// Portions copyright (c) 2026 Aether Developers. +// +// std.cryptography.shield: a long-lived secret (a private key) held +// encrypted in memory, in the clear only while it is used (#3376). +// +// A key a server keeps for its whole life sits at one address for as long +// as the process runs, which is what memory-reading attacks need: RAMBleed +// (Rowhammer used to read, Kwong et al., IEEE S&P 2020, which recovered an +// RSA key from OpenSSH), Spectre-class reads, a core dump. Shielding, as +// OpenSSH has done since 8.2: +// +// wrap(secret) a fresh 16 KiB random pre-key; the secret sealed with +// ChaCha20-Poly1305 under SHA-512(pre-key) (its first 32 +// bytes the key, the next 12 the nonce); the plaintext +// wiped. -> (handle, "") or (null, why) +// unwrap(handle) SHA-512 of the pre-key again, the secret opened into a +// fresh buffer the caller wipes after the one use +// (bytes.wipe_free). -> (secret, "") or (null, why) +// free(handle) the pre-key and the sealed secret wiped and freed +// +// To read the secret, an attacker must read the whole pre-key without a +// single wrong bit, as well as the sealed secret: far beyond what those +// channels give, where a plain key needs only its own few hundred bits. The +// plaintext exists for the length of one use. The Poly1305 tag also catches +// a bit that flipped in the sealed secret or the pre-key: unwrap refuses +// it, so a corrupted key is never used. The cost per unwrap is one SHA-512 +// over 16 KiB and the decryption of the secret. +// +// What it does not do: lock the pre-key in memory (mlock), keep it out of +// core dumps (MADV_DONTDUMP) or out of the kernel's direct map +// (memfd_secret). Hardware memory encryption (Intel TME, AMD SME) stops +// RAMBleed outright, and is the operator's choice. + +import std.bytes +import std.cryptography +import std.cryptography.sha2 +import std.cryptography.chacha20poly1305 + +exports( + wrap, unwrap, free, PREKEY_SIZE, Shielded +) + +// The pre-key's size, OpenSSH's (SSHKEY_SHIELD_PREKEY_LEN). +const PREKEY_SIZE = 16384 + +// What a handle points to. Its fields are the module's; they are exported +// so a test can show a changed bit is refused. +struct Shielded { + prekey: ptr // PREKEY_SIZE random bytes + sealed: ptr // the secret under ChaCha20-Poly1305, its tag appended +} + +// The AEAD key and nonce from the pre-key: SHA-512(prekey), the first 32 +// bytes the key and the next 12 the nonce. Each wrap has its own pre-key, +// so a nonce never serves two keys. The caller wipes both. +fn derive(prekey: ptr) -> (ptr, ptr) { + h = sha2.sha512_bytes(bytes.view(prekey)) + if h == null { return null, null } + key = bytes.new(32) + nonce = bytes.new(12) + bytes.copy_from_bytes(key, 0, h, 0, 32) + bytes.copy_from_bytes(nonce, 0, h, 32, 12) + bytes.wipe_free(h) + return key, nonce +} + +// Seal `secret` (a std.bytes buffer, consumed: wiped and freed on every +// path) behind a fresh pre-key. (handle, "") or (null, why): no randomness, +// or an empty or null secret. +wrap(secret: @frees ptr) -> (ptr, string) { + if secret == null { return null, "the secret is null" } + n = bytes.length(secret) + if n < 1 { + bytes.wipe_free(secret) + return null, "the secret is empty" + } + prekey, rerr = cryptography.random_buffer(PREKEY_SIZE) + if rerr != "" || prekey == null { + bytes.wipe_free(secret) + return null, "no randomness for the pre-key: ${rerr}" + } + key, nonce = derive(prekey) + if key == null { + bytes.wipe_free(prekey) + bytes.wipe_free(secret) + return null, "SHA-512 is not available" + } + sealed = chacha20poly1305.aead_seal(key, nonce, bytes.view(prekey)[0..0], bytes.view(secret)) + bytes.wipe_free(key) + bytes.wipe_free(nonce) + bytes.wipe_free(secret) + h = heap.new(Shielded) + h.prekey = prekey + h.sealed = sealed + return h as ptr, "" +} + +// The secret, in a fresh buffer the caller wipes with bytes.wipe_free as +// soon as it is used; (null, why) when the handle is null or the sealed +// secret or the pre-key changed in memory (the tag does not hold), in +// which case the secret must not be used. +unwrap(handle: @keep ptr) -> (ptr, string) { + if handle == null { return null, "the handle is null" } + h = handle as *Shielded + key, nonce = derive(h.prekey) + if key == null { return null, "SHA-512 is not available" } + secret, oerr = chacha20poly1305.aead_open(key, nonce, bytes.view(h.prekey)[0..0], bytes.view(h.sealed)) + bytes.wipe_free(key) + bytes.wipe_free(nonce) + if oerr != "" { + if secret != null { bytes.wipe_free(secret) } + return null, "the shielded secret changed in memory (${oerr}): it is not used" + } + return secret, "" +} + +// Wipe and free what wrap made. Idempotent on null. +free(handle: @frees ptr) { + if handle == null { return } + h = handle as *Shielded + bytes.wipe_free(h.prekey) + bytes.wipe_free(h.sealed) + heap.free(h) +} diff --git a/std/cryptography/shield/test_shield.ae b/std/cryptography/shield/test_shield.ae new file mode 100644 index 000000000..9f46cc673 --- /dev/null +++ b/std/cryptography/shield/test_shield.ae @@ -0,0 +1,118 @@ +// std.cryptography.shield (#3376): a secret sealed behind a random pre-key +// comes back whole, as many times as it is asked for; a bit changed in the +// sealed secret or in the pre-key is refused; a null or empty secret is +// refused at wrap. + +import std.bytes +import std.string +import std.cryptography.shield + +extern exit(code: int) + +fn same(a: ptr, b: ptr) -> bool { + n = bytes.length(a) + if n != bytes.length(b) { return false } + i = 0 + while i < n { + if bytes.get(a, i) != bytes.get(b, i) { return false } + i = i + 1 + } + return true +} + +fn secret_of(n: int, seed: int) -> ptr { + b = bytes.new(n) + i = 0 + while i < n { + bytes.set(b, i, (i * 37 + seed) & 255) + i = i + 1 + } + return b +} + +main() { + fails = 0 + + // 1. It comes back whole, twice. + want = secret_of(66, 11) + h, err = shield.wrap(secret_of(66, 11)) + if h == null { + println("FAIL wrap: ${err}") + exit(1) + } + got1, e1 = shield.unwrap(h) + got2, e2 = shield.unwrap(h) + if got1 != null && got2 != null && same(got1, want) && same(got2, want) { + println("ok the secret comes back whole, twice") + } else { + println("FAIL unwrap: ${e1} ${e2}") + fails = fails + 1 + } + bytes.wipe_free(got1) + bytes.wipe_free(got2) + s = h as *shield.Shielded + if bytes.length(s.prekey) == shield.PREKEY_SIZE && bytes.length(s.sealed) == 66 + 16 { + println("ok a 16 KiB pre-key, and the secret sealed with its tag") + } else { + println("FAIL pre-key ${bytes.length(s.prekey)} bytes, sealed ${bytes.length(s.sealed)}") + fails = fails + 1 + } + + // 2. A bit changed in the sealed secret, then in the pre-key, is refused; + // put back, the secret comes back again. + b0 = bytes.get(s.sealed, 5) + bytes.set(s.sealed, 5, b0 ^ 4) + bad1, why1 = shield.unwrap(h) + bytes.set(s.sealed, 5, b0) + p0 = bytes.get(s.prekey, 9000) + bytes.set(s.prekey, 9000, p0 ^ 1) + bad2, why2 = shield.unwrap(h) + bytes.set(s.prekey, 9000, p0) + if bad1 == null && bad2 == null && string.index_of(why1, "changed in memory") >= 0 && string.index_of(why2, "changed in memory") >= 0 { + println("ok a bit changed in the sealed secret or the pre-key is refused") + } else { + println("FAIL a changed bit was not refused: [${why1}] [${why2}]") + fails = fails + 1 + } + back, eb = shield.unwrap(h) + if back != null && same(back, want) { + println("ok put back, the secret comes back") + } else { + println("FAIL after the bits were put back: ${eb}") + fails = fails + 1 + } + bytes.wipe_free(back) + shield.free(h) + bytes.free(want) + + // 3. Each wrap has its own pre-key. + ha, _ea = shield.wrap(secret_of(32, 1)) + hb, _eb2 = shield.wrap(secret_of(32, 1)) + sa = ha as *shield.Shielded + sb = hb as *shield.Shielded + if same(sa.prekey, sb.prekey) || same(sa.sealed, sb.sealed) { + println("FAIL two wraps of one secret share a pre-key or a sealed form") + fails = fails + 1 + } else { + println("ok two wraps of one secret: two pre-keys, two sealed forms") + } + shield.free(ha) + shield.free(hb) + + // 4. Refusals, and free on null. + hn, en = shield.wrap(null) + he, ee = shield.wrap(bytes.new(8)) + if hn == null && he == null && en != "" && ee != "" { + println("ok a null or empty secret is refused") + } else { + println("FAIL a null or empty secret was wrapped") + fails = fails + 1 + } + shield.free(null) + + if fails > 0 { + println("${fails} FAILED") + exit(1) + } + println("ALL PASS") +} diff --git a/std/cryptography/skein/module.ae b/std/cryptography/skein/module.ae index f3315e136..759f0ae8a 100644 --- a/std/cryptography/skein/module.ae +++ b/std/cryptography/skein/module.ae @@ -30,6 +30,7 @@ import std.bits import std.bytes +import std.mem extern malloc(size: int) -> ptr extern free(p: ptr) @@ -292,7 +293,7 @@ create_initial_state(c: *SkeinCtx, out_bits: long) { // output length bits, LE 64-bit at offset 8 bytes.set_le64(cfg, 8, out_bits) ubi_complete(c, PARAM_TYPE_CONFIG, bytes.view(cfg)[0..32]) - bytes.free(cfg) + bytes.wipe_free(cfg) // save initial state i = 0 @@ -353,7 +354,7 @@ finalize_to_bytes(c: *SkeinCtx) -> ptr { cb = bytes.new(8) bytes.set_le64(cb, 0, seq) ubi_complete(c, PARAM_TYPE_OUTPUT, bytes.view(cb)[0..8]) - bytes.free(cb) + bytes.wipe_free(cb) // emit up to NBYTES of the resulting chain (LE words) base = seq * NBYTES @@ -381,7 +382,7 @@ finalize_to_bytes(c: *SkeinCtx) -> ptr { free_internals(c: *SkeinCtx) { if c.block != null { - bytes.free(c.block) + bytes.wipe_free(c.block) c.block = null } } @@ -394,6 +395,7 @@ final_bytes(ctx: ptr) -> ptr { out = finalize_to_bytes(c) s = bytes.finish(out, n) free_internals(c) + mem.wipe(ctx, sizeof(SkeinCtx)) free(ctx) res = bytes.new(n) bytes.copy_from_string(res, 0, s, n) @@ -424,9 +426,10 @@ final_hex(ctx: ptr) -> string { bytes.set(hexbuf, i * 2 + 1, c_lo) i = i + 1 } - bytes.free(out) + bytes.wipe_free(out) s = bytes.finish(hexbuf, n * 2) free_internals(c) + mem.wipe(ctx, sizeof(SkeinCtx)) free(ctx) return s } @@ -440,6 +443,7 @@ free_ctx(ctx: ptr) { } c = ctx as *SkeinCtx free_internals(c) + mem.wipe(ctx, sizeof(SkeinCtx)) free(ctx) } diff --git a/std/cryptography/sm3/module.ae b/std/cryptography/sm3/module.ae index ea3daa3f9..6d2691d12 100644 --- a/std/cryptography/sm3/module.ae +++ b/std/cryptography/sm3/module.ae @@ -26,6 +26,7 @@ import std.bits import std.bytes +import std.mem extern malloc(size: int) -> ptr extern free(p: ptr) @@ -285,7 +286,7 @@ emit_be32(out: ptr, off: int, v: long) { free_internals(c: *Sm3Ctx) { if c.block != null { - bytes.free(c.block) + bytes.wipe_free(c.block) c.block = null } } @@ -297,6 +298,7 @@ final_bytes(ctx: ptr) -> ptr { out = finalize_to_bytes(c) s = bytes.finish(out, 32) free_internals(c) + mem.wipe(ctx, sizeof(Sm3Ctx)) free(ctx) res = bytes.new(32) bytes.copy_from_string(res, 0, s, 32) @@ -326,9 +328,10 @@ final_hex(ctx: ptr) -> string { bytes.set(hexbuf, i * 2 + 1, c_lo) i = i + 1 } - bytes.free(out) + bytes.wipe_free(out) s = bytes.finish(hexbuf, 64) free_internals(c) + mem.wipe(ctx, sizeof(Sm3Ctx)) free(ctx) return s } @@ -342,6 +345,7 @@ free_ctx(ctx: ptr) { } c = ctx as *Sm3Ctx free_internals(c) + mem.wipe(ctx, sizeof(Sm3Ctx)) free(ctx) } diff --git a/std/cryptography/sm4/module.ae b/std/cryptography/sm4/module.ae index acad3a7c2..1fa1b11cd 100644 --- a/std/cryptography/sm4/module.ae +++ b/std/cryptography/sm4/module.ae @@ -168,6 +168,7 @@ new_decryptor(key: byte[]) -> ptr { } free(ctx: @frees ptr) { if ctx == null { return } + mem.wipe(ctx, sizeof(Sm4Ctx)) libc_free(ctx) } @@ -213,7 +214,7 @@ ecb_run(ctx: ptr, data: ptr) -> ptr { eb = process_block(ctx, blk) k = 0 while k < 16 { bytes.set(out, off + k, bytes.get(eb, k)); k = k + 1 } - bytes.free(blk); bytes.free(eb) + bytes.wipe_free(blk); bytes.wipe_free(eb) off = off + 16 } return out @@ -238,11 +239,11 @@ ctr_xor(ctx: ptr, iv: ptr, data: ptr) -> ptr { } j = j + 1 } - bytes.free(ks) + bytes.wipe_free(ks) ctr_increment(ctr) off = off + 16 } - bytes.free(ctr) + bytes.wipe_free(ctr) return out } ctr_increment(ctr: ptr) { @@ -287,10 +288,10 @@ cbc_encrypt(ctx: ptr, iv: ptr, data: ptr) -> ptr { bytes.set(prev, k, v) k = k + 1 } - bytes.free(blk); bytes.free(eb) + bytes.wipe_free(blk); bytes.wipe_free(eb) off = off + 16 } - bytes.free(prev) + bytes.wipe_free(prev) return out } @@ -315,10 +316,10 @@ cbc_decrypt(ctx: ptr, iv: ptr, data: ptr) -> ptr { bytes.set(prev, k, bytes.get(blk, k)) k = k + 1 } - bytes.free(blk); bytes.free(db) + bytes.wipe_free(blk); bytes.wipe_free(db) off = off + 16 } - bytes.free(prev) + bytes.wipe_free(prev) return out } @@ -371,7 +372,7 @@ pkcs7_unpad(data: ptr) -> { cbc_encrypt_pkcs7(ctx: ptr, iv: ptr, data: ptr) -> ptr { padded = pkcs7_pad(data) out = cbc_encrypt(ctx, iv, padded) - bytes.free(padded) + bytes.wipe_free(padded) return out } @@ -385,7 +386,7 @@ cbc_decrypt_pkcs7(ctx: ptr, iv: ptr, data: ptr) -> { } raw = cbc_decrypt(ctx, iv, data) out, err = pkcs7_unpad(raw) - bytes.free(raw) + bytes.wipe_free(raw) return out, err } diff --git a/std/cryptography/sparkle/module.ae b/std/cryptography/sparkle/module.ae index 8cdab6b25..6cc0028b5 100644 --- a/std/cryptography/sparkle/module.ae +++ b/std/cryptography/sparkle/module.ae @@ -9,6 +9,7 @@ import std.bits import std.bytes +import std.mem extern malloc(size: int) -> ptr extern free(p: ptr) @@ -261,6 +262,7 @@ new(algo: string) -> { ctx.sparkle_steps_big = 12 ctx.state_words = 16 } else { + mem.wipe(ctx, sizeof(SparkleCtx)) free(ctx) return null, "unknown algorithm" } @@ -357,7 +359,7 @@ finalize_to_bytes(c: *SparkleCtx) -> ptr { } free_internals(c: *SparkleCtx) { - if c.block != null { bytes.free(c.block); c.block = null } + if c.block != null { bytes.wipe_free(c.block); c.block = null } } final_bytes(ctx: ptr) -> ptr { @@ -366,6 +368,7 @@ final_bytes(ctx: ptr) -> ptr { out = finalize_to_bytes(c) s = bytes.finish(out, n) free_internals(c) + mem.wipe(ctx, sizeof(SparkleCtx)) free(ctx) res = bytes.new(n) bytes.copy_from_string(res, 0, s, n) @@ -394,9 +397,10 @@ final_hex(ctx: ptr) -> string { bytes.set(hexbuf, i * 2 + 1, c_lo) i = i + 1 } - bytes.free(out) + bytes.wipe_free(out) s = bytes.finish(hexbuf, n * 2) free_internals(c) + mem.wipe(ctx, sizeof(SparkleCtx)) free(ctx) return s } @@ -407,6 +411,7 @@ free_ctx(ctx: ptr) { } c = ctx as *SparkleCtx free_internals(c) + mem.wipe(ctx, sizeof(SparkleCtx)) free(ctx) } diff --git a/std/cryptography/tiger/module.ae b/std/cryptography/tiger/module.ae index 722994100..0e4f5df34 100644 --- a/std/cryptography/tiger/module.ae +++ b/std/cryptography/tiger/module.ae @@ -31,6 +31,7 @@ import std.bits import std.bytes +import std.mem extern malloc(size: int) -> ptr extern free(p: ptr) @@ -512,7 +513,7 @@ emit_le64(out: ptr, off: int, v: long) { free_internals(t: *TigerCtx) { if t.buf != null { - bytes.free(t.buf) + bytes.wipe_free(t.buf) t.buf = null } } @@ -524,6 +525,7 @@ final_bytes(ctx: ptr) -> ptr { out = finalize_to_bytes(t) s = bytes.finish(out, 24) free_internals(t) + mem.wipe(ctx, sizeof(TigerCtx)) free(ctx) res = bytes.new(24) bytes.copy_from_string(res, 0, s, 24) @@ -553,9 +555,10 @@ final_hex(ctx: ptr) -> string { bytes.set(hexbuf, i * 2 + 1, c_lo) i = i + 1 } - bytes.free(out) + bytes.wipe_free(out) s = bytes.finish(hexbuf, 48) free_internals(t) + mem.wipe(ctx, sizeof(TigerCtx)) free(ctx) return s } @@ -569,6 +572,7 @@ free_ctx(ctx: ptr) { } t = ctx as *TigerCtx free_internals(t) + mem.wipe(ctx, sizeof(TigerCtx)) free(ctx) } diff --git a/std/cryptography/tls13_cert/module.ae b/std/cryptography/tls13_cert/module.ae index 880ee9fd1..758ca0c9b 100644 --- a/std/cryptography/tls13_cert/module.ae +++ b/std/cryptography/tls13_cert/module.ae @@ -20,11 +20,13 @@ // (algorithm OID + public-key bits) out of the TBS. That SPKI is what the // CertificateVerify check needs (the leaf's public key). // -// EXPLICITLY OUT OF SCOPE for this brick (later increments): chain building -// against a trust store, hostname / SAN matching, validity-period and Basic- -// Constraints / EKU policy. (OCSP-staple revocation status IS parsed — see -// parse_ocsp_status.) This is the crypto core, not the -// full RFC 5280 policy engine. +// Also here: path validation (validate_chain: RFC 5280 section 6 as the Web +// PKI profiles it, see the comment above PURPOSE_SERVER), hostname / SAN +// matching (check_hostname), validity periods, the trust store loaders, and +// the OCSP-staple revocation status (parse_ocsp_status). Not here: CRLs, +// certificate policies, and the issuance-profile rules (CA/Browser Forum, +// RFC 5280's "conforming CAs MUST") that do not change what a path +// authorises; tests/conformance/x509_limbo/expected.txt lists each. // // Import with: import std.cryptography.tls13_cert // @@ -43,6 +45,7 @@ import std.cryptography.asn1 import std.cryptography.sha2 import std.cryptography.p256 import std.cryptography.p384 +import std.cryptography.p521 import std.cryptography.ed25519 import std.os @@ -53,16 +56,23 @@ exports( init_leaf, LEAFCERT_SIZE, cert_verify_content, client_cert_verify_content, sign_client_cert_verify_ecdsa_p256, sign_server_cert_verify_ecdsa_p256, - verify_cert_verify, verify_cert_verify_rsa_pss, parse_certificate, free_leaf, + sign_server_cert_verify_ecdsa, sign_server_cert_verify_ed25519, sign_server_cert_verify_rsa_pss, + sign_server_cert_verify_rsa_pss_scheme, SCHEME_RSA_PSS_RSAE_SHA384, SCHEME_RSA_PSS_RSAE_SHA512, + KEY_NONE, KEY_RSA, KEY_P256, KEY_P384, KEY_P521, KEY_ED25519, + verify_cert_verify, verify_cert_verify_rsa_pss, verify_cert_verify_rsa_pss_scheme, leaf_signature_holds, + client_cert_verify_holds, + parse_certificate, free_leaf, split_ecdsa_sig, LeafCert, leaf_not_before, leaf_not_before_len, leaf_not_after, leaf_not_after_len, leaf_dns_count, leaf_dns_name, leaf_tbs_len, leaf_subject_len, leaf_issuer_len, - leaf_is_ca, leaf_key_cert_sign, - SCHEME_ECDSA_P256_SHA256, SCHEME_ECDSA_P384_SHA384, SCHEME_ED25519, SCHEME_RSA_PSS_RSAE_SHA256, + leaf_is_ca, leaf_key_cert_sign, EXT_PATHLEN, EXT_MALFORMED, + SCHEME_ECDSA_P256_SHA256, SCHEME_ECDSA_P384_SHA384, SCHEME_ECDSA_P521_SHA512, SCHEME_ED25519, SCHEME_RSA_PSS_RSAE_SHA256, check_validity, time_to_key, check_hostname, chain_verify, chain_verify_path, verify_cert_signature, find_issuer_cert, + validate_chain, check_validity_at, PURPOSE_SERVER, PURPOSE_CLIENT, TRUST_PARTIAL_CHAIN, + check_leaf_cert_verify, trust_store_load, trust_store_add, trust_store_add_pem, free_anchors, parse_ocsp_status, verify_ocsp_signature, verify_ocsp_signature_at, OCSP_GOOD, OCSP_REVOKED, OCSP_UNKNOWN, OCSP_ERROR ) @@ -70,7 +80,10 @@ exports( // SignatureScheme code points (RFC 8446 §4.2.3). const SCHEME_ECDSA_P256_SHA256 = 0x0403 const SCHEME_ECDSA_P384_SHA384 = 0x0503 +const SCHEME_ECDSA_P521_SHA512 = 0x0603 const SCHEME_RSA_PSS_RSAE_SHA256 = 0x0804 +const SCHEME_RSA_PSS_RSAE_SHA384 = 0x0805 +const SCHEME_RSA_PSS_RSAE_SHA512 = 0x0806 const SCHEME_ED25519 = 0x0807 // The CertificateVerify context string for a server's signature @@ -103,55 +116,151 @@ client_cert_verify_content(transcript_hash: byte[]) -> (ptr, int) { } // Sign a client CertificateVerify with an ECDSA-P256 client key (mTLS). Builds -// the §4.4.3 client content, SHA-256-hashes it, ECDSA-signs with `priv` (32-byte -// scalar) and per-message nonce `k` (32 bytes), and returns the DER -// ECDSA-Sig-Value SEQUENCE { r INTEGER, s INTEGER } (caller frees). This is the +// the §4.4.3 client content, SHA-256-hashes it, ECDSA-signs it with `priv` +// (32-byte scalar; the nonce is RFC 6979's, derived from the key and the +// digest and hedged with fresh random bytes, #3376), and returns the DER +// ECDSA-Sig-Value SEQUENCE { r INTEGER, s INTEGER } (caller frees), or null +// for a private key outside [1, n-1] or without randomness. This is the // signature that goes in the client CertificateVerify handshake message under // SignatureScheme ecdsa_secp256r1_sha256 (0x0403). -sign_client_cert_verify_ecdsa_p256(priv: ptr, transcript_hash: byte[], k: ptr) -> ptr { +sign_client_cert_verify_ecdsa_p256(priv: ptr, transcript_hash: byte[]) -> ptr { content, clen = client_cert_verify_content(transcript_hash) - digest = sha256_of_bytes(bytes.view(content)[0..clen]) - r32, s32 = p256.ecdsa_sign(priv, digest, k) - // DER-encode as SEQUENCE { INTEGER r, INTEGER s }. - rbn = bignum.from_bytes_unsigned(r32, 32) - sbn = bignum.from_bytes_unsigned(s32, 32) - rder = asn1.encode_integer(rbn) - sder = asn1.encode_integer(sbn) - body = bytes.new(bytes.length(rder) + bytes.length(sder)) - if bytes.length(body) > 0 { bytes.set(body, bytes.length(body) - 1, 0) } - bytes.copy_from_bytes(body, 0, rder, 0, bytes.length(rder)) - bytes.copy_from_bytes(body, bytes.length(rder), sder, 0, bytes.length(sder)) - // NOTE: asn1.encode_sequence -> wrap_tlv CONSUMES (frees) `body`, so we must - // NOT free it again here (double-free). rder/sder are our own copies. - sig = asn1.encode_sequence(body) - bytes.free(content); bytes.free(digest) - bytes.free(r32); bytes.free(s32) - bignum.free(rbn); bignum.free(sbn) - bytes.free(rder); bytes.free(sder) + sig = sign_content_ecdsa_p256(priv, content, clen) + bytes.wipe_free(content) return sig } // Sign a server CertificateVerify with an ECDSA-P256 server key. Builds the -// §4.4.3 server content ("TLS 1.3, server CertificateVerify"), SHA-256 hashes it, -// ECDSA-signs with `priv` (32-byte scalar) and per-message nonce `k` (32 bytes), -// and returns the DER ECDSA-Sig-Value SEQUENCE { r INTEGER, s INTEGER } (caller frees). -sign_server_cert_verify_ecdsa_p256(priv: ptr, transcript_hash: byte[], k: ptr) -> ptr { +// §4.4.3 server content ("TLS 1.3, server CertificateVerify"), SHA-256 hashes +// it, ECDSA-signs it with `priv` (32-byte scalar, an RFC 6979 nonce hedged +// with fresh random bytes, #3376), and returns the DER ECDSA-Sig-Value +// SEQUENCE { r INTEGER, s INTEGER } (caller frees), or null for a private key +// outside [1, n-1] or without randomness. +sign_server_cert_verify_ecdsa_p256(priv: ptr, transcript_hash: byte[]) -> ptr { content, clen = cert_verify_content(transcript_hash) - digest = sha256_of_bytes(bytes.view(content)[0..clen]) - r32, s32 = p256.ecdsa_sign(priv, digest, k) - rbn = bignum.from_bytes_unsigned(r32, 32) - sbn = bignum.from_bytes_unsigned(s32, 32) + sig = sign_content_ecdsa_p256(priv, content, clen) + bytes.wipe_free(content) + return sig +} + +// Sign a server CertificateVerify (RFC 8446 §4.4.3) with an ECDSA key of +// `kind` (KEY_P256 with SHA-256, KEY_P384 with SHA-384, KEY_P521 with +// SHA-512: the schemes 0x0403, 0x0503, 0x0603, which tie the hash to the +// curve). `priv` is the scalar, its curve's size; the nonce is RFC 6979's, +// hedged with fresh random bytes (#3376). Returns the DER ECDSA-Sig-Value +// (caller frees), or null for a key outside [1, n-1], another kind or no +// randomness (#3326). +sign_server_cert_verify_ecdsa(kind: int, priv: ptr, transcript_hash: byte[]) -> ptr { + content, clen = cert_verify_content(transcript_hash) + sig = sign_content_ecdsa(kind, priv, content, clen) + bytes.wipe_free(content) + return sig +} + +// Sign a server CertificateVerify with an Ed25519 key (scheme 0x0807): the +// 32-byte seed signs the §4.4.3 content itself (Ed25519 hashes inside), r +// hedged with 32 fresh random bytes (ed25519.sign_hedged, #3376). Returns +// the 64-byte signature (caller frees), or null for a bad seed or without +// randomness (#3326). +sign_server_cert_verify_ed25519(seed: ptr, transcript_hash: byte[]) -> ptr { + if seed == null || bytes.length(seed) != 32 { return null } + extra, rerr = cryptography.random_buffer(32) + if rerr != "" || extra == null { return null } + content, clen = cert_verify_content(transcript_hash) + sig = ed25519.sign_hedged(seed, bytes.view(content)[0..clen], extra) + bytes.wipe_free(extra) + bytes.wipe_free(content) + return sig +} + +// Sign a server CertificateVerify with an RSA key as rsa_pss_rsae_sha256 +// (0x0804; RFC 8446 forbids PKCS#1 v1.5 in TLS 1.3): SHA-256, MGF1 with +// SHA-256, and a fresh 32-byte salt (the hash's length, §4.2.3). The +// private-key operation is std.cryptography.rsa's, constant time and blinded. +// Returns the signature (caller frees), or null without randomness (#3326). +sign_server_cert_verify_rsa_pss(key: ptr, transcript_hash: byte[]) -> ptr { + return sign_server_cert_verify_rsa_pss_scheme(key, SCHEME_RSA_PSS_RSAE_SHA256, transcript_hash) +} + +// The same for `scheme` rsa_pss_rsae_sha256, _sha384 or _sha512 (0x0804, +// 0x0805, 0x0806): the content hashed with that hash, MGF1 with it, a salt +// of its length (#3357). Null for another scheme or without randomness. +sign_server_cert_verify_rsa_pss_scheme(key: ptr, scheme: int, transcript_hash: byte[]) -> ptr { + if key == null { return null } + algo = "sha256" + hl = 32 + if scheme == SCHEME_RSA_PSS_RSAE_SHA384 { + algo = "sha384" + hl = 48 + } else if scheme == SCHEME_RSA_PSS_RSAE_SHA512 { + algo = "sha512" + hl = 64 + } else if scheme != SCHEME_RSA_PSS_RSAE_SHA256 { + return null + } + salt, serr = cryptography.random_buffer(hl) + if serr != "" || salt == null { return null } + content, clen = cert_verify_content(transcript_hash) + ctx, herr = sha2.new(algo) + sig = null + if herr == "" { + sha2.update(ctx, bytes.view(content)[0..clen]) + digest = sha2.final_bytes(ctx) + sig = rsa.sign_pss_hash(key, algo, digest, bytes.view(salt)[0..hl]) + bytes.wipe_free(digest) + } + bytes.wipe_free(content) + bytes.wipe_free(salt) + return sig +} + +// SHA-256 the first clen bytes of content, sign the digest with p256 and +// DER-encode (r, s); null when the key is refused. +fn sign_content_ecdsa_p256(priv: ptr, content: ptr, clen: int) -> ptr { + return sign_content_ecdsa(KEY_P256, priv, content, clen) +} + +// Hash the first clen bytes of content with the hash `kind`'s scheme ties to +// its curve, sign the digest and DER-encode (r, s); null when the key is +// refused, `kind` is not an ECDSA curve, or there is no randomness. The +// nonce is hedged (#3376): RFC 6979's, with 32 fresh random bytes mixed in +// (RFC 6979 3.6), so no two signatures of one content share a nonce. +fn sign_content_ecdsa(kind: int, priv: ptr, content: ptr, clen: int) -> ptr { + hid = 0 + size = 0 + if kind == KEY_P256 { hid = 1 size = 32 } + if kind == KEY_P384 { hid = 2 size = 48 } + if kind == KEY_P521 { hid = 3 size = 66 } + if hid == 0 || priv == null || bytes.length(priv) != size { return null } + extra, rerr = cryptography.random_buffer(32) + if rerr != "" || extra == null { return null } + digest = digest_of(bytes.view(content)[0..clen], hid) + var r32: ptr = null + var s32: ptr = null + if kind == KEY_P256 { r32, s32 = p256.ecdsa_sign_hedged(priv, digest, extra) } + if kind == KEY_P384 { r32, s32 = p384.ecdsa_sign_hedged(priv, digest, extra) } + if kind == KEY_P521 { r32, s32 = p521.ecdsa_sign_hedged(priv, digest, extra) } + bytes.wipe_free(extra) + bytes.wipe_free(digest) + if r32 == null { + if s32 != null { bytes.wipe_free(s32) } + return null + } + // DER-encode as SEQUENCE { INTEGER r, INTEGER s }. + rbn = bignum.from_bytes_unsigned(r32, bytes.length(r32)) + sbn = bignum.from_bytes_unsigned(s32, bytes.length(s32)) rder = asn1.encode_integer(rbn) sder = asn1.encode_integer(sbn) body = bytes.new(bytes.length(rder) + bytes.length(sder)) if bytes.length(body) > 0 { bytes.set(body, bytes.length(body) - 1, 0) } bytes.copy_from_bytes(body, 0, rder, 0, bytes.length(rder)) bytes.copy_from_bytes(body, bytes.length(rder), sder, 0, bytes.length(sder)) + // NOTE: asn1.encode_sequence -> wrap_tlv CONSUMES (frees) `body`, so we must + // NOT free it again here (double-free). rder/sder are our own copies. sig = asn1.encode_sequence(body) - bytes.free(content); bytes.free(digest) - bytes.free(r32); bytes.free(s32) + bytes.wipe_free(r32); bytes.wipe_free(s32) bignum.free(rbn); bignum.free(sbn) - bytes.free(rder); bytes.free(sder) + bytes.wipe_free(rder); bytes.wipe_free(sder) return sig } @@ -203,19 +312,19 @@ split_ecdsa_sig(sig: byte[], coord: int) -> (ptr, ptr, string) { sub, body, serr = asn1.read_sequence(rdr) if string.equals(serr, "") != 1 { asn1.reader_free(rdr) - bytes.free(view) + bytes.wipe_free(view) return null, null, serr } // read_integer returns a std.bignum handle (NOT a std.bytes buffer). rint, rerr = asn1.read_integer(sub) if string.equals(rerr, "") != 1 { - asn1.reader_free(sub); asn1.reader_free(rdr); bytes.free(view); bytes.free(body) + asn1.reader_free(sub); asn1.reader_free(rdr); bytes.wipe_free(view); bytes.wipe_free(body) return null, null, rerr } sint, serr2 = asn1.read_integer(sub) if string.equals(serr2, "") != 1 { bignum.free(rint) - asn1.reader_free(sub); asn1.reader_free(rdr); bytes.free(view); bytes.free(body) + asn1.reader_free(sub); asn1.reader_free(rdr); bytes.wipe_free(view); bytes.wipe_free(body) return null, null, serr2 } @@ -238,8 +347,8 @@ split_ecdsa_sig(sig: byte[], coord: int) -> (ptr, ptr, string) { smag = bignum.to_bytes_unsigned(sint) if bytes.length(rmag) > coord { strict = 0 } if bytes.length(smag) > coord { strict = 0 } - bytes.free(rmag) - bytes.free(smag) + bytes.wipe_free(rmag) + bytes.wipe_free(smag) } if strict == 1 { ri = asn1.encode_integer(rint) @@ -264,14 +373,14 @@ split_ecdsa_sig(sig: byte[], coord: int) -> (ptr, ptr, string) { j = j + 1 } } - bytes.free(ri) - bytes.free(si) - bytes.free(reenc) + bytes.wipe_free(ri) + bytes.wipe_free(si) + bytes.wipe_free(reenc) } if strict != 1 { bignum.free(rint) bignum.free(sint) - asn1.reader_free(sub); asn1.reader_free(rdr); bytes.free(view); bytes.free(body) + asn1.reader_free(sub); asn1.reader_free(rdr); bytes.wipe_free(view); bytes.wipe_free(body) return null, null, "ecdsa-sig: not strict minimal DER, or scalar out of range" } @@ -281,8 +390,8 @@ split_ecdsa_sig(sig: byte[], coord: int) -> (ptr, ptr, string) { bignum.free(sint) asn1.reader_free(sub) asn1.reader_free(rdr) - bytes.free(view) - bytes.free(body) + bytes.wipe_free(view) + bytes.wipe_free(body) return r, s, "" } @@ -311,7 +420,7 @@ fn bn_to_fixed(v: ptr, width: int) -> ptr { bytes.set(out, dst + k, bytes.get(be, src_start + k) & 0xFF) k = k + 1 } - bytes.free(be) + bytes.wipe_free(be) return out } @@ -344,29 +453,41 @@ verify_cert_verify(scheme: int, pubx: ptr, puby: ptr, transcript_hash: byte[], s r32, s32, serr = split_ecdsa_sig(sig, 32) if string.equals(serr, "") == 1 { result = p256.ecdsa_verify(pubx, puby, digest, r32, s32) - bytes.free(r32) - bytes.free(s32) + bytes.wipe_free(r32) + bytes.wipe_free(s32) } - bytes.free(digest) + bytes.wipe_free(digest) } if scheme == SCHEME_ECDSA_P384_SHA384 { // ECDSA over SHA-384(content) on a P-384 leaf key; pubx/puby are the // 48-byte affine coordinates. DER sig -> (r,s), each 48 bytes. - digest = sha384_of_bytes(bytes.view(content)[0..clen]) + digest = digest_of(bytes.view(content)[0..clen], 2) r48, s48, serr = split_ecdsa_sig(sig, 48) if string.equals(serr, "") == 1 { result = p384.ecdsa_verify(pubx, puby, digest, r48, s48) - bytes.free(r48) - bytes.free(s48) + bytes.wipe_free(r48) + bytes.wipe_free(s48) + } + bytes.wipe_free(digest) + } + if scheme == SCHEME_ECDSA_P521_SHA512 { + // ECDSA over SHA-512(content) on a P-521 leaf key; pubx/puby are the + // 66-byte affine coordinates. DER sig -> (r,s), each 66 bytes. + digest = digest_of(bytes.view(content)[0..clen], 3) + r66, s66, serr = split_ecdsa_sig(sig, 66) + if string.equals(serr, "") == 1 { + result = p521.ecdsa_verify(pubx, puby, digest, r66, s66) + bytes.wipe_free(r66) + bytes.wipe_free(s66) } - bytes.free(digest) + bytes.wipe_free(digest) } if scheme == SCHEME_ED25519 { // Ed25519 signs the raw content (hashes internally). pubx = 32-byte key. result = ed25519.verify(pubx, bytes.view(content)[0..clen], sig) } - bytes.free(content) + bytes.wipe_free(content) return result } @@ -376,18 +497,137 @@ verify_cert_verify(scheme: int, pubx: ptr, puby: ptr, transcript_hash: byte[], s // this scheme are fixed: SHA-256 for both the message hash and MGF1, salt // length = hash length = 32. Returns 1 if valid, 0 otherwise. verify_cert_verify_rsa_pss(spki_key: byte[], transcript_hash: byte[], sig: byte[]) -> int { + return verify_cert_verify_rsa_pss_scheme(spki_key, SCHEME_RSA_PSS_RSAE_SHA256, transcript_hash, sig) +} + +// The same for `scheme` rsa_pss_rsae_sha256, _sha384 or _sha512 (0x0804, +// 0x0805, 0x0806): the content hashed with that hash, MGF1 with it, a salt +// of its length, as sign_server_cert_verify_rsa_pss_scheme signs. 0 for +// another scheme (#3376). +verify_cert_verify_rsa_pss_scheme(spki_key: byte[], scheme: int, transcript_hash: byte[], sig: byte[]) -> int { + if spki_key.len < 0 { panic("tls13_cert.verify_cert_verify_rsa_pss_scheme: the `spki_key` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } + if transcript_hash.len < 0 { panic("tls13_cert.verify_cert_verify_rsa_pss_scheme: the `transcript_hash` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } + if sig.len < 0 { panic("tls13_cert.verify_cert_verify_rsa_pss_scheme: the `sig` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } + algo = "sha256" + hl = 32 + hid = 1 + if scheme == SCHEME_RSA_PSS_RSAE_SHA384 { + algo = "sha384" + hl = 48 + hid = 2 + } else if scheme == SCHEME_RSA_PSS_RSAE_SHA512 { + algo = "sha512" + hl = 64 + hid = 3 + } else if scheme != SCHEME_RSA_PSS_RSAE_SHA256 { + return 0 + } n, e, perr = parse_rsa_spki(spki_key) if string.equals(perr, "") != 1 { return 0 } content, clen = cert_verify_content(transcript_hash) - digest = sha256_of_bytes(bytes.view(content)[0..clen]) + digest = digest_of(bytes.view(content)[0..clen], hid) k = rsa.key_from_components(n, e, null) - result = rsa.verify_pss(k, digest, sig, 32) + result = 0 + if digest != null { result = rsa.verify_pss_hash(k, algo, digest, sig, hl) } rsa.key_free(k) // frees n, e - bytes.free(content) - bytes.free(digest) + bytes.wipe_free(content) + bytes.wipe_free(digest) return result } +// 1 when `sig` is a valid CertificateVerify signature (RFC 8446 4.4.3) by +// the key of the parsed certificate `leaf` under `scheme`, over +// `transcript_hash`; 0 otherwise, a scheme that does not fit the key +// included. Every scheme this module signs: ECDSA P-256, P-384 and P-521 +// (0x0403, 0x0503, 0x0603), rsa_pss_rsae_sha256, _sha384 and _sha512 +// (0x0804 to 0x0806) and Ed25519 (0x0807). A server checks its own +// signature with it before sending one (#3376); check_leaf_cert_verify +// gives a peer's its alert. +leaf_signature_holds(leaf: *LeafCert, scheme: int, transcript_hash: byte[], sig: byte[]) -> int { + if transcript_hash.len < 0 { panic("tls13_cert.leaf_signature_holds: the `transcript_hash` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } + if sig.len < 0 { panic("tls13_cert.leaf_signature_holds: the `sig` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } + want = KEY_NONE + if scheme == SCHEME_ECDSA_P256_SHA256 { want = KEY_P256 } + if scheme == SCHEME_ECDSA_P384_SHA384 { want = KEY_P384 } + if scheme == SCHEME_ECDSA_P521_SHA512 { want = KEY_P521 } + if scheme == SCHEME_RSA_PSS_RSAE_SHA256 || scheme == SCHEME_RSA_PSS_RSAE_SHA384 || scheme == SCHEME_RSA_PSS_RSAE_SHA512 { want = KEY_RSA } + if scheme == SCHEME_ED25519 { want = KEY_ED25519 } + if want == KEY_NONE || leaf.key_kind != want { return 0 } + if want == KEY_RSA { + return verify_cert_verify_rsa_pss_scheme(bytes.view(leaf.spki_key)[0..leaf.spki_key_len], scheme, transcript_hash, sig) + } + if want == KEY_ED25519 { + return verify_cert_verify(scheme, leaf.spki_key, null, transcript_hash, sig) + } + // An uncompressed point of the curve's size, as spki_key_check made + // sure: 0x04 || X || Y. + w = (leaf.spki_key_len - 1) / 2 + px = bytes.new(w) + py = bytes.new(w) + bytes.set(px, w - 1, 0) + bytes.set(py, w - 1, 0) + bytes.copy_from_bytes(px, 0, leaf.spki_key, 1, w) + bytes.copy_from_bytes(py, 0, leaf.spki_key, 1 + w, w) + ok = verify_cert_verify(scheme, px, py, transcript_hash, sig) + bytes.wipe_free(px) + bytes.wipe_free(py) + return ok +} + +// 1 when `sig` is a valid client CertificateVerify signature (RFC 8446 +// 4.4.3, the "client" context) under ecdsa_secp256r1_sha256 by the key of +// the parsed certificate `leaf`, over `transcript_hash`; 0 otherwise, +// another key included. The mTLS client checks its own signature with it +// before sending it (#3376); it signs with P-256 only. +client_cert_verify_holds(leaf: *LeafCert, transcript_hash: byte[], sig: byte[]) -> int { + if transcript_hash.len < 0 { panic("tls13_cert.client_cert_verify_holds: the `transcript_hash` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } + if sig.len < 0 { panic("tls13_cert.client_cert_verify_holds: the `sig` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } + if leaf.key_kind != KEY_P256 { return 0 } + content, clen = client_cert_verify_content(transcript_hash) + digest = sha256_of_bytes(bytes.view(content)[0..clen]) + bytes.wipe_free(content) + px = bytes.new(32) + py = bytes.new(32) + bytes.copy_from_bytes(px, 0, leaf.spki_key, 1, 32) + bytes.copy_from_bytes(py, 0, leaf.spki_key, 33, 32) + ok = 0 + r32, s32, serr = split_ecdsa_sig(sig, 32) + if string.equals(serr, "") == 1 { + ok = p256.ecdsa_verify(px, py, digest, r32, s32) + bytes.wipe_free(r32) + bytes.wipe_free(s32) + } + bytes.wipe_free(px) + bytes.wipe_free(py) + bytes.wipe_free(digest) + return ok +} + +// Check a peer's CertificateVerify (RFC 8446 4.4.3) with the key of its +// end-entity certificate `leaf`: `scheme` and `sig` as the message carries +// them, `transcript_hash` through the Certificate. (0, "") when the +// signature holds, else the alert and why: bad_certificate (42) for a key +// no scheme is accepted with (KEY_NONE), illegal_parameter (47) for a +// scheme this module does not verify or one that does not fit the key (an +// ECDSA scheme names its curve and hash, an RSA key signs rsa_pss_rsae, +// Ed25519 its own), decrypt_error (51) for a signature that does not +// verify (#3324, #3335). +check_leaf_cert_verify(leaf: *LeafCert, scheme: int, transcript_hash: byte[], sig: byte[]) -> (int, string) { + if transcript_hash.len < 0 { panic("tls13_cert.check_leaf_cert_verify: the `transcript_hash` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } + if sig.len < 0 { panic("tls13_cert.check_leaf_cert_verify: the `sig` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } + want = KEY_NONE + if scheme == SCHEME_ECDSA_P256_SHA256 { want = KEY_P256 } + if scheme == SCHEME_ECDSA_P384_SHA384 { want = KEY_P384 } + if scheme == SCHEME_ECDSA_P521_SHA512 { want = KEY_P521 } + if scheme == SCHEME_RSA_PSS_RSAE_SHA256 { want = KEY_RSA } + if scheme == SCHEME_ED25519 { want = KEY_ED25519 } + if leaf.key_kind == KEY_NONE { return 42, "CertificateVerify: the certificate's key is not one this client accepts" } + if want == KEY_NONE { return 47, "CertificateVerify: signature scheme ${scheme} is not one this client verifies" } + if leaf.key_kind != want { return 47, "CertificateVerify: signature scheme ${scheme} does not fit the certificate's key" } + if leaf_signature_holds(leaf, scheme, transcript_hash, sig) != 1 { return 51, "CertificateVerify signature invalid" } + return 0, "" +} + // ---- X.509 structural parse ---- // Views into a parsed Certificate (RFC 5280). All ptr fields are fresh @@ -418,11 +658,11 @@ verify_cert_verify_rsa_pss(spki_key: byte[], transcript_hash: byte[], sig: byte[ // a long way from the edit that caused it. One constant, exported, so the // count lives next to the definition it has to track. // -// Sized with headroom rather than exactly: 11 ptr (8) + 7 int (4) padded is -// 144, and the slack absorbs the next field or two without another silent -// overflow. A few spare bytes per certificate is the right trade against -// corrupting the heap. -const LEAFCERT_SIZE = 192 +// Sized with headroom rather than exactly: 19 ptr and 16 int fields come to +// 248 bytes with x86-64 alignment, and the slack absorbs the next field or +// two without another silent overflow. A few spare bytes per certificate is +// the right trade against corrupting the heap. +const LEAFCERT_SIZE = 320 struct LeafCert { tbs: ptr // full tbsCertificate DER (tag+len+content) @@ -446,8 +686,23 @@ struct LeafCert { is_ca: int has_key_usage: int key_cert_sign: int - has_path_len: int - path_len: int + version: int // the encoded version: 0 = v1, 1 = v2, 2 = v3 + ext_bits: int // EXT_* flags: which extensions were seen, and how + path_len: int // basicConstraints pathLenConstraint (when EXT_PATHLEN) + key_usage: int // KeyUsage, bit i of the BIT STRING as (1 << i) (when has_key_usage) + eku_bits: int // EKU_* purposes listed (when EXT_EKU) + san_forms: int // bit (1 << form) for each GeneralName form in the SAN (form = tag & 0x1F) + nc_forms: int // bit (1 << form) for each form a name-constraint subtree names + key_kind: int // KEY_* of the subject public key, KEY_NONE when unsupported or weak + key_bits: int // the RSA modulus length in bits (KEY_RSA) + nc_permitted: ptr // std.list of permitted name-constraint subtrees (tag byte, then value), or null + nc_excluded: ptr // std.list of excluded subtrees, or null + san_other: ptr // std.list of rfc822Name, URI and directoryName SANs (tag byte, then value), or null + serial: ptr // serialNumber content bytes, or null + ski: ptr // subjectKeyIdentifier, or null + aki_keyid: ptr // authorityKeyIdentifier keyIdentifier, or null + aki_issuer: ptr // the first authorityCertIssuer directoryName (a Name), or null + aki_serial: ptr // authorityCertSerialNumber content bytes, or null } // Parse a DER Certificate into `out`. Returns "" on success or an error. @@ -479,8 +734,23 @@ parse_certificate(der: byte[], out: *LeafCert) -> string { out.is_ca = 0 out.has_key_usage = 0 out.key_cert_sign = 0 - out.has_path_len = 0 + out.version = 0 + out.ext_bits = 0 out.path_len = 0 + out.key_usage = 0 + out.eku_bits = 0 + out.nc_permitted = null + out.nc_excluded = null + out.san_other = null + out.san_forms = 0 + out.nc_forms = 0 + out.key_kind = KEY_NONE + out.key_bits = 0 + out.serial = null + out.ski = null + out.aki_keyid = null + out.aki_issuer = null + out.aki_serial = null view = bytes.new(len) if len > 0 { bytes.set(view, len - 1, 0) } @@ -493,13 +763,13 @@ parse_certificate(der: byte[], out: *LeafCert) -> string { // Certificate is a SEQUENCE; step into it, then the first element is TBS. cert, cert_body, cerr = asn1.read_sequence(top) if string.equals(cerr, "") != 1 { - asn1.reader_free(top); bytes.free(view) + asn1.reader_free(top); bytes.wipe_free(view) return cerr } tbs_start = asn1.reader_pos(cert) tbs_content, terr = asn1.read_tlv(cert) if string.equals(terr, "") != 1 { - asn1.reader_free(cert); asn1.reader_free(top); bytes.free(view); bytes.free(cert_body) + asn1.reader_free(cert); asn1.reader_free(top); bytes.wipe_free(view); bytes.wipe_free(cert_body) return terr } tbs_end = asn1.reader_pos(cert) @@ -517,7 +787,7 @@ parse_certificate(der: byte[], out: *LeafCert) -> string { soid, soerr = asn1.read_oid(salg_body) if string.equals(soerr, "") == 1 { out.sig_alg_oid = oid_to_bytes(soid); string.release(soid) } asn1.reader_free(salg_body) - bytes.free(salg_full) + bytes.wipe_free(salg_full) } sigbits, _snbits, sberr = asn1.read_bit_string(cert) if string.equals(sberr, "") == 1 { @@ -530,11 +800,11 @@ parse_certificate(der: byte[], out: *LeafCert) -> string { perr = parse_tbs_fields(tbsr, out) asn1.reader_free(tbsr) - bytes.free(tbs_content) + bytes.wipe_free(tbs_content) asn1.reader_free(cert) asn1.reader_free(top) - bytes.free(view) - bytes.free(cert_body) + bytes.wipe_free(view) + bytes.wipe_free(cert_body) return perr } @@ -547,7 +817,20 @@ fn parse_tbs_fields(tbsr: ptr, out: *LeafCert) -> string { // [0] version (context tag 0xA0) — optional; skip if present. v, verr = asn1.read_tlv(tbsr) vtag = asn1.last_tag(tbsr) - bytes.free(v) + if string.equals(verr, "") == 1 && vtag == 0xA0 { + // [0] EXPLICIT Version INTEGER: one content byte, 0..2. + vr0 = asn1.reader_new(v) + vi, vierr = asn1.read_tlv(vr0) + if string.equals(vierr, "") == 1 && asn1.last_tag(vr0) == 0x02 && bytes.length(vi) == 1 { + out.version = bytes.get(vi, 0) & 0xFF + } else { + out.version = -1 + } + if vi != null { bytes.wipe_free(vi) } + asn1.reader_free(vr0) + } + if string.equals(verr, "") == 1 && vtag == 0x02 { serial_check(v, out) } + bytes.wipe_free(v) // Every read below used to drop its error, so a truncated or malformed // certificate parsed "successfully" into a LeafCert whose fields came // from reads that failed -- an issuer, subject, validity or SPKI the @@ -565,55 +848,64 @@ fn parse_tbs_fields(tbsr: ptr, out: *LeafCert) -> string { if vtag == 0xA0 { sn, snerr = asn1.read_tlv(tbsr) if string.equals(snerr, "") != 1 { return snerr } - bytes.free(sn) + serial_check(sn, out) + bytes.wipe_free(sn) } // signature AlgorithmIdentifier (SEQUENCE) — skip. sa, saerr = asn1.read_tlv(tbsr) - bytes.free(sa) + bytes.wipe_free(sa) if string.equals(saerr, "") != 1 { return saerr } // issuer Name (SEQUENCE) — capture raw DER (tag+len+content). iss, ierr = read_tlv_full(tbsr) - if string.equals(ierr, "") != 1 { bytes.free(iss); return ierr } + if string.equals(ierr, "") != 1 { bytes.wipe_free(iss); return ierr } out.issuer = iss out.issuer_len = bytes.length(iss) // validity SEQUENCE { notBefore, notAfter }. val_content, valerr = asn1.read_tlv(tbsr) - if string.equals(valerr, "") != 1 { bytes.free(val_content); return valerr } + if string.equals(valerr, "") != 1 { bytes.wipe_free(val_content); return valerr } vr = asn1.reader_new(val_content) nb, nberr = asn1.read_tlv(vr) if string.equals(nberr, "") == 1 { out.not_before = nb; out.not_before_len = bytes.length(nb) } na, naerr = asn1.read_tlv(vr) if string.equals(naerr, "") == 1 { out.not_after = na; out.not_after_len = bytes.length(na) } asn1.reader_free(vr) - bytes.free(val_content) + bytes.wipe_free(val_content) // subject Name (SEQUENCE) — capture raw DER. subj, suberr = read_tlv_full(tbsr) - if string.equals(suberr, "") != 1 { bytes.free(subj); return suberr } + if string.equals(suberr, "") != 1 { bytes.wipe_free(subj); return suberr } out.subject = subj out.subject_len = bytes.length(subj) // subjectPublicKeyInfo SEQUENCE { algorithm AlgId, subjectPublicKey BIT }. spki_content, spkierr = asn1.read_tlv(tbsr) - if string.equals(spkierr, "") != 1 { bytes.free(spki_content); return spkierr } + if string.equals(spkierr, "") != 1 { bytes.wipe_free(spki_content); return spkierr } sr = asn1.reader_new(spki_content) algo_body, algo_full, ae = asn1.read_sequence(sr) + kind = KEY_NONE if string.equals(ae, "") == 1 { oid, oerr = asn1.read_oid(algo_body) - if string.equals(oerr, "") == 1 { out.spki_algo_oid = oid_to_bytes(oid); string.release(oid) } + if string.equals(oerr, "") == 1 { + out.spki_algo_oid = oid_to_bytes(oid) + kind = spki_kind(oid, algo_body) + string.release(oid) + } asn1.reader_free(algo_body) - bytes.free(algo_full) + bytes.wipe_free(algo_full) } bitkey, _nbits, berr = asn1.read_bit_string(sr) if string.equals(berr, "") == 1 { out.spki_key = bitkey out.spki_key_len = bytes.length(bitkey) + kk, kb = spki_key_check(kind, bitkey, out.spki_key_len) + out.key_kind = kk + out.key_bits = kb } asn1.reader_free(sr) - bytes.free(spki_content) + bytes.wipe_free(spki_content) // Remaining: optional [1]/[2] unique IDs and [3] extensions. Scan for the // [3] EXPLICIT extensions wrapper (tag 0xA3) and parse SAN out of it. @@ -624,20 +916,36 @@ fn parse_tbs_fields(tbsr: ptr, out: *LeafCert) -> string { if string.equals(eerr, "") != 1 { break } etag = asn1.last_tag(tbsr) if etag == 0xA3 { - parse_extensions_for_san(ext_content, out) + // Processed whatever the version says, as OpenSSL does: a + // "v2" certificate carrying extensions is bound by them. + parse_extensions(ext_content, out) } - bytes.free(ext_content) + bytes.wipe_free(ext_content) } + self_signed_check(out) + return "" } +// The serialNumber, kept in out.serial; an empty one (not an INTEGER) +// marks the certificate EXT_MALFORMED. A serial over 20 octets (RFC 5280 +// 4.1.2.2, a rule for CAs), zero or negative is let through, as OpenSSL +// lets it: it plays no part in validation. +fn serial_check(sn: ptr, out: *LeafCert) { + if out.serial == null { out.serial = copy_range(sn, 0, bytes.length(sn)) } + n = bytes.length(sn) + if n == 0 { + out.ext_bits = out.ext_bits | EXT_MALFORMED + } +} + // Read a TLV and return its FULL DER (tag+len+content) as fresh bytes. fn read_tlv_full(r: ptr) -> (ptr, string) { start = asn1.reader_pos(r) content, err = asn1.read_tlv(r) if string.equals(err, "") != 1 { return null, err } - bytes.free(content) + bytes.wipe_free(content) end = asn1.reader_pos(r) // The reader's buffer holds the bytes; re-read the span. We need access to // the buffer — read_tlv already advanced it. Reconstruct by re-reading the @@ -665,176 +973,741 @@ struct CertReaderView { last_tag: int } -// Parse the [3] extensions content: SEQUENCE OF Extension, each -// { extnID OID, critical BOOLEAN OPTIONAL, extnValue OCTET STRING }. Decodes -// subjectAltName (2.5.29.17) into out.dns_names / out.ip_addrs, and -// basicConstraints (2.5.29.19) and keyUsage (2.5.29.15) into out.is_ca, -// out.has_key_usage and out.key_cert_sign, which chain_verify_path checks on -// every issuing certificate. -fn parse_extensions_for_san(ext_wrapper: @keep ptr, out: *LeafCert) { +// ---- subject public keys ---- +// +// The keys a path may carry: what the signature checks and the client's +// CertificateVerify can use, at a strength worth trusting. Anything else +// (DSA, P-192 or any other curve, explicit curve parameters, RSA under 2048 +// bits) is KEY_NONE, and validate_chain refuses a path with such a +// certificate anywhere in it. Explicit curve parameters are the +// CVE-2020-0601 shape: a key whose meaning depends on parameters the +// certificate itself supplies. +const KEY_NONE = 0 +const KEY_RSA = 1 +const KEY_P256 = 2 +const KEY_P384 = 3 +const KEY_P521 = 4 +const KEY_ED25519 = 5 + +const RSA_MIN_BITS = 2048 +const RSA_MAX_BITS = 16384 + +// The KEY_* an SPKI AlgorithmIdentifier names (`params` reads what follows +// the OID), before the key itself is looked at. +fn spki_kind(oid: string, params: ptr) -> int { + if string.equals(oid, "1.2.840.113549.1.1.1") == 1 { return KEY_RSA } + if string.equals(oid, "1.3.101.112") == 1 { + if asn1.reader_eof(params) == 1 { return KEY_ED25519 } + return KEY_NONE + } + if string.equals(oid, "1.2.840.10045.2.1") != 1 { return KEY_NONE } + // ECParameters ::= CHOICE { namedCurve OID, ... }: only a named curve. + curve, cerr = asn1.read_oid(params) + if string.equals(cerr, "") != 1 { return KEY_NONE } + k = KEY_NONE + if string.equals(curve, "1.2.840.10045.3.1.7") == 1 { k = KEY_P256 } + if string.equals(curve, "1.3.132.0.34") == 1 { k = KEY_P384 } + if string.equals(curve, "1.3.132.0.35") == 1 { k = KEY_P521 } + string.release(curve) + return k +} + +// (KEY_*, RSA bits) for the subjectPublicKey `key` of an SPKI of `kind`: +// an EC key must be an uncompressed point of its curve's size, Ed25519 32 +// bytes, and RSA an RSAPublicKey of RSA_MIN_BITS..RSA_MAX_BITS with an odd +// exponent above 1. +fn spki_key_check(kind: int, key: ptr, klen: int) -> (int, int) { + if key == null || klen < 1 { return KEY_NONE, 0 } + if kind == KEY_RSA { + bits = spki_rsa_bits(key, klen) + if bits >= RSA_MIN_BITS && bits <= RSA_MAX_BITS { return KEY_RSA, bits } + return KEY_NONE, bits + } + if kind == KEY_ED25519 { + if klen == 32 { return KEY_ED25519, 0 } + return KEY_NONE, 0 + } + want = 0 + if kind == KEY_P256 { want = 65 } + if kind == KEY_P384 { want = 97 } + if kind == KEY_P521 { want = 133 } + if want == 0 || klen != want || (bytes.get(key, 0) & 0xFF) != 0x04 { return KEY_NONE, 0 } + return kind, 0 +} + +// The modulus length in bits of a DER RSAPublicKey ::= SEQUENCE { modulus +// INTEGER, publicExponent INTEGER }, or 0 when it is not one (or the +// exponent is even, 1, or negative). +fn spki_rsa_bits(key: ptr, klen: int) -> int { + tag, co, cl, nx = der_tlv(key, 0, klen) + if tag != 0x30 || nx != klen { return 0 } + end = co + cl + nt, nco, ncl, nnx = der_tlv(key, co, end) + if nt != 0x02 || ncl < 1 || (bytes.get(key, nco) & 0x80) != 0 { return 0 } + p = nco + n = ncl + while n > 0 && (bytes.get(key, p) & 0xFF) == 0 { + p = p + 1 + n = n - 1 + } + if n == 0 { return 0 } + top = bytes.get(key, p) & 0xFF + b = 0 + while top > 0 { + b = b + 1 + top = top >> 1 + } + et, eco, ecl, enx = der_tlv(key, nnx, end) + if et != 0x02 || ecl < 1 || enx != end || (bytes.get(key, eco) & 0x80) != 0 { return 0 } + last = bytes.get(key, eco + ecl - 1) & 0xFF + if (last & 1) == 0 { return 0 } + if ecl == 1 && last == 1 { return 0 } + return (n - 1) * 8 + b +} + +// ---- extensions (RFC 5280 4.2) ---- +// +// What path validation needs from each certificate: basicConstraints, +// keyUsage, extKeyUsage and nameConstraints (#1298, the x509-limbo corpus), +// the SAN names, and whether anything was critical but unknown, repeated, or +// malformed. A certificate with such an extension is refused wherever it +// appears in a path. +const EXT_BC = 1 // basicConstraints present +const EXT_PATHLEN = 4 // basicConstraints pathLenConstraint present +const EXT_EKU = 16 // extKeyUsage present +const EXT_NC = 32 // nameConstraints present +const EXT_UNKNOWN_CRITICAL = 64 // a critical extension this code does not process +const EXT_DUPLICATE = 128 // an extension OID appears twice +const EXT_MALFORMED = 256 // an extension or field did not parse, or a key identifier or AIA is critical +const EXT_SAN = 512 // subjectAltName present +const EXT_SAN_EMPTY = 1024 // subjectAltName with no names +const EXT_SELF_SIGNED = 4096 // not an extension: OpenSSL's self-signed (self_signed_check) + +const EKU_SERVER = 1 // id-kp-serverAuth +const EKU_CLIENT = 2 // id-kp-clientAuth +const EKU_ANY = 4 // anyExtendedKeyUsage +const EKU_OTHER = 8 // anything else + +const KU_DIGITAL_SIGNATURE = 1 // bit 0 +const KU_KEY_CERT_SIGN = 32 // bit 5 + +// The extnValue of every Extension in the [3] wrapper, dispatched on extnID. +fn parse_extensions(ext_wrapper: @keep ptr, out: *LeafCert) { wr = asn1.reader_new(ext_wrapper) // [3] wraps a single SEQUENCE OF Extension. seq_content, serr = asn1.read_tlv(wr) + seq_tag = asn1.last_tag(wr) asn1.reader_free(wr) - if string.equals(serr, "") != 1 { return } + if string.equals(serr, "") != 1 || seq_tag != 0x30 { + out.ext_bits = out.ext_bits | EXT_MALFORMED + if seq_content != null { bytes.wipe_free(seq_content) } + return + } + seen = list_new() er = asn1.reader_new(seq_content) while asn1.reader_eof(er) != 1 { one, oerr = asn1.read_tlv(er) // one Extension SEQUENCE content - if string.equals(oerr, "") != 1 { break } + if string.equals(oerr, "") != 1 { + out.ext_bits = out.ext_bits | EXT_MALFORMED + break + } xr = asn1.reader_new(one) oid, oiderr = asn1.read_oid(xr) if string.equals(oiderr, "") == 1 { - val = read_extn_value(xr) - if val != null { - if string.equals(oid, "2.5.29.17") == 1 { decode_san_dnsnames(val, out) } - if string.equals(oid, "2.5.29.19") == 1 { decode_basic_constraints(val, out) } - if string.equals(oid, "2.5.29.15") == 1 { decode_key_usage(val, out) } - bytes.free(val) + ob = oid_to_bytes(oid) + if bytes_list_has(seen, ob) == 1 { + out.ext_bits = out.ext_bits | EXT_DUPLICATE + bytes.wipe_free(ob) + } else { + list.add(seen, ob) + } + value, critical = read_extn_value(xr) + if value == null { + out.ext_bits = out.ext_bits | EXT_MALFORMED + } else { + parse_one_extension(oid, critical, value, out) + bytes.wipe_free(value) } string.release(oid) + } else { + out.ext_bits = out.ext_bits | EXT_MALFORMED } asn1.reader_free(xr) - bytes.free(one) + bytes.wipe_free(one) } asn1.reader_free(er) - bytes.free(seq_content) + bytes.wipe_free(seq_content) + free_bytes_list(seen) } -// Read an Extension's extnValue OCTET STRING content, skipping the optional -// critical BOOLEAN before it. Returns fresh bytes (caller frees), or null. -fn read_extn_value(xr: ptr) -> ptr { - nx, nxerr = asn1.read_tlv(xr) - if string.equals(nxerr, "") != 1 { return null } - if asn1.last_tag(xr) != 0x01 { return nx } - bytes.free(nx) - os2, os2e = asn1.read_tlv(xr) - if string.equals(os2e, "") != 1 { return null } - return os2 +// 1 if the std.list of std.bytes `l` holds a buffer equal to `b`. +fn bytes_list_has(l: ptr, b: ptr) -> int { + n = list_size(l) + bl = bytes.length(b) + i = 0 + while i < n { + e = list.at(l, i) as ptr + if e != null && bytes.length(e) == bl { + same = 1 + k = 0 + while k < bl { + if bytes.get(e, k) != bytes.get(b, k) { same = 0 } + k = k + 1 + } + if same == 1 { return 1 } + } + i = i + 1 + } + return 0 } -// basicConstraints ::= SEQUENCE { cA BOOLEAN DEFAULT FALSE, -// pathLenConstraint INTEGER OPTIONAL }. An absent or malformed cA leaves -// out.is_ca at 0, so the certificate cannot issue. -fn decode_basic_constraints(octet_content: @keep ptr, out: *LeafCert) { - bcr = asn1.reader_new(octet_content) - seq_content, serr = asn1.read_tlv(bcr) - stag = asn1.last_tag(bcr) - asn1.reader_free(bcr) - if string.equals(serr, "") != 1 { return } - if stag != 0x30 { - bytes.free(seq_content) +fn parse_one_extension(oid: string, critical: int, value: @keep ptr, out: *LeafCert) { + if string.equals(oid, "2.5.29.17") == 1 { + out.ext_bits = out.ext_bits | EXT_SAN + n_any = decode_san_dnsnames(value, out) + if n_any == 0 { out.ext_bits = out.ext_bits | EXT_SAN_EMPTY } return } - sr = asn1.reader_new(seq_content) - if asn1.reader_eof(sr) != 1 { - val, verr = asn1.read_tlv(sr) - tag = asn1.last_tag(sr) - if string.equals(verr, "") == 1 { - if tag == 0x01 { - if bytes.length(val) > 0 { - if bytes.get(val, 0) != 0 { - out.is_ca = 1 - } + if string.equals(oid, "2.5.29.19") == 1 { + out.ext_bits = out.ext_bits | EXT_BC + decode_basic_constraints(value, out) + return + } + if string.equals(oid, "2.5.29.15") == 1 { + decode_key_usage(value, out) + return + } + if string.equals(oid, "2.5.29.37") == 1 { + out.ext_bits = out.ext_bits | EXT_EKU + parse_ext_key_usage(value, out) + return + } + if string.equals(oid, "2.5.29.30") == 1 { + out.ext_bits = out.ext_bits | EXT_NC + parse_name_constraints(value, out) + return + } + // Recognised and not acted on: the subject and authority key + // identifiers (kept for self_signed_check) and authority information + // access, which RFC 5280 4.2.1.1, 4.2.1.2 and 4.2.2.1 say MUST be + // non-critical (a critical one is refused, as OpenSSL refuses it); CRL + // distribution points, certificate policies, SCTs, and non-critical + // policy constraints. Policy constraints that are critical ask for + // policy processing this code does not do. Anything else that is + // critical cannot be honoured. + if string.equals(oid, "2.5.29.14") == 1 { + if critical == 1 { out.ext_bits = out.ext_bits | EXT_MALFORMED } + parse_ski(value, out) + return + } + if string.equals(oid, "2.5.29.35") == 1 { + if critical == 1 { out.ext_bits = out.ext_bits | EXT_MALFORMED } + parse_aki(value, out) + return + } + if string.equals(oid, "1.3.6.1.5.5.7.1.1") == 1 { + if critical == 1 { out.ext_bits = out.ext_bits | EXT_MALFORMED } + return + } + if string.equals(oid, "2.5.29.36") == 1 { + if critical == 1 { out.ext_bits = out.ext_bits | EXT_UNKNOWN_CRITICAL } + return + } + if string.equals(oid, "2.5.29.31") == 1 { return } + if string.equals(oid, "2.5.29.32") == 1 || string.equals(oid, "1.3.6.1.4.1.11129.2.4.2") == 1 { return } + if critical == 1 { out.ext_bits = out.ext_bits | EXT_UNKNOWN_CRITICAL } +} + +// subjectKeyIdentifier ::= OCTET STRING: kept in out.ski. +fn parse_ski(value: @keep ptr, out: *LeafCert) { + vl = bytes.length(value) + tag, co, cl, nx = der_tlv(value, 0, vl) + if tag != 0x04 || nx != vl { + out.ext_bits = out.ext_bits | EXT_MALFORMED + return + } + if out.ski == null { out.ski = copy_range(value, co, cl) } +} + +// AuthorityKeyIdentifier ::= SEQUENCE { keyIdentifier [0] OCTET STRING +// OPTIONAL, authorityCertIssuer [1] GeneralNames OPTIONAL, +// authorityCertSerialNumber [2] INTEGER OPTIONAL }: the key identifier, +// the first directoryName of the issuer names (a Name; OpenSSL also takes +// the first), and the serial go to out.aki_keyid, aki_issuer, aki_serial. +fn parse_aki(value: @keep ptr, out: *LeafCert) { + vl = bytes.length(value) + tag, co, cl, nx = der_tlv(value, 0, vl) + if tag != 0x30 || nx != vl { + out.ext_bits = out.ext_bits | EXT_MALFORMED + return + } + p = co + end = co + cl + while p < end { + ft, fco, fcl, fnx = der_tlv(value, p, end) + if ft == 0x80 && out.aki_keyid == null { + out.aki_keyid = copy_range(value, fco, fcl) + } else if ft == 0x82 && out.aki_serial == null { + out.aki_serial = copy_range(value, fco, fcl) + } else if ft == 0xA1 { + q = fco + while q < fco + fcl { + gt, gco, gcl, gnx = der_tlv(value, q, fco + fcl) + if gt < 0 { + out.ext_bits = out.ext_bits | EXT_MALFORMED + q = fco + fcl + } else { + if gt == 0xA4 && out.aki_issuer == null { out.aki_issuer = copy_range(value, gco, gcl) } + q = gnx } } - if tag == 0x02 { - decode_path_len(val, out) - } - bytes.free(val) + } else { + out.ext_bits = out.ext_bits | EXT_MALFORMED + return } + p = fnx } - if asn1.reader_eof(sr) != 1 { - val2, verr2 = asn1.read_tlv(sr) - tag2 = asn1.last_tag(sr) - if string.equals(verr2, "") == 1 { - if tag2 == 0x02 { - decode_path_len(val2, out) +} + +// Fresh bytes holding b[off, off + n). +fn copy_range(b: ptr, off: int, n: int) -> ptr { + c = bytes.new(n) + if n > 0 { bytes.copy_from_bytes(c, 0, b, off, n) } + return c +} + +// 1 when the buffers hold the same bytes. +fn bytes_same(a: ptr, b: ptr) -> int { + n = bytes.length(a) + if bytes.length(b) != n { return 0 } + i = 0 + while i < n { + if bytes.get(a, i) != bytes.get(b, i) { return 0 } + i = i + 1 + } + return 1 +} + +// bytes.free on a buffer that may be null. +fn free_opt(b: @frees ptr) { + if b != null { bytes.wipe_free(b) } +} + +// OpenSSL's "self-signed" (ossl_x509v3_cache_extensions): the issuer is the +// subject, the AKI, if present, names the certificate itself (its key +// identifier against the SKI when both are there, its directoryName against +// the issuer, its serial against the serial: X509_check_akid), and the +// signature algorithm is one for the certificate's own kind of key. Only +// such a certificate ends a path as a trust anchor (#3310), on the pure +// backend as on OpenSSL's. Names are compared byte for byte, where OpenSSL +// compares them canonically. +fn self_signed_check(out: *LeafCert) { + if out.issuer == null || out.subject == null { return } + if dn_equal(bytes.view(out.issuer)[0..out.issuer_len], bytes.view(out.subject)[0..out.subject_len]) != 1 { return } + if out.aki_keyid != null && out.ski != null && bytes_same(out.aki_keyid, out.ski) == 0 { return } + if out.aki_serial != null && (out.serial == null || bytes_same(out.aki_serial, out.serial) == 0) { return } + if out.aki_issuer != null && bytes_same(out.aki_issuer, out.issuer) == 0 { return } + if oid_has_prefix(out.sig_alg_oid, "1.2.840.113549.1.1.") == 1 && oid_is(out.spki_algo_oid, "1.2.840.113549.1.1.1") != 1 { return } + if oid_has_prefix(out.sig_alg_oid, "1.2.840.10045.4.") == 1 && oid_is(out.spki_algo_oid, "1.2.840.10045.2.1") != 1 { return } + if oid_is(out.sig_alg_oid, "1.3.101.112") == 1 && oid_is(out.spki_algo_oid, "1.3.101.112") != 1 { return } + out.ext_bits = out.ext_bits | EXT_SELF_SIGNED +} + +// 1 when the dotted-OID bytes `oid` begin with `prefix`. +fn oid_has_prefix(oid: ptr, prefix: string) -> int { + if oid == null { return 0 } + pl = string.length(prefix) + if bytes.length(oid) < pl { return 0 } + i = 0 + while i < pl { + if (bytes.get(oid, i) & 0xFF) != (string_char_at_n(prefix, pl, i) & 0xFF) { return 0 } + i = i + 1 + } + return 1 +} + +// ExtKeyUsageSyntax ::= SEQUENCE SIZE (1..MAX) OF KeyPurposeId +fn parse_ext_key_usage(value: @keep ptr, out: *LeafCert) { + r = asn1.reader_new(value) + body, berr = asn1.read_tlv(r) + btag = asn1.last_tag(r) + asn1.reader_free(r) + if string.equals(berr, "") != 1 || btag != 0x30 { + out.ext_bits = out.ext_bits | EXT_MALFORMED + if body != null { bytes.wipe_free(body) } + return + } + br = asn1.reader_new(body) + count = 0 + while asn1.reader_eof(br) != 1 { + kp, kperr = asn1.read_oid(br) + if string.equals(kperr, "") != 1 { + out.ext_bits = out.ext_bits | EXT_MALFORMED + break + } + count = count + 1 + if string.equals(kp, "1.3.6.1.5.5.7.3.1") == 1 { + out.eku_bits = out.eku_bits | EKU_SERVER + } else if string.equals(kp, "1.3.6.1.5.5.7.3.2") == 1 { + out.eku_bits = out.eku_bits | EKU_CLIENT + } else if string.equals(kp, "2.5.29.37.0") == 1 { + out.eku_bits = out.eku_bits | EKU_ANY + } else { + out.eku_bits = out.eku_bits | EKU_OTHER + } + string.release(kp) + } + if count == 0 { out.ext_bits = out.ext_bits | EXT_MALFORMED } + asn1.reader_free(br) + bytes.wipe_free(body) +} + +// NameConstraints ::= SEQUENCE { permittedSubtrees [0] GeneralSubtrees OPTIONAL, +// excludedSubtrees [1] GeneralSubtrees OPTIONAL } +// GeneralSubtrees ::= SEQUENCE SIZE (1..MAX) OF GeneralSubtree +// GeneralSubtree ::= SEQUENCE { base GeneralName, minimum [0] DEFAULT 0, +// maximum [1] OPTIONAL } +// Each base is kept as its tag byte followed by its content, and its form +// goes to nc_forms. EXT_MALFORMED: an empty permittedSubtrees (read as +// "no constraint", as OpenSSL reads it, it would leave a constrained CA +// unconstrained, so it is refused), a minimum or maximum (RFC 5280 +// 4.2.1.10: MUST be absent; OpenSSL refuses them too), or a base that is +// not a well-formed constraint of its form (constraint_ok). An empty +// excludedSubtrees, or neither list, constrains nothing, as in OpenSSL. +fn parse_name_constraints(value: @keep ptr, out: *LeafCert) { + r = asn1.reader_new(value) + body, berr = asn1.read_tlv(r) + btag = asn1.last_tag(r) + asn1.reader_free(r) + if string.equals(berr, "") != 1 || btag != 0x30 { + out.ext_bits = out.ext_bits | EXT_MALFORMED + if body != null { bytes.wipe_free(body) } + return + } + br = asn1.reader_new(body) + while asn1.reader_eof(br) != 1 { + trees, terr = asn1.read_tlv(br) + ttag = asn1.last_tag(br) + if string.equals(terr, "") != 1 { + out.ext_bits = out.ext_bits | EXT_MALFORMED + break + } + if ttag == 0xA0 || ttag == 0xA1 { + dest = list_new() + tr = asn1.reader_new(trees) + while asn1.reader_eof(tr) != 1 { + sub, suberr = asn1.read_tlv(tr) + if string.equals(suberr, "") != 1 || asn1.last_tag(tr) != 0x30 { + out.ext_bits = out.ext_bits | EXT_MALFORMED + if sub != null { bytes.wipe_free(sub) } + break + } + sr = asn1.reader_new(sub) + base, baseerr = asn1.read_tlv(sr) + btag2 = asn1.last_tag(sr) + if string.equals(baseerr, "") == 1 { + bl = bytes.length(base) + e = bytes.new(bl + 1) + bytes.set(e, bl, 0) + bytes.set(e, 0, btag2) + if bl > 0 { bytes.copy_from_bytes(e, 1, base, 0, bl) } + list.add(dest, e) + bytes.wipe_free(base) + out.nc_forms = out.nc_forms | (1 << (btag2 & 0x1F)) + if constraint_ok(e) == 0 { out.ext_bits = out.ext_bits | EXT_MALFORMED } + if asn1.reader_eof(sr) != 1 { out.ext_bits = out.ext_bits | EXT_MALFORMED } + } else { + out.ext_bits = out.ext_bits | EXT_MALFORMED + } + asn1.reader_free(sr) + bytes.wipe_free(sub) + } + asn1.reader_free(tr) + if ttag == 0xA0 && list_size(dest) == 0 { out.ext_bits = out.ext_bits | EXT_MALFORMED } + if ttag == 0xA0 { + free_bytes_list(out.nc_permitted) + out.nc_permitted = dest + } else { + free_bytes_list(out.nc_excluded) + out.nc_excluded = dest } - bytes.free(val2) + } else { + out.ext_bits = out.ext_bits | EXT_MALFORMED } + bytes.wipe_free(trees) + } + asn1.reader_free(br) + bytes.wipe_free(body) +} + +// Is the subtree base `e` (tag byte, then content) a well-formed constraint? +// dNSName: empty (every name) or a DNS name, no leading period (RFC 5280 +// 4.2.1.10 allows one for URIs and mailboxes only); iPAddress: an address +// and a mask of the same family; rfc822Name: a mailbox, a host, or a host +// with a leading period; URI: a host, or one with a leading period; +// directoryName: a Name. The other forms are not processed, and are not +// judged here (names_allowed refuses a name of such a form below them). +fn constraint_ok(e: ptr) -> int { + tag = bytes.get(e, 0) & 0xFF + n = bytes.length(e) - 1 + if tag == 0x82 { + if n == 0 { return 1 } + return dns_name_ok(e, 1, n, 0) + } + if tag == 0x87 { + if n == 8 || n == 32 { return 1 } + return 0 } - asn1.reader_free(sr) - bytes.free(seq_content) -} - -// Decode a non-negative pathLenConstraint. Malformed negative values become -// a rejecting limit; positive values wider than int exceed the bounded path -// depth and are therefore equivalent to INT_MAX here. -fn decode_path_len(content: @keep ptr, out: *LeafCert) { - n = bytes.length(content) - out.has_path_len = 1 - out.path_len = -1 - if n < 1 { return } - // The sign is the first content byte's top bit. Test it before stripping - // leading zeros: DER writes 128 as 00 80, which reads as negative once - // the 00 is gone. - if (bytes.get(content, 0) & 0x80) != 0 { return } - start = 0 - while start < n - 1 && bytes.get(content, start) == 0 { - start = start + 1 - } - // Four or more significant bytes is at least 2^24, far past - // MAX_CHAIN_DEPTH, and a fourth byte of 0x80 or more would overflow int. - if n - start >= 4 { - out.path_len = 2147483647 - return + if tag == 0x81 { + at = byte_index(e, 1, n, 64) + if at >= 0 { return mailbox_ok(e, 1, n) } + if n > 0 && (bytes.get(e, 1) & 0xFF) == 46 { return dns_name_ok(e, 2, n - 1, 0) } + return dns_name_ok(e, 1, n, 0) + } + if tag == 0x86 { + if n > 0 && (bytes.get(e, 1) & 0xFF) == 46 { return dns_name_ok(e, 2, n - 1, 0) } + return dns_name_ok(e, 1, n, 0) + } + if tag == 0xA4 { + if n >= 2 && (bytes.get(e, 1) & 0xFF) == 0x30 { return 1 } + return 0 } - v = 0 - i = start + return 1 +} + +// The first offset in b[off, off + n) holding byte `ch`, or -1. +fn byte_index(b: ptr, off: int, n: int, ch: int) -> int { + i = 0 while i < n { - v = (v << 8) | (bytes.get(content, i) & 0xFF) + if (bytes.get(b, off + i) & 0xFF) == ch { return off + i } i = i + 1 } - out.path_len = v + return -1 } -// keyUsage ::= BIT STRING. The first content byte counts the unused bits; -// keyCertSign is bit 5, which is 0x04 of the next byte. -fn decode_key_usage(octet_content: @keep ptr, out: *LeafCert) { - kur = asn1.reader_new(octet_content) - ku_content, kerr = asn1.read_tlv(kur) - tag = asn1.last_tag(kur) - asn1.reader_free(kur) - if string.equals(kerr, "") != 1 { return } - if tag == 0x03 { - out.has_key_usage = 1 - if bytes.length(ku_content) >= 2 { - b = bytes.get(ku_content, 1) - if (b & 0x04) != 0 { - out.key_cert_sign = 1 +// Is b[off, off + n) a DNS name: dot-separated labels of 1 to 63 letters, +// digits, hyphens and underscores, 253 bytes at most, no empty label (so +// no leading, trailing or doubled dot)? With `wild`, a leftmost "*." is +// allowed before such a name. Underscores are accepted as Go and OpenSSL +// accept them (RFC 5280 asks for the preferred name syntax, which has +// none). +fn dns_name_ok(b: ptr, off: int, n: int, wild: int) -> int { + p = off + m = n + if wild == 1 && m >= 2 && (bytes.get(b, p) & 0xFF) == 42 && (bytes.get(b, p + 1) & 0xFF) == 46 { + p = p + 2 + m = m - 2 + } + if m < 1 || m > 253 { return 0 } + label = 0 + i = 0 + while i < m { + c = bytes.get(b, p + i) & 0xFF + if c == 46 { + if label == 0 { return 0 } + label = 0 + } else { + if dns_char_ok(c) == 0 { return 0 } + label = label + 1 + if label > 63 { return 0 } + } + i = i + 1 + } + if label == 0 { return 0 } + return 1 +} + +// 1 for a letter, digit, hyphen or underscore. +fn dns_char_ok(c: int) -> int { + if c >= 97 && c <= 122 { return 1 } + if c >= 65 && c <= 90 { return 1 } + if c >= 48 && c <= 57 { return 1 } + if c == 45 || c == 95 { return 1 } + return 0 +} + +// Is b[off, off + n) a mailbox "local@host": exactly one "@", a non-empty +// local part, and a DNS name after it? +fn mailbox_ok(b: ptr, off: int, n: int) -> int { + at = byte_index(b, off, n, 64) + if at <= off { return 0 } + if byte_index(b, at + 1, off + n - at - 1, 64) >= 0 { return 0 } + return dns_name_ok(b, at + 1, off + n - at - 1, 0) +} + +// Read an Extension's extnValue OCTET STRING content, after the optional +// critical BOOLEAN: (fresh bytes, critical), or (null, 0) when the +// Extension does not parse: a BOOLEAN that is not one byte of 0x00 or 0xFF +// (X.690 8.2, 11.1), an extnValue that is not an OCTET STRING, or anything +// after it. The caller refuses the certificate then (EXT_MALFORMED). +fn read_extn_value(xr: ptr) -> (ptr, int) { + nx, nxerr = asn1.read_tlv(xr) + if string.equals(nxerr, "") != 1 { return null, 0 } + if asn1.last_tag(xr) != 0x01 { + if asn1.last_tag(xr) != 0x04 || asn1.reader_eof(xr) != 1 { + bytes.wipe_free(nx) + return null, 0 + } + return nx, 0 + } + critical = -1 + if bytes.length(nx) == 1 && (bytes.get(nx, 0) & 0xFF) == 0xFF { critical = 1 } + if bytes.length(nx) == 1 && (bytes.get(nx, 0) & 0xFF) == 0 { critical = 0 } + bytes.wipe_free(nx) + if critical < 0 { return null, 0 } + os2, os2e = asn1.read_tlv(xr) + if string.equals(os2e, "") != 1 { return null, 0 } + if asn1.last_tag(xr) != 0x04 || asn1.reader_eof(xr) != 1 { + bytes.wipe_free(os2) + return null, 0 + } + return os2, critical +} + +// basicConstraints ::= SEQUENCE { cA BOOLEAN DEFAULT FALSE, +// pathLenConstraint INTEGER (0..MAX) OPTIONAL }. A cA of TRUE sets +// out.is_ca; a pathLenConstraint goes to out.path_len (EXT_PATHLEN), and one +// of four bytes or more, too large to limit any path, as 2147483647. A +// BOOLEAN that is not one byte of 0x00 or 0xFF, a pathLenConstraint that is +// negative or not minimal DER, a field repeated or after the one that +// follows it (the last no longer wins, as OpenSSL refuses the same), and +// anything else make the extension malformed (EXT_MALFORMED), so the +// certificate is refused rather than read as not a CA. +fn decode_basic_constraints(octet_content: @keep ptr, out: *LeafCert) { + r = asn1.reader_new(octet_content) + body, berr = asn1.read_tlv(r) + btag = asn1.last_tag(r) + // Nothing after the SEQUENCE: bytes there were read past (#3303). + trailing = asn1.reader_eof(r) != 1 + asn1.reader_free(r) + if string.equals(berr, "") != 1 || btag != 0x30 || trailing { + out.ext_bits = out.ext_bits | EXT_MALFORMED + if body != null { bytes.wipe_free(body) } + return + } + br = asn1.reader_new(body) + // The fields read so far: 1 the cA, 2 the pathLenConstraint, in order. + seen = 0 + while asn1.reader_eof(br) != 1 { + f, ferr = asn1.read_tlv(br) + ftag = asn1.last_tag(br) + if string.equals(ferr, "") != 1 { + out.ext_bits = out.ext_bits | EXT_MALFORMED + break + } + if (ftag == 0x01 && seen >= 1) || (ftag == 0x02 && seen >= 2) { + out.ext_bits = out.ext_bits | EXT_MALFORMED + } else if ftag == 0x01 { + seen = 1 + if bytes.length(f) == 1 && (bytes.get(f, 0) & 0xFF) == 0xFF { + out.is_ca = 1 + } else if bytes.length(f) != 1 || (bytes.get(f, 0) & 0xFF) != 0 { + out.ext_bits = out.ext_bits | EXT_MALFORMED } + } else if ftag == 0x02 { + seen = 2 + fl = bytes.length(f) + if fl < 1 || (bytes.get(f, 0) & 0x80) != 0 { + out.ext_bits = out.ext_bits | EXT_MALFORMED + } else if fl > 1 && (bytes.get(f, 0) & 0xFF) == 0 && (bytes.get(f, 1) & 0x80) == 0 { + // A leading zero the next byte does not need is not DER + // (X.690 8.3.2), and OpenSSL refuses it too: 00 00 00 05 must + // not read as a large constraint. + out.ext_bits = out.ext_bits | EXT_MALFORMED + } else if fl > 3 { + // RFC 5280 allows 0..MAX: a constraint of 2^23 or more limits + // no path this code builds (MAX_CHAIN_DEPTH), so it is read + // as unbounded, as OpenSSL reads a large one, not as a reason + // to refuse the CA (#3303). + out.path_len = 2147483647 + out.ext_bits = out.ext_bits | EXT_PATHLEN + } else { + v = 0 + k = 0 + while k < fl { + v = (v << 8) | (bytes.get(f, k) & 0xFF) + k = k + 1 + } + out.path_len = v + out.ext_bits = out.ext_bits | EXT_PATHLEN + } + } else { + out.ext_bits = out.ext_bits | EXT_MALFORMED } + bytes.wipe_free(f) + } + asn1.reader_free(br) + bytes.wipe_free(body) +} + +// keyUsage ::= BIT STRING. The extension sets out.has_key_usage; bit i +// (counting from the first byte's top bit) becomes (1 << i) in +// out.key_usage, and keyCertSign, bit 5, sets out.key_cert_sign. A value +// that is not a BIT STRING makes the extension malformed (EXT_MALFORMED), +// so the certificate is refused rather than read as having no keyUsage. +fn decode_key_usage(octet_content: @keep ptr, out: *LeafCert) { + out.has_key_usage = 1 + r = asn1.reader_new(octet_content) + bits, _unused, berr = asn1.read_bit_string(r) + asn1.reader_free(r) + if string.equals(berr, "") != 1 || bits == null { + out.ext_bits = out.ext_bits | EXT_MALFORMED + return + } + n = bytes.length(bits) + ku = 0 + i = 0 + while i < n * 8 && i < 16 { + if ((bytes.get(bits, i / 8) >> (7 - (i % 8))) & 1) == 1 { ku = ku | (1 << i) } + i = i + 1 } - bytes.free(ku_content) + out.key_usage = ku + if (ku & KU_KEY_CERT_SIGN) != 0 { out.key_cert_sign = 1 } + bytes.wipe_free(bits) } // The SAN extnValue OCTET STRING wraps GeneralNames ::= SEQUENCE OF GeneralName. -// dNSName is [2] IMPLICIT IA5String (context tag 0x82). Add each to dns_names, -// rejecting any with an embedded NUL. -fn decode_san_dnsnames(octet_content: @keep ptr, out: *LeafCert) { +// Each dNSName ([2] IMPLICIT IA5String) goes to dns_names, each iPAddress +// ([7], 4 or 16 bytes) to ip_addrs, each rfc822Name ([1]), URI ([6]) and +// directoryName ([4]) to san_other as its tag byte then its content, and +// every form seen to san_forms. A dNSName with a NUL in it or an iPAddress +// of any other length marks the certificate EXT_MALFORMED. Returns how many +// GeneralNames there were. +fn decode_san_dnsnames(octet_content: @keep ptr, out: *LeafCert) -> int { or = asn1.reader_new(octet_content) gn_content, gerr = asn1.read_tlv(or) // the GeneralNames SEQUENCE content + gtag = asn1.last_tag(or) + trailing = asn1.reader_eof(or) != 1 asn1.reader_free(or) - if string.equals(gerr, "") != 1 { return } + if string.equals(gerr, "") != 1 || gtag != 0x30 || trailing { + out.ext_bits = out.ext_bits | EXT_MALFORMED + if gn_content != null { bytes.wipe_free(gn_content) } + return 0 + } + int count = 0 gr = asn1.reader_new(gn_content) while asn1.reader_eof(gr) != 1 { name_content, nerr = asn1.read_tlv(gr) - if string.equals(nerr, "") != 1 { break } + if string.equals(nerr, "") != 1 { + out.ext_bits = out.ext_bits | EXT_MALFORMED + break + } tag = asn1.last_tag(gr) + count = count + 1 + out.san_forms = out.san_forms | (1 << (tag & 0x1F)) + nlen = bytes.length(name_content) if tag == 0x87 { - // iPAddress [7] IMPLICIT OCTET STRING -- 4 bytes for IPv4, 16 for - // IPv6, raw network order (RFC 5280 s4.2.1.6). Any other length is - // malformed; skip it rather than match against it. - alen = bytes.length(name_content) - if alen == 4 || alen == 16 { - ip = bytes.new(alen) - bytes.copy_from_bytes(ip, 0, name_content, 0, alen) + // iPAddress [7] IMPLICIT OCTET STRING: 4 bytes for IPv4, 16 for + // IPv6, raw network order (RFC 5280 s4.2.1.6). + if nlen == 4 || nlen == 16 { + ip = bytes.new(nlen) + bytes.copy_from_bytes(ip, 0, name_content, 0, nlen) list.add(out.ip_addrs, ip) + } else { + out.ext_bits = out.ext_bits | EXT_MALFORMED } - } - if tag == 0x82 { + } else if tag == 0x82 { // dNSName IA5String content - nlen = bytes.length(name_content) has_nul = 0 i = 0 while i < nlen { @@ -846,12 +1719,22 @@ fn decode_san_dnsnames(octet_content: @keep ptr, out: *LeafCert) { if nlen > 0 { bytes.set(nm, nlen - 1, 0) } bytes.copy_from_bytes(nm, 0, name_content, 0, nlen) list.add(out.dns_names, nm) + } else { + out.ext_bits = out.ext_bits | EXT_MALFORMED } + } else if tag == 0x81 || tag == 0x86 || tag == 0xA4 { + if out.san_other == null { out.san_other = list_new() } + e = bytes.new(nlen + 1) + bytes.set(e, nlen, 0) + bytes.set(e, 0, tag) + if nlen > 0 { bytes.copy_from_bytes(e, 1, name_content, 0, nlen) } + list.add(out.san_other, e) } - bytes.free(name_content) + bytes.wipe_free(name_content) } asn1.reader_free(gr) - bytes.free(gn_content) + bytes.wipe_free(gn_content) + return count } // ---- LeafCert accessors + cleanup ---- @@ -895,21 +1778,21 @@ leaf_dns_name(c: *LeafCert, i: int) -> ptr { // Free every owned buffer/string in a parsed LeafCert. free_leaf(c: *LeafCert) { - if c.tbs != null { bytes.free(c.tbs) } - if c.signature != null { bytes.free(c.signature) } - if c.spki_key != null { bytes.free(c.spki_key) } - if c.issuer != null { bytes.free(c.issuer) } - if c.subject != null { bytes.free(c.subject) } - if c.sig_alg_oid != null { bytes.free(c.sig_alg_oid) } - if c.spki_algo_oid != null { bytes.free(c.spki_algo_oid) } - if c.not_before != null { bytes.free(c.not_before) } - if c.not_after != null { bytes.free(c.not_after) } + if c.tbs != null { bytes.wipe_free(c.tbs) } + if c.signature != null { bytes.wipe_free(c.signature) } + if c.spki_key != null { bytes.wipe_free(c.spki_key) } + if c.issuer != null { bytes.wipe_free(c.issuer) } + if c.subject != null { bytes.wipe_free(c.subject) } + if c.sig_alg_oid != null { bytes.wipe_free(c.sig_alg_oid) } + if c.spki_algo_oid != null { bytes.wipe_free(c.spki_algo_oid) } + if c.not_before != null { bytes.wipe_free(c.not_before) } + if c.not_after != null { bytes.wipe_free(c.not_after) } if c.dns_names != null { n = list_size(c.dns_names) i = 0 while i < n { e = list.at(c.dns_names, i) as ptr - bytes.free(e) + bytes.wipe_free(e) i = i + 1 } list_free(c.dns_names) @@ -919,11 +1802,40 @@ free_leaf(c: *LeafCert) { j = 0 while j < m { a = list.at(c.ip_addrs, j) as ptr - bytes.free(a) + bytes.wipe_free(a) j = j + 1 } list_free(c.ip_addrs) } + free_bytes_list(c.nc_permitted) + free_bytes_list(c.nc_excluded) + free_bytes_list(c.san_other) + c.nc_permitted = null + c.nc_excluded = null + c.san_other = null + free_opt(c.serial) + free_opt(c.ski) + free_opt(c.aki_keyid) + free_opt(c.aki_issuer) + free_opt(c.aki_serial) + c.serial = null + c.ski = null + c.aki_keyid = null + c.aki_issuer = null + c.aki_serial = null +} + +// Free a std.list of std.bytes buffers (null-safe). +fn free_bytes_list(l: @frees ptr) { + if l == null { return } + n = list_size(l) + i = 0 + while i < n { + e = list.at(l, i) as ptr + if e != null { bytes.wipe_free(e) } + i = i + 1 + } + list_free(l) } // ---- validity-period check (RFC 5280 §4.1.2.5) ---- @@ -955,15 +1867,15 @@ fn time_to_key(t: ptr, tlen: int) -> string { k = 0 while k < 12 { bytes.set(out, 2 + k, bytes.get(digits, k)); k = k + 1 } r = bytes.to_string(out, 14) - bytes.free(out); bytes.free(digits) + bytes.wipe_free(out); bytes.wipe_free(digits) return r } if dn == 14 { r = bytes.to_string(digits, 14) - bytes.free(digits) + bytes.wipe_free(digits) return r } - bytes.free(digits) + bytes.wipe_free(digits) return "" } @@ -983,7 +1895,7 @@ fn now_key() -> string { i = i + 1 } r = bytes.to_string(out, 14) - bytes.free(out) + bytes.wipe_free(out) return r } @@ -1053,6 +1965,10 @@ fn dnsname_matches(pat: ptr, plen: int, host: string, hlen: int) -> int { j = j + 1 } if hdot <= 0 { return 0 } // no dot, or empty first label -> no match + // At least two labels after the wildcard: "*.com" would cover a whole + // top-level domain (x509-limbo's public-suffix-wildcard-san). Without a + // public suffix list, "*.co.uk" is not caught. + if byte_index(pat, 2, plen - 2, 46) < 0 { return 0 } // The pattern tail is pat[1..plen) == ".rest"; host tail is host[hdot..hlen). tail_len = plen - 1 if tail_len != (hlen - hdot) { return 0 } @@ -1103,7 +2019,7 @@ fn parse_ipv4(host: string, hlen: int) -> ptr { if digits == 0 { bad = 1 } if idx != 3 { bad = 1 } if bad == 1 { - bytes.free(out) + bytes.wipe_free(out) return null } bytes.set(out, 3, octet) @@ -1179,7 +2095,7 @@ fn parse_ipv6(host: string, hlen: int) -> ptr { if gap < 0 && nw != 8 { bad = 1 } if gap >= 0 && nw > 7 { bad = 1 } if bad == 1 { - bytes.free(words) + bytes.wipe_free(words) return null } // bytes.new does not zero: every byte of `out` is written below. @@ -1202,7 +2118,7 @@ fn parse_ipv6(host: string, hlen: int) -> ptr { bytes.set(out, 16 - 2 * tail + k, bytes.get(words, 2 * head + k) & 0xFF) k = k + 1 } - bytes.free(words) + bytes.wipe_free(words) return out } @@ -1243,11 +2159,11 @@ check_hostname(c: *LeafCert, host: string) -> string { ipv4 = parse_ipv4(host, hlen0) if ipv4 != null { if c.ip_addrs == null { - bytes.free(ipv4) + bytes.wipe_free(ipv4) return "certificate has no iPAddress SAN for this address" } hit4 = ip_san_matches(c, ipv4) - bytes.free(ipv4) + bytes.wipe_free(ipv4) if hit4 == 1 { return "" } return "certificate is not valid for the requested address" } @@ -1258,11 +2174,11 @@ check_hostname(c: *LeafCert, host: string) -> string { ipv6 = parse_ipv6(host, hlen0) if ipv6 == null { return "not a valid IPv6 address" } if c.ip_addrs == null { - bytes.free(ipv6) + bytes.wipe_free(ipv6) return "certificate has no iPAddress SAN for this address" } hit6 = ip_san_matches(c, ipv6) - bytes.free(ipv6) + bytes.wipe_free(ipv6) if hit6 == 1 { return "" } return "certificate is not valid for the requested address" } @@ -1315,6 +2231,10 @@ fn der_tlv_slice(buf: byte[], p: int, end: int) -> (int, int, int, int) { clen = lb } else { nbytes = lb & 0x7F + // At most three length bytes (16 MB): four could overflow `clen` + // into a negative length that passes the bound below, and the + // callers would then walk backwards through the buffer (#3308). + if nbytes == 0 || nbytes > 3 { return -1, 0, 0, 0 } if q + nbytes > end { return -1, 0, 0, 0 } i = 0 while i < nbytes { clen = (clen * 256) + (buf[q] & 0xFF); q = q + 1; i = i + 1 } @@ -1468,15 +2388,15 @@ verify_ocsp_signature(der: byte[], issuer: *LeafCert) -> int { // signatureAlgorithm ::= SEQUENCE { algorithm OID, ... } ta, ca, la, na = der_tlv_slice(der, nt, bend) - if ta != 0x30 { bytes.free(tbs); return 0 } + if ta != 0x30 { bytes.wipe_free(tbs); return 0 } toi, coi, loi, _noi = der_tlv_slice(der, ca, ca + la) - if toi != 0x06 { bytes.free(tbs); return 0 } + if toi != 0x06 { bytes.wipe_free(tbs); return 0 } sig_alg_oid = oid_der_to_dotted_bytes_slice(der, coi, loi) // signature BIT STRING — strip the leading "unused bits" octet (0x00). tsg, csg, lsg, nsg = der_tlv_slice(der, na, bend) - if tsg != 0x03 { bytes.free(tbs); bytes.free(sig_alg_oid); return 0 } - if lsg < 1 { bytes.free(tbs); bytes.free(sig_alg_oid); return 0 } + if tsg != 0x03 { bytes.wipe_free(tbs); bytes.wipe_free(sig_alg_oid); return 0 } + if lsg < 1 { bytes.wipe_free(tbs); bytes.wipe_free(sig_alg_oid); return 0 } sig_len = lsg - 1 sig = bytes.new(sig_len) if sig_len > 0 { bytes.set(sig, sig_len - 1, 0) } @@ -1507,7 +2427,7 @@ verify_ocsp_signature(der: byte[], issuer: *LeafCert) -> int { d = malloc(LEAFCERT_SIZE) as *LeafCert init_leaf(d) perr = parse_certificate(bytes.view(cert_der)[0..cert_der_len], d) - bytes.free(cert_der) + bytes.wipe_free(cert_der) if string.equals(perr, "") == 1 { // The delegated responder cert must be signed by `issuer` // and carry the OCSPSigning EKU. If either fails, the staple @@ -1539,9 +2459,9 @@ verify_ocsp_signature(der: byte[], issuer: *LeafCert) -> int { free_leaf(delegated) free(delegated) } - bytes.free(tbs) - bytes.free(sig_alg_oid) - bytes.free(sig) + bytes.wipe_free(tbs) + bytes.wipe_free(sig_alg_oid) + bytes.wipe_free(sig) return result } @@ -1562,11 +2482,11 @@ fn oid_der_to_dotted_bytes_slice(buf: byte[], coff: int, clen: int) -> ptr { bytes.set(tlv, 0, 0x06) bytes.copy_from_bytes(tlv, 1, lenoct, 0, ll) bytes.copy_from_slice(tlv, 1 + ll, buf[coff..coff + clen]) - bytes.free(lenoct) + bytes.wipe_free(lenoct) r = asn1.reader_new(tlv) dotted, oerr = asn1.read_oid(r) asn1.reader_free(r) - bytes.free(tlv) + bytes.wipe_free(tlv) if string.equals(oerr, "") != 1 { return bytes.new(0) } out = oid_to_bytes(dotted) string.release(dotted) @@ -1613,7 +2533,7 @@ fn cert_has_ocsp_signing_eku(c: *LeafCert) -> int { if toid == 0x06 { oid_b = oid_der_to_dotted_bytes(buf, coid, loid) is_eku = oid_is(oid_b, "2.5.29.37") - bytes.free(oid_b) + bytes.wipe_free(oid_b) if is_eku == 1 { // Skip optional critical BOOL, reach extnValue OCTET STRING. tv, cv, lv, nv = der_tlv(buf, noid, ext_inner_end) @@ -1646,7 +2566,7 @@ fn eku_octets_have_ocsp_signing(buf: ptr, start: int, end: int) -> int { if to == 0x06 { oid_b = oid_der_to_dotted_bytes(buf, co, lo) hit = oid_is(oid_b, "1.3.6.1.5.5.7.3.9") - bytes.free(oid_b) + bytes.wipe_free(oid_b) if hit == 1 { return 1 } } p = no @@ -1670,6 +2590,7 @@ fn eku_octets_have_ocsp_signing(buf: ptr, start: int, end: int) -> int { // rsaEncryption 1.2.840.113549.1.1.1 // id-ecPublicKey 1.2.840.10045.2.1 +import std.cryptography import std.cryptography.rsa // Compare a bytes-stored OID (ASCII dotted string) against a literal. @@ -1703,30 +2624,6 @@ fn dn_equal(a: byte[], b: byte[]) -> int { return 1 } -// Build the DER DigestInfo for a SHA-256 hash (what PKCS#1 v1.5 verify wants): -// SEQUENCE { SEQUENCE { OID 2.16.840.1.101.3.4.2.1, NULL }, OCTET STRING hash } -// The 19-byte fixed prefix for SHA-256 followed by the 32-byte hash. -fn sha256_digestinfo(hash32: ptr) -> ptr { - // 30 31 30 0d 06 09 60 86 48 01 65 03 04 02 01 05 00 04 20 - prefix = bytes.new(19) - bytes.set(prefix, 0, 0x30); bytes.set(prefix, 1, 0x31) - bytes.set(prefix, 2, 0x30); bytes.set(prefix, 3, 0x0d) - bytes.set(prefix, 4, 0x06); bytes.set(prefix, 5, 0x09) - bytes.set(prefix, 6, 0x60); bytes.set(prefix, 7, 0x86) - bytes.set(prefix, 8, 0x48); bytes.set(prefix, 9, 0x01) - bytes.set(prefix, 10, 0x65); bytes.set(prefix, 11, 0x03) - bytes.set(prefix, 12, 0x04); bytes.set(prefix, 13, 0x02) - bytes.set(prefix, 14, 0x01); bytes.set(prefix, 15, 0x05) - bytes.set(prefix, 16, 0x00); bytes.set(prefix, 17, 0x04) - bytes.set(prefix, 18, 0x20) - di = bytes.new(51) - bytes.set(di, 50, 0) - bytes.copy_from_bytes(di, 0, prefix, 0, 19) - bytes.copy_from_bytes(di, 19, hash32, 0, 32) - bytes.free(prefix) - return di -} - // Parse an RSA SPKI key blob (the SubjectPublicKey content, which for RSA is // SEQUENCE { INTEGER n, INTEGER e }) into (n, e) bignums. Returns (n,e,"") or // (null,null,err). Caller frees the bignums with bignum.free. @@ -1739,21 +2636,21 @@ fn parse_rsa_spki(spki_key: byte[]) -> (ptr, ptr, string) { r = asn1.reader_new(view) sub, body, serr = asn1.read_sequence(r) if string.equals(serr, "") != 1 { - asn1.reader_free(r); bytes.free(view) + asn1.reader_free(r); bytes.wipe_free(view) return null, null, serr } n, nerr = asn1.read_integer(sub) if string.equals(nerr, "") != 1 { - asn1.reader_free(sub); asn1.reader_free(r); bytes.free(view); bytes.free(body) + asn1.reader_free(sub); asn1.reader_free(r); bytes.wipe_free(view); bytes.wipe_free(body) return null, null, nerr } e, eerr = asn1.read_integer(sub) if string.equals(eerr, "") != 1 { bignum.free(n) - asn1.reader_free(sub); asn1.reader_free(r); bytes.free(view); bytes.free(body) + asn1.reader_free(sub); asn1.reader_free(r); bytes.wipe_free(view); bytes.wipe_free(body) return null, null, eerr } - asn1.reader_free(sub); asn1.reader_free(r); bytes.free(view); bytes.free(body) + asn1.reader_free(sub); asn1.reader_free(r); bytes.wipe_free(view); bytes.wipe_free(body) return n, e, "" } @@ -1765,15 +2662,19 @@ verify_cert_signature(leaf: *LeafCert, issuer: *LeafCert) -> int { return verify_sig_over_bytes(bytes.view(leaf.tbs)[0..leaf.tbs_len], leaf.sig_alg_oid, bytes.view(leaf.signature)[0..leaf.signature_len], issuer.spki_algo_oid, bytes.view(issuer.spki_key)[0..issuer.spki_key_len]) } -// Verify `sig` over the raw bytes `msg[0..msg_len)` under the signature -// algorithm named by `sig_alg_oid` (dotted-OID bytes), using the signer's SPKI -// (`key_algo_oid` + `key`/`key_len`). Returns 1 if valid, else 0. The three -// algorithms cover the real world for both X.509 cert signatures and OCSP -// responder signatures (RFC 6960 §4.2.2.2 uses the same AlgorithmIdentifier / -// key shapes as certificates): -// sha256WithRSAEncryption 1.2.840.113549.1.1.11 -> RSA PKCS#1 v1.5 / SHA-256 -// ecdsa-with-SHA256 1.2.840.10045.4.3.2 -> ECDSA P-256 / SHA-256 -// ecdsa-with-SHA384 1.2.840.10045.4.3.3 -> ECDSA P-384 / SHA-384 +// Verify `sig` over the bytes `msg` under the signature algorithm named by +// `sig_alg_oid` (dotted-OID bytes), with the signer's SPKI (`key_algo_oid` +// and `key`). Returns 1 if valid, else 0. Used for X.509 certificate +// signatures and OCSP responder signatures (RFC 6960 4.2.2.2 uses the same +// AlgorithmIdentifier and key shapes): +// sha256/384/512WithRSAEncryption 1.2.840.113549.1.1.11/12/13 +// RSA PKCS#1 v1.5 on an rsaEncryption key +// ecdsa-with-SHA256/384/512 1.2.840.10045.4.3.2/3/4 +// ECDSA on an id-ecPublicKey key: P-256, P-384 or P-521 by its size +// id-Ed25519 1.3.101.112 +// sha384WithRSAEncryption is what Microsoft's and many DigiCert chains use +// (x509-limbo's online::bing.com), and P-521 is in the Web PKI's roots +// (#3307). fn verify_sig_over_bytes(msg: byte[], sig_alg_oid: ptr, sig: byte[], key_algo_oid: ptr, key: byte[]) -> int { if msg.len < 0 { panic("tls13_cert.verify_sig_over_bytes: the `msg` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } if sig.len < 0 { panic("tls13_cert.verify_sig_over_bytes: the `sig` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } @@ -1783,70 +2684,93 @@ fn verify_sig_over_bytes(msg: byte[], sig_alg_oid: ptr, sig: byte[], key_algo_oi if sig == null { return 0 } if key == null { return 0 } + // The hash: 1 SHA-256, 2 SHA-384, 3 SHA-512; and the family. + hid = 0 + rsa_sig = 0 + ec_sig = 0 + if oid_is(sig_alg_oid, "1.2.840.113549.1.1.11") == 1 { hid = 1; rsa_sig = 1 } + if oid_is(sig_alg_oid, "1.2.840.113549.1.1.12") == 1 { hid = 2; rsa_sig = 1 } + if oid_is(sig_alg_oid, "1.2.840.113549.1.1.13") == 1 { hid = 3; rsa_sig = 1 } + if oid_is(sig_alg_oid, "1.2.840.10045.4.3.2") == 1 { hid = 1; ec_sig = 1 } + if oid_is(sig_alg_oid, "1.2.840.10045.4.3.3") == 1 { hid = 2; ec_sig = 1 } + if oid_is(sig_alg_oid, "1.2.840.10045.4.3.4") == 1 { hid = 3; ec_sig = 1 } + + if oid_is(sig_alg_oid, "1.3.101.112") == 1 { + if oid_is(key_algo_oid, "1.3.101.112") != 1 || key_len != 32 { return 0 } + pub = bytes.from_slice(key) + ok = ed25519.verify(pub, msg, sig) + bytes.wipe_free(pub) + return ok + } + if hid == 0 { return 0 } + result = 0 - if oid_is(sig_alg_oid, "1.2.840.113549.1.1.11") == 1 { - // sha256WithRSAEncryption. Signer key must be rsaEncryption. - if oid_is(key_algo_oid, "1.2.840.113549.1.1.1") == 1 { - hash = sha256_of_bytes(msg) - n, e, perr = parse_rsa_spki(key) - if string.equals(perr, "") == 1 { - k = rsa.key_from_components(n, e, null) - di = sha256_digestinfo(hash) - result = rsa.verify_pkcs1(k, di, sig) - bytes.free(di) - rsa.key_free(k) // frees n, e too - } - bytes.free(hash) - } - } - if oid_is(sig_alg_oid, "1.2.840.10045.4.3.2") == 1 { - // ecdsa-with-SHA256 on a P-256 signer key (65-byte uncompressed point). - if oid_is(key_algo_oid, "1.2.840.10045.2.1") == 1 { - if key_len == 65 { - hash = sha256_of_bytes(msg) - px, py, kerr = ec_point_from_spki(key, 32) - if string.equals(kerr, "") == 1 { - r32, s32, serr = split_ecdsa_sig(sig, 32) - if string.equals(serr, "") == 1 { - result = p256.ecdsa_verify(px, py, hash, r32, s32) - bytes.free(r32); bytes.free(s32) - } - bytes.free(px); bytes.free(py) - } - bytes.free(hash) - } + if rsa_sig == 1 && oid_is(key_algo_oid, "1.2.840.113549.1.1.1") == 1 { + hash = digest_of(msg, hid) + n, e, perr = parse_rsa_spki(key) + if string.equals(perr, "") == 1 { + k = rsa.key_from_components(n, e, null) + di = digest_info(hash, hid) + result = rsa.verify_pkcs1(k, di, sig) + bytes.wipe_free(di) + rsa.key_free(k) // frees n, e too } - } - if oid_is(sig_alg_oid, "1.2.840.10045.4.3.3") == 1 { - // ecdsa-with-SHA384 on a P-384 signer key (97-byte uncompressed point). - // Common on modern roots/intermediates (e.g. Google Trust Services). - if oid_is(key_algo_oid, "1.2.840.10045.2.1") == 1 { - if key_len == 97 { - hash = sha384_of_bytes(msg) - px, py, kerr = ec_point_from_spki(key, 48) - if string.equals(kerr, "") == 1 { - r48, s48, serr = split_ecdsa_sig(sig, 48) - if string.equals(serr, "") == 1 { - result = p384.ecdsa_verify(px, py, hash, r48, s48) - bytes.free(r48); bytes.free(s48) - } - bytes.free(px); bytes.free(py) + bytes.wipe_free(hash) + } + if ec_sig == 1 && oid_is(key_algo_oid, "1.2.840.10045.2.1") == 1 { + coord = 0 + if key_len == 65 { coord = 32 } + if key_len == 97 { coord = 48 } + if key_len == 133 { coord = 66 } + if coord > 0 { + hash = digest_of(msg, hid) + px, py, kerr = ec_point_from_spki(key, coord) + if string.equals(kerr, "") == 1 { + rr, ss, serr = split_ecdsa_sig(sig, coord) + if string.equals(serr, "") == 1 { + if coord == 32 { result = p256.ecdsa_verify(px, py, hash, rr, ss) } + if coord == 48 { result = p384.ecdsa_verify(px, py, hash, rr, ss) } + if coord == 66 { result = p521.ecdsa_verify(px, py, hash, rr, ss) } + bytes.wipe_free(rr); bytes.wipe_free(ss) } - bytes.free(hash) + bytes.wipe_free(px); bytes.wipe_free(py) } + bytes.wipe_free(hash) } } return result } -// SHA-384 of a std.bytes buffer (streaming path). -fn sha384_of_bytes(data: byte[]) -> ptr { - ctx, err = sha2.new("sha384") +// SHA-256 (hid 1), SHA-384 (2) or SHA-512 (3) of `data`. +fn digest_of(data: byte[], hid: int) -> ptr { + name = "sha256" + if hid == 2 { name = "sha384" } + if hid == 3 { name = "sha512" } + ctx, err = sha2.new(name) if err != "" { return null } sha2.update(ctx, data) return sha2.final_bytes(ctx) } +// The DER DigestInfo PKCS#1 v1.5 signs, for a hash from digest_of: +// SEQUENCE { SEQUENCE { OID 2.16.840.1.101.3.4.2., NULL }, OCTET STRING hash } +fn digest_info(hash: ptr, hid: int) -> ptr { + hl = bytes.length(hash) + di = bytes.new(19 + hl) + bytes.set(di, 0, 0x30); bytes.set(di, 1, 17 + hl) + bytes.set(di, 2, 0x30); bytes.set(di, 3, 0x0d) + bytes.set(di, 4, 0x06); bytes.set(di, 5, 0x09) + bytes.set(di, 6, 0x60); bytes.set(di, 7, 0x86) + bytes.set(di, 8, 0x48); bytes.set(di, 9, 0x01) + bytes.set(di, 10, 0x65); bytes.set(di, 11, 0x03) + bytes.set(di, 12, 0x04); bytes.set(di, 13, 0x02) + bytes.set(di, 14, hid); bytes.set(di, 15, 0x05) + bytes.set(di, 16, 0x00); bytes.set(di, 17, 0x04) + bytes.set(di, 18, hl) + bytes.copy_from_bytes(di, 19, hash, 0, hl) + return di +} + // Split an uncompressed EC point (04 || X || Y, each `coord` bytes) from an SPKI // key blob into (X, Y, ""). Returns (null,null,err) if it isn't a // (1 + 2*coord)-byte uncompressed point. Caller frees X/Y. coord = 32 for @@ -1886,119 +2810,621 @@ chain_verify(leaf: *LeafCert, anchors: ptr) -> string { return "certificate does not chain to a trusted anchor" } -// Does `cert` chain DIRECTLY to any trusted anchor (issuer DN == anchor subject -// DN and cert signature verifies under that anchor)? Returns 1 if so. -fn chains_to_anchor(cert: *LeafCert, anchors: ptr) -> int { - n = list_size(anchors) - i = 0 - while i < n { - a = list.at(anchors, i) as *LeafCert - if a != null { - if dn_equal(bytes.view(cert.issuer)[0..cert.issuer_len], bytes.view(a.subject)[0..a.subject_len]) == 1 { - if verify_cert_signature(cert, a) == 1 { - return 1 - } - } - } - i = i + 1 - } - return 0 +// Verify a certificate PATH from `leaf` through the server-sent +// `intermediates` (a std.list of *LeafCert, possibly empty or null) to one of +// the trusted `anchors`, now, for a TLS server: validate_chain with +// PURPOSE_SERVER at the current time. "" on a valid path, else why not. +// +// This used to accept any path whose DNs matched and whose signatures +// verified, never asking whether an issuer was a CA: a certificate a CA +// issued to any web site could sign one for any other host (#3303). +chain_verify_path(leaf: *LeafCert, intermediates: ptr, anchors: ptr) -> string { + return validate_chain(leaf, intermediates, anchors, "", PURPOSE_SERVER, -1) } -// Verify a certificate PATH: `leaf` may chain through server-sent -// `intermediates` (a std.list of *LeafCert, possibly empty/null) up to a -// trusted anchor in `anchors`. Candidate issuers are explored with bounded -// backtracking because two certificates can legitimately share a subject and -// public key while only one has a path to a trusted anchor. -chain_verify_path(leaf: *LeafCert, intermediates: ptr, anchors: ptr) -> string { - if anchors == null { return "no trust anchors" } - if list_size(anchors) == 0 { return "no trust anchors" } - - used = list_new() - state = PathSearchState { attempts: 0, limit_hit: 0, depth_hit: 0 } - ok = path_search(leaf, intermediates, anchors, used, 0, 0, &state) - list_free(used) - if ok == 1 { return "" } - if state.limit_hit == 1 { return "certificate path search limit exceeded" } - if state.depth_hit == 1 { return "certificate chain too long" } +const MAX_CHAIN_DEPTH = 8 + +// ---- path validation (RFC 5280 section 6, as the Web PKI profiles it) ---- +// +// validate_chain(leaf, intermediates, anchors, at, purpose, max_depth) -> "" +// or why no path is acceptable. It searches every path from the leaf +// through the intermediates (each used at most once) to a trust anchor, +// backtracking when a candidate fails, and accepts the first where: +// - every signature verifies and every issuer DN matches its child's +// (RSA PKCS#1 v1.5 with SHA-256, SHA-384 or SHA-512; ECDSA on P-256, +// P-384 or P-521 with SHA-256, SHA-384 or SHA-512; Ed25519); +// - every certificate is within its validity period at `at` (a +// "YYYYMMDDHHMMSS" key, "" for now); +// - every certificate's public key is a KEY_* one: RSA of 2048 bits or +// more, a named P-256, P-384 or P-521 point, or Ed25519; +// - every issuer below the anchor is a v3 CA (basicConstraints cA), may +// sign certificates (keyUsage keyCertSign, when keyUsage is present), +// and its pathLenConstraint covers the CAs below it; the anchor is a +// self-signed root (self_signed_check, OpenSSL's rule: a trusted +// intermediate ends no path, #3310, #3311), trusted as given, but one +// whose basicConstraints says it is not a CA, or whose keyUsage lacks +// keyCertSign, issues nothing; +// - no certificate has an unknown critical, repeated or malformed +// extension, or a critical key identifier or authority information +// access (RFC 5280 says MUST be non-critical; OpenSSL refuses them +// too); no CA has an empty subject. The issuance rules OpenSSL does +// not enforce either (nameConstraints or keyCertSign outside a CA, +// serial length, a non-critical SAN beside an empty subject) are not +// enforced, so both backends accept the same chains; +// - the leaf, and every issuer that lists extendedKeyUsage, allows +// `purpose` (PURPOSE_SERVER: id-kp-serverAuth, PURPOSE_CLIENT: +// id-kp-clientAuth); anyExtendedKeyUsage alone does not (RFC 5280 +// 4.2.1.12 lets an application that needs a purpose refuse it); +// - the leaf's keyUsage, when present, allows digitalSignature: both +// purposes sign a TLS 1.3 CertificateVerify with its key (RFC 8446 +// 4.4.2.2), and OpenSSL refuses such a leaf as an unsuitable purpose +// (#3309); +// - every issuer's name constraints (the anchor's too) permit, and do not +// exclude, the names of every certificate below it that is the leaf or +// not self-issued: dNSName (a wildcard SAN is excluded when any name it +// could match is), iPAddress, rfc822Name, the host of a URI, and +// directoryName (the subject and directoryName SANs, as a byte-exact +// prefix of RDNs). A SAN that is malformed for a constrained form, and +// a SAN of a form this code does not process (otherName, x400Address, +// ediPartyName, registeredID) below a constraint of that form, are +// refused; +// - (a self-signed leaf that is itself an anchor, a pinned certificate, +// is trusted as it stands, as OpenSSL trusts it); +// - only a self-signed root ends a path (#3310), unless `purpose` has +// TRUST_PARTIAL_CHAIN or'd in (#3311, OpenSSL's +// X509_V_FLAG_PARTIAL_CHAIN): then any anchor ends one, an anchor that +// is not a self-signed root held to the rules of an intermediate (a v3 +// CA with keyCertSign), and a leaf that is itself an anchor is trusted +// as it stands whether self-signed or not; +// - there are at most max_depth intermediates that are not self-issued +// (a negative max_depth means MAX_CHAIN_DEPTH). +// The search spends at most CHAIN_SIG_BUDGET signature checks and +// CHAIN_NC_BUDGET name comparisons, so a crafted set of certificates cannot +// make it unbounded. The hostname is checked separately (check_hostname). +// #3303, #1298 (x509-limbo; tests/conformance/x509_limbo/expected.txt says +// which of its cases this does not agree with, and why). + +const PURPOSE_SERVER = 1 +const PURPOSE_CLIENT = 2 +// OR'd into validate_chain's `purpose`: any trusted certificate may end a +// path, not only a self-signed root, as with OpenSSL's +// X509_V_FLAG_PARTIAL_CHAIN (set_partial_chain, #3311). Off by default on +// both backends. +const TRUST_PARTIAL_CHAIN = 256 +// The purpose bits of `purpose`, without the options or'd in. +const PURPOSE_MASK = 3 + +const CHAIN_NO_PATH = 1 +const CHAIN_TOO_LONG = 2 +const CHAIN_NOT_CA = 3 +const CHAIN_NO_CERT_SIGN = 4 +const CHAIN_PATH_LEN = 5 +const CHAIN_VALIDITY = 6 +const CHAIN_CRITICAL = 7 +const CHAIN_BAD_EXT = 8 +const CHAIN_PURPOSE = 9 +const CHAIN_NAME_CONSTRAINT = 10 +const CHAIN_NOT_V3 = 11 +const CHAIN_BUDGET = 12 +const CHAIN_BAD_KEY = 13 +const CHAIN_LEAF_KEY_USAGE = 14 +const CHAIN_ANCHOR_NOT_ROOT = 15 + +// Signature checks one path search may spend: a set of cross-signed +// intermediates can otherwise make the search exponential (x509-limbo's +// pathological cases). The same bound as rustls-webpki. +const CHAIN_SIG_BUDGET = 100 + +// Name-constraint comparisons one path search may spend: 2048 subtrees +// against 2048 names is four million (x509-limbo's nc-dos cases). The same +// bound as Go's crypto/x509 and rustls-webpki. +const CHAIN_NC_BUDGET = 250000 + +// The GeneralName forms a constraint can name that this code does not +// match: otherName [0], x400Address [3], ediPartyName [5], registeredID [8]. +const NC_UNPROCESSED = 297 + +struct ChainCtx { + intermediates: ptr // std.list of *LeafCert, or null + anchors: ptr // std.list of *LeafCert + path: ptr // std.list of *LeafCert: leaf, intermediates, anchor + purpose: int // PURPOSE_SERVER or PURPOSE_CLIENT + partial: int // 1: any anchor ends a path (TRUST_PARTIAL_CHAIN) + max_depth: int + err: int // the most specific CHAIN_* failure seen + budget: int // signature verifications left (path building is bounded) + nc_budget: int // name-constraint comparisons left +} + +fn chain_error(code: int) -> string { + if code == CHAIN_TOO_LONG { return "certificate chain too long" } + if code == CHAIN_NOT_CA { return "an issuing certificate is not a CA (basicConstraints)" } + if code == CHAIN_NO_CERT_SIGN { return "an issuing certificate may not sign certificates (keyUsage)" } + if code == CHAIN_PATH_LEN { return "a CA's path length constraint is exceeded" } + if code == CHAIN_VALIDITY { return "a certificate in the chain has expired or is not yet valid" } + if code == CHAIN_CRITICAL { return "a certificate in the chain has an unknown critical extension" } + if code == CHAIN_BAD_EXT { return "a certificate in the chain has a malformed, repeated or misused extension or field" } + if code == CHAIN_PURPOSE { return "a certificate in the chain is not valid for this purpose (extendedKeyUsage)" } + if code == CHAIN_NAME_CONSTRAINT { return "a name in the chain violates a CA's name constraints" } + if code == CHAIN_NOT_V3 { return "an issuing certificate is not version 3" } + if code == CHAIN_BUDGET { return "too many candidate paths or name comparisons: path building gave up" } + if code == CHAIN_BAD_KEY { return "a certificate in the chain has an unsupported or weak public key" } + if code == CHAIN_LEAF_KEY_USAGE { return "the certificate's keyUsage does not allow signing (digitalSignature), which TLS needs" } + if code == CHAIN_ANCHOR_NOT_ROOT { return "a trusted certificate that is not a self-signed root cannot end a path (as on OpenSSL; #3311)" } return "certificate does not chain to a trusted anchor" } -const MAX_CHAIN_DEPTH = 8 -const MAX_PATH_ATTEMPTS = 64 +fn chain_note(cx: *ChainCtx, code: int) { + if cx.err == CHAIN_NO_PATH || cx.err == CHAIN_TOO_LONG { cx.err = code } +} + +// 1 if `at` is within c's validity period (both times well formed). +fn cert_time_ok(c: *LeafCert, at: string) -> int { + if c.not_before == null || c.not_after == null { return 0 } + nb = time_to_key(c.not_before, c.not_before_len) + na = time_to_key(c.not_after, c.not_after_len) + if string.equals(nb, "") == 1 || string.equals(na, "") == 1 { return 0 } + if string_compare(at, nb) < 0 { return 0 } + if string_compare(at, na) > 0 { return 0 } + return 1 +} + +fn ext_code(c: *LeafCert) -> int { + if (c.ext_bits & EXT_UNKNOWN_CRITICAL) != 0 { return CHAIN_CRITICAL } + if (c.ext_bits & (EXT_DUPLICATE | EXT_MALFORMED | EXT_SAN_EMPTY)) != 0 { return CHAIN_BAD_EXT } + if c.key_kind == KEY_NONE { return CHAIN_BAD_KEY } + return 0 +} -struct PathSearchState { - attempts: int - limit_hit: int - depth_hit: int +fn purpose_ok(c: *LeafCert, purpose: int) -> int { + if (c.ext_bits & EXT_EKU) == 0 { return 1 } + if purpose == PURPOSE_SERVER && (c.eku_bits & EKU_SERVER) != 0 { return 1 } + if purpose == PURPOSE_CLIENT && (c.eku_bits & EKU_CLIENT) != 0 { return 1 } + return 0 +} + +// Why `ca` cannot issue (0 if it can), its own properties only. +fn issuer_code(ca: *LeafCert, at: string, purpose: int, is_anchor: int) -> int { + e = ext_code(ca) + if e != 0 { return e } + if is_anchor == 0 && ca.version != 2 { return CHAIN_NOT_V3 } + // A CA names itself: RFC 5280 4.1.2.6, its subject MUST NOT be empty. + if ca.subject == null || ca.subject_len <= 2 { return CHAIN_BAD_EXT } + if is_anchor == 0 && ca.is_ca == 0 { return CHAIN_NOT_CA } + if is_anchor == 1 && (ca.ext_bits & EXT_BC) != 0 && ca.is_ca == 0 { return CHAIN_NOT_CA } + if ca.has_key_usage == 1 && ca.key_cert_sign == 0 { return CHAIN_NO_CERT_SIGN } + if purpose_ok(ca, purpose) == 0 { return CHAIN_PURPOSE } + if cert_time_ok(ca, at) == 0 { return CHAIN_VALIDITY } + return 0 +} + +fn is_self_issued(c: *LeafCert) -> int { + return dn_equal(bytes.view(c.issuer)[0..c.issuer_len], bytes.view(c.subject)[0..c.subject_len]) } -fn path_search(current: *LeafCert, intermediates: ptr, anchors: ptr, - used: ptr, ca_below: int, depth: int, - state: *PathSearchState) -> int { - if chains_to_anchor(current, anchors) == 1 { return 1 } - if depth >= MAX_CHAIN_DEPTH { - state.depth_hit = 1 +fn issued_by(cx: *ChainCtx, child: *LeafCert, parent: *LeafCert) -> int { + if dn_equal(bytes.view(child.issuer)[0..child.issuer_len], bytes.view(parent.subject)[0..parent.subject_len]) != 1 { return 0 } + if cx.budget <= 0 { + cx.err = CHAIN_BUDGET return 0 } - if intermediates == null { return 0 } + cx.budget = cx.budget - 1 + return verify_cert_signature(child, parent) +} + +// ASCII-lower-cased byte. +fn lc(c: int) -> int { + if c >= 65 && c <= 90 { return c + 32 } + return c +} + +// Is the domain name[noff, noff + nl) inside the constraint +// cons[coff, coff + cl) (RFC 5280 4.2.1.10)? A constraint with a leading +// period holds only the names under it; one without holds itself and, when +// `under` is 1 (dNSName), every name under it too; an empty one holds +// everything. Case-insensitive. +fn domain_within(name: ptr, noff: int, nl: int, cons: ptr, coff: int, cl: int, under: int) -> int { + if cl <= 0 { return 1 } + if nl < cl { return 0 } + if (bytes.get(cons, coff) & 0xFF) == 46 { + if nl == cl { return 0 } + } else if nl > cl { + if under == 0 { return 0 } + if (bytes.get(name, noff + nl - cl - 1) & 0xFF) != 46 { return 0 } + } + k = 0 + while k < cl { + if lc(bytes.get(name, noff + nl - cl + k) & 0xFF) != lc(bytes.get(cons, coff + k) & 0xFF) { return 0 } + k = k + 1 + } + return 1 +} + +// Could the wildcard dNSName "*.X" at name[off] match a name inside the +// excluded subtree cons[coff..]? Beyond X being inside it (domain_within), +// that is when the subtree is "L.X" for a single label L: "*.example.com" +// matches bar.example.com, so an excluded bar.example.com excludes it +// (CVE-2025-61727). +fn wildcard_may_match(name: ptr, off: int, cons: ptr, coff: int) -> int { + nl = bytes.length(name) - off + if nl < 3 || (bytes.get(name, off) & 0xFF) != 42 || (bytes.get(name, off + 1) & 0xFF) != 46 { return 0 } + xl = nl - 1 // ".X" + cl = bytes.length(cons) - coff + if cl <= xl { return 0 } + k = 0 + while k < xl { + if lc(bytes.get(cons, coff + cl - xl + k) & 0xFF) != lc(bytes.get(name, off + 1 + k) & 0xFF) { return 0 } + k = k + 1 + } + if byte_index(cons, coff, cl - xl, 46) >= 0 { return 0 } + return 1 +} + +// iPAddress constraint: address || mask (8 or 32 bytes). +fn ip_constraint_match(ip: ptr, cons: ptr, coff: int) -> int { + il = bytes.length(ip) + cl = bytes.length(cons) - coff + if cl != 2 * il { return 0 } + k = 0 + while k < il { + m = bytes.get(cons, coff + il + k) & 0xFF + if ((bytes.get(ip, k) & 0xFF) & m) != ((bytes.get(cons, coff + k) & 0xFF) & m) { return 0 } + k = k + 1 + } + return 1 +} - next_ca_below = ca_below - if depth > 0 && current.is_ca == 1 { - if self_issued(current) == 0 { - next_ca_below = next_ca_below + 1 +// rfc822Name constraint against the mailbox at name[off]: a constraint +// with an "@" is that mailbox exactly (the local part byte for byte, every +// character literal, the host case-insensitively); one without names the +// host (or, with a leading period, the hosts under it) of every mailbox. +fn email_constraint_match(name: ptr, off: int, cons: ptr, coff: int) -> int { + nl = bytes.length(name) - off + cl = bytes.length(cons) - coff + at = byte_index(name, off, nl, 64) + if byte_index(cons, coff, cl, 64) >= 0 { + if nl != cl { return 0 } + k = 0 + while k < nl { + a = bytes.get(name, off + k) & 0xFF + b = bytes.get(cons, coff + k) & 0xFF + if off + k > at { + a = lc(a) + b = lc(b) + } + if a != b { return 0 } + k = k + 1 } + return 1 } + return domain_within(name, at + 1, off + nl - at - 1, cons, coff, cl, 0) +} + +// The host of the URI at name[off, off + n) as fresh bytes, or null when +// it has none that is a DNS name: no "scheme://" authority, or an IP +// literal (RFC 5280 4.2.1.10: the certificate MUST then be refused). +fn uri_host(name: ptr, off: int, n: int) -> ptr { + end = off + n + colon = byte_index(name, off, n, 58) + if colon <= off || colon + 2 >= end { return null } + if (bytes.get(name, colon + 1) & 0xFF) != 47 || (bytes.get(name, colon + 2) & 0xFF) != 47 { return null } + a = colon + 3 + ae = a + stop = 0 + while ae < end && stop == 0 { + c = bytes.get(name, ae) & 0xFF + if c == 47 || c == 63 || c == 35 { + stop = 1 + } else { + if c == 64 { a = ae + 1 } // userinfo ends at the last "@" + ae = ae + 1 + } + } + if a >= ae || (bytes.get(name, a) & 0xFF) == 91 { return null } + pc = byte_index(name, a, ae - a, 58) + if pc >= 0 { ae = pc } + hl = ae - a + if dns_name_ok(name, a, hl, 0) == 0 { return null } + numeric = 1 + k = 0 + while k < hl { + c = bytes.get(name, a + k) & 0xFF + if c != 46 && (c < 48 || c > 57) { numeric = 0 } + k = k + 1 + } + if numeric == 1 { return null } + h = bytes.new(hl) + bytes.copy_from_bytes(h, 0, name, a, hl) + return h +} + +// Does the Name (a DER SEQUENCE OF RDN) at name[noff] begin with the RDNs +// of the Name at cons[coff], each byte for byte? RFC 5280's comparison +// rules are looser (case and space folding), so an excluded subtree written +// in another string encoding is not matched: the CAs that use directoryName +// constraints use them to permit, where a mismatch refuses. +fn dirname_within(name: ptr, noff: int, cons: ptr, coff: int) -> int { + nt, nco, ncl, _nnx = der_tlv(name, noff, bytes.length(name)) + ct, cco, ccl, _cnx = der_tlv(cons, coff, bytes.length(cons)) + if nt != 0x30 || ct != 0x30 { return 0 } + np = nco + nend = nco + ncl + cp = cco + cend = cco + ccl + while cp < cend { + if np >= nend { return 0 } + t1, _o1, _l1, x1 = der_tlv(cons, cp, cend) + t2, _o2, _l2, x2 = der_tlv(name, np, nend) + if t1 < 0 || t2 < 0 || x1 - cp != x2 - np { return 0 } + k = 0 + while k < x1 - cp { + if bytes.get(cons, cp + k) != bytes.get(name, np + k) { return 0 } + k = k + 1 + } + cp = x1 + np = x2 + } + return 1 +} - n = list_size(intermediates) +// One name against one subtree base `cons` (tag byte, then content) of +// the same form; `excl` when the subtree is excluded. For a URI the name is +// its host. +fn constraint_match(tag: int, name: ptr, off: int, cons: ptr, excl: int) -> int { + if tag == 0x82 { + nl = bytes.length(name) - off + if domain_within(name, off, nl, cons, 1, bytes.length(cons) - 1, 1) == 1 { return 1 } + if excl == 1 { return wildcard_may_match(name, off, cons, 1) } + return 0 + } + if tag == 0x87 { return ip_constraint_match(name, cons, 1) } + if tag == 0x81 { return email_constraint_match(name, off, cons, 1) } + if tag == 0x86 { return domain_within(name, 0, bytes.length(name), cons, 1, bytes.length(cons) - 1, 0) } + if tag == 0xA4 { return dirname_within(name, off, cons, 1) } + return 0 +} + +// The subtrees of form `tag` in `trees` against one name: 0 when there are +// none of that form, 1 when there are and none matches, 2 when one does, 3 +// when the comparison budget ran out. +fn subtrees_match(cx: *ChainCtx, trees: ptr, tag: int, name: ptr, off: int, excl: int) -> int { + if trees == null { return 0 } + res = 0 + n = list_size(trees) i = 0 while i < n { - if is_in_list(intermediates, i, used) == 0 { - issuer = list.at(intermediates, i) as *LeafCert - if issuer_name_matches(current, issuer) == 1 { - if state.attempts >= MAX_PATH_ATTEMPTS { - state.limit_hit = 1 - return 0 - } - state.attempts = state.attempts + 1 - if issuer_candidate_valid(current, issuer, next_ca_below) == 1 { - _ = list.add(used, issuer) - if path_search(issuer, intermediates, anchors, used, - next_ca_below, depth + 1, state) == 1 { - return 1 - } - list_remove(used, list_size(used) - 1) + t = list.at(trees, i) as ptr + if t != null && bytes.length(t) >= 1 && (bytes.get(t, 0) & 0xFF) == tag { + if res == 0 { res = 1 } + if cx.nc_budget <= 0 { + cx.err = CHAIN_BUDGET + return 3 + } + cx.nc_budget = cx.nc_budget - 1 + if constraint_match(tag, name, off, t, excl) == 1 { return 2 } + } + i = i + 1 + } + return res +} + +// 1 if one name of form `tag` at name[off] satisfies `ca`'s constraints of +// that form (or there are none). A name the form's matcher cannot read is +// outside every subtree, so it is refused when the form is constrained. +fn name_allowed(cx: *ChainCtx, ca: *LeafCert, tag: int, name: ptr, off: int) -> int { + if (ca.nc_forms & (1 << (tag & 0x1F))) == 0 { return 1 } + n = bytes.length(name) - off + if tag == 0x82 && dns_name_ok(name, off, n, 1) == 0 { return 0 } + if tag == 0x81 && mailbox_ok(name, off, n) == 0 { return 0 } + subject = name + soff = off + if tag == 0x86 { + subject = uri_host(name, off, n) + if subject == null { return 0 } + soff = 0 + } + ok = 1 + r = subtrees_match(cx, ca.nc_excluded, tag, subject, soff, 1) + if r == 2 || r == 3 { ok = 0 } + if ok == 1 { + r = subtrees_match(cx, ca.nc_permitted, tag, subject, soff, 0) + if r == 1 || r == 3 { ok = 0 } + } + if tag == 0x86 { bytes.wipe_free(subject) } + return ok +} + +// Every name in the std.list `names` (form `tag`, or each entry's own tag +// byte when `tagged`). +fn names_allowed_list(cx: *ChainCtx, ca: *LeafCert, names: ptr, tag: int, tagged: int) -> int { + if names == null { return 1 } + n = list_size(names) + i = 0 + while i < n { + nm = list.at(names, i) as ptr + if nm != null { + if tagged == 1 { + if name_allowed(cx, ca, bytes.get(nm, 0) & 0xFF, nm, 1) == 0 { return 0 } + } else if name_allowed(cx, ca, tag, nm, 0) == 0 { + return 0 + } + } + i = i + 1 + } + return 1 +} + +// 1 if the names of `c` satisfy `ca`'s name constraints: its SANs of every +// form, and its subject against directoryName subtrees. +fn names_allowed(cx: *ChainCtx, ca: *LeafCert, c: *LeafCert) -> int { + if (ca.ext_bits & EXT_NC) == 0 { return 1 } + if (c.san_forms & ca.nc_forms & NC_UNPROCESSED) != 0 { return 0 } + if names_allowed_list(cx, ca, c.dns_names, 0x82, 0) == 0 { return 0 } + if names_allowed_list(cx, ca, c.ip_addrs, 0x87, 0) == 0 { return 0 } + if names_allowed_list(cx, ca, c.san_other, 0, 1) == 0 { return 0 } + // An empty subject (SEQUENCE {}, two bytes) names nothing. + if c.subject != null && c.subject_len > 2 { + if name_allowed(cx, ca, 0xA4, c.subject, 0) == 0 { return 0 } + } + return 1 +} + +// The checks over the whole candidate path (leaf at 0, anchor last): path +// length constraints and name constraints. 0 if they hold. +fn path_code(cx: *ChainCtx) -> int { + n = list_size(cx.path) + j = 1 + while j < n { + ca = list.at(cx.path, j) as *LeafCert + if (ca.ext_bits & EXT_PATHLEN) != 0 { + below = 0 + k = 1 + while k < j { + if is_self_issued(list.at(cx.path, k) as *LeafCert) == 0 { below = below + 1 } + k = k + 1 + } + if below > ca.path_len { return CHAIN_PATH_LEN } + } + if (ca.ext_bits & EXT_NC) != 0 { + k = 0 + while k < j { + c = list.at(cx.path, k) as *LeafCert + if k == 0 || is_self_issued(c) == 0 { + if names_allowed(cx, ca, c) == 0 { return CHAIN_NAME_CONSTRAINT } } + k = k + 1 } } + j = j + 1 + } + return 0 +} + +fn path_pop(cx: *ChainCtx) { + list_remove(cx.path, list_size(cx.path) - 1) +} + +fn in_path(cx: *ChainCtx, c: ptr) -> int { + n = list_size(cx.path) + i = 0 + while i < n { + if list.at(cx.path, i) == c { return 1 } i = i + 1 } return 0 } -fn issuer_candidate_valid(cert: *LeafCert, issuer: *LeafCert, - ca_below: int) -> int { - if issuer == null { return 0 } - if issuer.is_ca != 1 { return 0 } - if issuer.has_key_usage == 1 && issuer.key_cert_sign != 1 { return 0 } - if issuer.has_path_len == 1 { - if ca_below > issuer.path_len { return 0 } +// Extend the path above `cert`, which has `depth` intermediates that are +// not self-issued below it; 1 when an acceptable path is found. +fn chain_dfs(cx: *ChainCtx, cert: *LeafCert, at: string, depth: int) -> int { + na = list_size(cx.anchors) + i = 0 + while i < na { + a = list.at(cx.anchors, i) as *LeafCert + // Only a self-signed root ends a path (#3310), unless the caller + // trusts partial chains (#3311). An anchor that is not a root is + // then held to an intermediate's rules: trusting it does not make a + // certificate that is not a CA one. + root = 0 + if a != null && (a.ext_bits & EXT_SELF_SIGNED) != 0 { root = 1 } + if a != null && root == 0 && cx.partial == 0 { + if dn_equal(bytes.view(cert.issuer)[0..cert.issuer_len], bytes.view(a.subject)[0..a.subject_len]) == 1 { + chain_note(cx, CHAIN_ANCHOR_NOT_ROOT) + } + } else if a != null && issued_by(cx, cert, a) == 1 { + code = issuer_code(a, at, cx.purpose, root) + if code == 0 { + list.add(cx.path, a) + code = path_code(cx) + path_pop(cx) + if code == 0 { return 1 } + } + chain_note(cx, code) + } + i = i + 1 + } + if cx.intermediates == null { return 0 } + ni = list_size(cx.intermediates) + j = 0 + while j < ni { + m = list.at(cx.intermediates, j) as *LeafCert + if m != null && in_path(cx, m as ptr) == 0 { + d = depth + if is_self_issued(m) == 0 { d = depth + 1 } + if d > cx.max_depth { + if dn_equal(bytes.view(cert.issuer)[0..cert.issuer_len], bytes.view(m.subject)[0..m.subject_len]) == 1 { + chain_note(cx, CHAIN_TOO_LONG) + } + } else if issued_by(cx, cert, m) == 1 { + code = issuer_code(m, at, cx.purpose, 0) + if code == 0 { + list.add(cx.path, m) + found = chain_dfs(cx, m, at, d) + path_pop(cx) + if found == 1 { return 1 } + } else { + chain_note(cx, code) + } + } + } + j = j + 1 } - validity = check_validity(issuer) - if string.equals(validity, "") != 1 { return 0 } - return verify_cert_signature(cert, issuer) + return 0 } -fn issuer_name_matches(cert: *LeafCert, issuer: *LeafCert) -> int { - if issuer == null { return 0 } - return dn_equal(bytes.view(cert.issuer)[0..cert.issuer_len], - bytes.view(issuer.subject)[0..issuer.subject_len]) +// 1 when `c` is one of `anchors`, byte for byte (its signed body and its +// signature). +fn pinned_anchor(anchors: ptr, c: *LeafCert) -> int { + n = list_size(anchors) + i = 0 + while i < n { + a = list.at(anchors, i) as *LeafCert + if a != null && a.tbs != null && c.tbs != null && a.signature != null && c.signature != null { + if bytes_same(a.tbs, c.tbs) == 1 && bytes_same(a.signature, c.signature) == 1 { return 1 } + } + i = i + 1 + } + return 0 } -fn self_issued(cert: *LeafCert) -> int { - if cert.issuer == null || cert.subject == null { return 0 } - return dn_equal(bytes.view(cert.issuer)[0..cert.issuer_len], - bytes.view(cert.subject)[0..cert.subject_len]) +// "" when there is a valid path from `leaf` through `intermediates` (a +// std.list of *LeafCert, or null) to one of `anchors` at `at` (a +// "YYYYMMDDHHMMSS" key, "" for now) for `purpose` (PURPOSE_SERVER or +// PURPOSE_CLIENT, with TRUST_PARTIAL_CHAIN or'd in to let any anchor end a +// path), with at most `max_depth` intermediates that are not self-issued +// (negative: MAX_CHAIN_DEPTH); else why not. The rules are in the comment +// above PURPOSE_SERVER. +validate_chain(leaf: *LeafCert, intermediates: ptr, anchors: ptr, at: string, purpose: int, max_depth: int) -> string { + if anchors == null || list_size(anchors) == 0 { return "no trust anchors" } + want = purpose & PURPOSE_MASK + partial = 0 + if (purpose & TRUST_PARTIAL_CHAIN) != 0 { partial = 1 } + at_key = at + if string.equals(at_key, "") == 1 { at_key = now_key() } + e = ext_code(leaf) + if e != 0 { return chain_error(e) } + if purpose_ok(leaf, want) == 0 { return chain_error(CHAIN_PURPOSE) } + if leaf.has_key_usage == 1 && (leaf.key_usage & KU_DIGITAL_SIGNATURE) == 0 { return chain_error(CHAIN_LEAF_KEY_USAGE) } + if cert_time_ok(leaf, at_key) == 0 { return chain_error(CHAIN_VALIDITY) } + // A self-signed leaf that is itself one of the anchors (a pinned + // certificate) is trusted as it stands, as OpenSSL trusts it: it has no + // issuer to check, so basicConstraints cA FALSE does not refuse it. With + // partial chains any leaf that is an anchor is (#3311). + if ((leaf.ext_bits & EXT_SELF_SIGNED) != 0 || partial == 1) && pinned_anchor(anchors, leaf) == 1 { return "" } + md = max_depth + if md < 0 { md = MAX_CHAIN_DEPTH } + cx = ChainCtx { intermediates: intermediates, anchors: anchors, path: list_new(), purpose: want, partial: partial, max_depth: md, err: CHAIN_NO_PATH, budget: CHAIN_SIG_BUDGET, nc_budget: CHAIN_NC_BUDGET } + list.add(cx.path, leaf) + found = chain_dfs(&cx, leaf, at_key, 0) + list_free(cx.path) + if found == 1 { return "" } + return chain_error(cx.err) +} + +// Validity at a given "YYYYMMDDHHMMSS" key: "" or why not. +check_validity_at(c: *LeafCert, at: string) -> string { + if cert_time_ok(c, at) == 1 { return "" } + return "certificate expired, not yet valid, or with an invalid validity period" } // Find the certificate that issued `leaf` — search `intermediates` then @@ -2035,18 +3461,6 @@ find_issuer_cert(leaf: *LeafCert, intermediates: ptr, anchors: ptr) -> ptr { return null } -// Is the intermediate at index `idx` (by stored pointer) already in `used`? -fn is_in_list(intermediates: ptr, idx: int, used: ptr) -> int { - target = list.at(intermediates, idx) - un = list_size(used) - j = 0 - while j < un { - if list.at(used, j) == target { return 1 } - j = j + 1 - } - return 0 -} - // ---- system trust store (PEM bundle) ---- import std.fs @@ -2161,7 +3575,7 @@ fn add_pem_text(anchors: ptr, text: string) -> int { free(leaf) } } - bytes.free(der) + bytes.wipe_free(der) } pos = block_end } @@ -2190,8 +3604,10 @@ init_leaf(l: *LeafCert) { l.is_ca = 0 l.has_key_usage = 0 l.key_cert_sign = 0 - l.has_path_len = 0 - l.path_len = 0 + l.version = 0; l.ext_bits = 0; l.path_len = 0; l.key_usage = 0; l.eku_bits = 0 + l.nc_permitted = null; l.nc_excluded = null; l.san_other = null + l.san_forms = 0; l.nc_forms = 0; l.key_kind = KEY_NONE; l.key_bits = 0 + l.serial = null; l.ski = null; l.aki_keyid = null; l.aki_issuer = null; l.aki_serial = null } // Free a trust-anchor list built by trust_store_load: each *LeafCert's buffers, diff --git a/std/cryptography/tls13_client/module.ae b/std/cryptography/tls13_client/module.ae index d7777fec7..39a1eb34c 100644 --- a/std/cryptography/tls13_client/module.ae +++ b/std/cryptography/tls13_client/module.ae @@ -27,9 +27,13 @@ // 2. the leaf is within its validity window (not expired / not-yet-valid); // 3. the requested hostname matches a leaf SAN dNSName (exact or a single // leftmost-label wildcard; SAN-only, no CN fallback); -// 4. the leaf chains to a trusted anchor — walking any server-sent -// intermediate CAs (leaf→intermediate→…→root), each hop verified by -// issuer/subject DN match + signature, terminating at a trust-store root. +// 4. there is a valid path from the leaf through the server-sent +// certificates to a trusted anchor (tls13_cert.validate_chain): every +// hop's DN match and signature, every certificate's validity, every +// issuer a v3 CA allowed to sign certificates within its path length, +// extendedKeyUsage serverAuth wherever it is listed, name constraints, +// no unknown critical extension; every candidate path is tried. Before +// #3303 only the DNs and signatures were checked. // A self-signed, expired, wrong-host, or untrusted cert is rejected. The trust // store is the system CA bundle (SSL_CERT_FILE, else the OS default path); if // it cannot be loaded, connect() fails closed rather than proceeding unauthed. @@ -102,6 +106,7 @@ import std.cryptography.hmac import std.cryptography.x25519 import std.cryptography.mlkem import std.cryptography.p256 +import std.cryptography.p384 import std.cryptography.tls13_kdf import std.cryptography.tls13_ks import std.cryptography.tls13_hs @@ -134,12 +139,14 @@ exports( finished_key, finished_verify_data, hs_msg_type, hs_msg_len, hs_msg_total, HS_ENCRYPTED_EXTENSIONS, HS_CERTIFICATE, HS_CERTIFICATE_VERIFY, HS_FINISHED, - connect, connect_mtls, connect_pq, conn_group, connect_resume, connect_resume_0rtt, close_conn, conn_send, conn_recv, + connect, connect_mtls, connect_pq, connect_socket, conn_group, connect_resume, connect_resume_0rtt, close_conn, conn_send, conn_recv, + conn_key_update, conn_early_accepted, conn_get_ticket, free_ticket, SessionTicket, - ticket_len, ticket_nonce_len, ticket_lifetime, ticket_max_early_data, + ticket_len, ticket_nonce_len, ticket_lifetime, ticket_max_early_data, ticket_cipher_suite, verify_flight_auth, // Shared with std.cryptography.tls13_server - transcript_hash_algo, finished_verify_data_algo, ALERT_CLOSE_NOTIFY, alert_name + transcript_hash_algo, finished_verify_data_algo, ALERT_CLOSE_NOTIFY, alert_name, + alert_event, transcript_retry, EV_NONE, EV_CLOSED, EV_ERROR ) // Handshake message types (RFC 8446 §4) seen in the server's flight. @@ -225,7 +232,7 @@ transcript_hash_algo(t: ptr, algo: string) -> ptr { transcript_free(t: @frees ptr) { tt = t as *Transcript - bytes.free(tt.buf) + bytes.wipe_free(tt.buf) libc_free(t) } @@ -279,7 +286,7 @@ derive_handshake_keys_algo(ecdhe: byte[], th: byte[], key_len: int, hash_algo: s k.server_iv = siv k.handshake_secret = hs - bytes.free(early) + bytes.wipe_free(early) return k as ptr } @@ -310,7 +317,7 @@ fn derive_handshake_keys_psk(psk: byte[], ecdhe: byte[], th: byte[], key_len: in k.server_iv = siv k.handshake_secret = hs - bytes.free(early) + bytes.wipe_free(early) return k as ptr } @@ -333,7 +340,7 @@ finished_key_algo(secret: ptr, hash_algo: string, hlen: int) -> ptr { empty = bytes.new(1) bytes.set(empty, 0, 0) fk = tls13_kdf.expand_label(hash_algo, bytes.view(secret)[0..hlen], "finished", bytes.view(empty)[0..0], hlen) - bytes.free(empty) + bytes.wipe_free(empty) return fk } @@ -345,7 +352,7 @@ finished_verify_data(secret: ptr, th: byte[]) -> ptr { finished_verify_data_algo(secret: ptr, th: byte[], hash_algo: string, hlen: int) -> ptr { fk = finished_key_algo(secret, hash_algo, hlen) vd = hmac.bytes(hash_algo, bytes.view(fk)[0..hlen], th) - bytes.free(fk) + bytes.wipe_free(fk) return vd } @@ -381,13 +388,13 @@ hs_server_iv(k: ptr) -> ptr { kk = k as *HsKeys; return kk.server_iv } free_hs_keys(k: ptr) { kk = k as *HsKeys - bytes.free(kk.client_hs_secret) - bytes.free(kk.server_hs_secret) - bytes.free(kk.client_key) - bytes.free(kk.client_iv) - bytes.free(kk.server_key) - bytes.free(kk.server_iv) - bytes.free(kk.handshake_secret) + bytes.wipe_free(kk.client_hs_secret) + bytes.wipe_free(kk.server_hs_secret) + bytes.wipe_free(kk.client_key) + bytes.wipe_free(kk.client_iv) + bytes.wipe_free(kk.server_key) + bytes.wipe_free(kk.server_iv) + bytes.wipe_free(kk.handshake_secret) libc_free(k) } @@ -406,6 +413,8 @@ struct RecordIO { buf: ptr // std.bytes accumulator len: int // valid bytes in buf off: int // consumed-so-far offset (records already returned) + alert: int // the alert recio_next says to send, when it refuses a record + ccs: int // the dummy change_cipher_spec records read in the handshake (#3379) } fn recio_new(sock: @keep ptr) -> *RecordIO { @@ -415,17 +424,44 @@ fn recio_new(sock: @keep ptr) -> *RecordIO { bytes.set(r.buf, 0, 0) r.len = 0 r.off = 0 + r.alert = 0 + r.ccs = 0 return r } fn recio_free(r: *RecordIO) { - bytes.free(r.buf) + bytes.wipe_free(r.buf) libc_free(r as ptr) } +// Drop what has been consumed before reading more (#3379): back to the +// start when everything was consumed, else, once the consumed part is +// past RECIO_COMPACT_AT bytes and at least half of what is held, the rest +// moved to a fresh buffer. A connection then holds about one record and +// one read however long it lives, not everything it ever received. +const RECIO_COMPACT_AT = 16384 + +fn recio_compact(r: *RecordIO) { + if r.off == 0 { return } + if r.off >= r.len { + r.off = 0 + r.len = 0 + return + } + if r.off < RECIO_COMPACT_AT || r.off * 2 < r.len { return } + rest = r.len - r.off + nb = bytes.new(rest + 4096) + bytes.copy_from_bytes(nb, 0, r.buf, r.off, rest) + bytes.wipe_free(r.buf) + r.buf = nb + r.off = 0 + r.len = rest +} + // Pull more bytes from the socket into the buffer. Returns bytes read, or <= 0 // on close/error. fn recio_fill(r: *RecordIO) -> int { + recio_compact(r) // Retry on a would-block "timeout" (peer idle, not closed); only an orderly // FIN or hard error is a real close. Without this, a server that pauses even // briefly after the handshake (network latency to a real host) is mistaken @@ -463,41 +499,81 @@ fn recio_fill(r: *RecordIO) -> int { // carries the OS socket timeout, so this caps the total post-handshake wait. const RECIO_MAX_TIMEOUT_RETRIES = 50 -// Read exactly one complete TLS record. Returns (record_bytes, total_len, "") -// where record_bytes is a fresh buffer of the whole record (header + payload), -// Like recio_next but skips any ChangeCipherSpec records (content type 0x14), -// which a TLS 1.3 server may interleave for middlebox compatibility (notably -// right after a HelloRetryRequest). Returns the next non-CCS record. +// The ServerHello, whatever records it came in, as one record: a 5-byte +// header, then the message (RFC 8446 5.1: a handshake message may be split +// across records, #3321). The one dummy change_cipher_spec (the byte 1) +// is dropped, a second or another unexpected_message (#3379); +// an alert record is the server refusing the ClientHello; anything else +// is unexpected_message, as are bytes after the message in its last +// record (a key change follows the ServerHello) and a message over 2^16 +// bytes. r.alert says what to send on an error. fn recio_next_hs(r: *RecordIO) -> (ptr, int, string) { - looking = 1 - rec = null - rlen = 0 - err = "" - while looking == 1 { + acc = bytes.new(0) + alen = 0 + while 1 == 1 { rec, rlen, err = recio_next(r) - if string.equals(err, "") != 1 { looking = 0 } else { - ct = bytes.get(rec, 0) & 0xFF - if ct == 0x14 { - bytes.free(rec) // skip CCS - } else { - if ct == 0x15 { - // A plaintext Alert where the ServerHello belongs: the - // server refused the ClientHello outright. Parsing it as a - // ServerHello produced "server hello: truncated header", - // which named the wrong layer. The usual cause is a server - // that does not speak TLS 1.3, which answers this client's - // TLS-1.3-only offer with protocol_version (OpenSSL) or - // handshake_failure (no cipher suite in common). - err = server_hello_alert_error(rec, rlen) - bytes.free(rec) - rec = null - rlen = 0 + if string.equals(err, "") != 1 { + bytes.wipe_free(acc) + return null, 0, err + } + ct = bytes.get(rec, 0) & 0xFF + if ct == 0x14 { + // The one dummy change_cipher_spec of compatibility mode, the + // single byte 1 (RFC 8446 5, D.4); any other, or a second, is + // unexpected_message (#3379). + ok = 0 + if rlen == 6 && (bytes.get(rec, 5) & 0xFF) == 1 && r.ccs == 0 && alen == 0 { ok = 1 } + bytes.wipe_free(rec) + if ok == 0 { + bytes.wipe_free(acc) + r.alert = ALERT_UNEXPECTED_MESSAGE + return null, 0, "record: a change_cipher_spec other than the one compatibility mode allows" + } + r.ccs = 1 + } else if ct == 0x15 { + // A plaintext Alert where the ServerHello belongs: the server + // refused the ClientHello outright, usually because it does not + // speak TLS 1.3 (protocol_version from OpenSSL) or shares no + // cipher suite (handshake_failure). + aerr = server_hello_alert_error(rec, rlen) + bytes.wipe_free(rec) + bytes.wipe_free(acc) + return null, 0, aerr + } else if ct != 0x16 { + bytes.wipe_free(rec) + bytes.wipe_free(acc) + r.alert = ALERT_UNEXPECTED_MESSAGE + return null, 0, "record: a record of type ${ct} where the ServerHello belongs" + } else { + bytes.copy_from_bytes(acc, alen, rec, 5, rlen - 5) + alen = alen + rlen - 5 + bytes.wipe_free(rec) + if alen >= 4 { + ml = ((bytes.get(acc, 1) & 0xFF) << 16) | ((bytes.get(acc, 2) & 0xFF) << 8) | (bytes.get(acc, 3) & 0xFF) + if ml > 65536 { + bytes.wipe_free(acc) + r.alert = ALERT_UNEXPECTED_MESSAGE + return null, 0, "handshake: a ServerHello over 2^16 bytes" + } + if alen >= 4 + ml { + if alen != 4 + ml { + bytes.wipe_free(acc) + r.alert = ALERT_UNEXPECTED_MESSAGE + return null, 0, "handshake: data after the ServerHello in its record" + } + out = bytes.new(5 + alen) + bytes.set(out, 0, 0x16) + bytes.set(out, 1, 0x03) + bytes.set(out, 2, 0x03) + bytes.set(out, 3, (alen >> 8) & 0xFF) + bytes.set(out, 4, alen & 0xFF) + bytes.copy_from_bytes(out, 5, acc, 0, alen) + bytes.wipe_free(acc) + return out, 5 + alen, "" } - looking = 0 } } } - return rec, rlen, err } // The error for a plaintext Alert record received instead of a ServerHello. @@ -514,6 +590,8 @@ fn server_hello_alert_error(rec: ptr, rlen: int) -> string { return "server refused the handshake with alert ${name} (${desc})" } +// Read exactly one complete TLS record. Returns (record_bytes, total_len, "") +// where record_bytes is a fresh buffer of the whole record (header + payload), // or (null, 0, err). Blocks (via recio_fill) until a full record is available. fn recio_next(r: *RecordIO) -> (ptr, int, string) { loop = 1 @@ -521,6 +599,13 @@ fn recio_next(r: *RecordIO) -> (ptr, int, string) { avail = r.len - r.off if avail >= 5 { // We have the 5-byte header; read the declared payload length. + // The header alone says whether to read on (#3317): a type TLS + // 1.3 does not know or a length over the limit ends it here. + ha, herr = tls13_record.check_header(bytes.view(r.buf)[r.off..r.off + 5]) + if ha != 0 { + r.alert = ha + return null, 0, herr + } plen = ((bytes.get(r.buf, r.off + 3) & 0xFF) << 8) | (bytes.get(r.buf, r.off + 4) & 0xFF) total = 5 + plen if avail >= total { @@ -554,6 +639,60 @@ struct ClientConn { res_hash_algo: ptr // hash name as bytes ("sha256"/"sha384") or null res_hash_len: int group: int // the key exchange group negotiated (conn_group) + suite: int // the negotiated cipher suite, which a ticket from it records (#3339) + // The application traffic secrets the record contexts are keyed from; + // each KeyUpdate moves one on (RFC 8446 7.2, #3320). + send_secret: ptr + recv_secret: ptr + // Post-handshake handshake bytes of a message not yet whole (a message + // may span records, 5.1), or null. + hs_pend: ptr + hs_pend_len: int + ticket: ptr // the latest NewSessionTicket not yet taken (*SessionTicket), or null + held: ptr // application data read while conn_get_ticket waited, or null + held_len: int + empties: int // empty application-data records in a row (#3317) + key_updates: int // KeyUpdates since the last application data (#3320) + warnings: int // user_canceled alerts since the last application data (#3323) + ku_pending: int // 1 when the server asked for a KeyUpdate not yet sent (#3320) + closed: int // 0 open, 1 after the server's close_notify, 2 after an error + early_accepted: int // 1 when the server took this resumption's 0-RTT data (#3336) +} + +// A ClientConn over `sock` and `rio` for `suite`, keyed with the +// application traffic secrets `c_ap` (client write) and `s_ap` (server +// write); it keeps both secrets, `res_master` and the socket. +fn new_client_conn(sock: @keep ptr, rio: @keep ptr, suite: int, c_ap: @keep ptr, s_ap: @keep ptr, res_master: @keep ptr, group: int) -> ptr { + aead, key_len, hash_algo, hash_len, _serr = suite_params(suite) + ck = tls13_ks.write_key(hash_algo, bytes.view(c_ap)[0..hash_len], key_len) + civ = tls13_ks.write_iv(hash_algo, c_ap) + sk = tls13_ks.write_key(hash_algo, bytes.view(s_ap)[0..hash_len], key_len) + siv = tls13_ks.write_iv(hash_algo, s_ap) + conn = malloc(sizeof(ClientConn)) as *ClientConn + conn.sock = sock + conn.rio = rio + conn.app_send = tls13_record.new_aead(aead, bytes.view(ck)[0..key_len], civ) as ptr + conn.app_recv = tls13_record.new_aead(aead, bytes.view(sk)[0..key_len], siv) as ptr + conn.res_master = res_master + conn.res_hash_algo = str_to_bytes_local(hash_algo) + conn.res_hash_len = hash_len + conn.group = group + conn.suite = suite + conn.send_secret = c_ap + conn.recv_secret = s_ap + conn.hs_pend = null + conn.hs_pend_len = 0 + conn.ticket = null + conn.held = null + conn.held_len = 0 + conn.empties = 0 + conn.key_updates = 0 + conn.warnings = 0 + conn.ku_pending = 0 + conn.closed = 0 + conn.early_accepted = 0 + tls13_ks.free_secret(ck); tls13_ks.free_secret(civ); tls13_ks.free_secret(sk); tls13_ks.free_secret(siv) + return conn as ptr } // Send the record `rec` on `sock`, a borrowed view of its buffer with no @@ -568,9 +707,46 @@ fn cli_send_view(sock: ptr, rec: byte[]) -> int { return sent } +// `msg` of `content_type` under `ctx` in as many records as it takes +// (tls13_record.seal_records), sent: 1, or 0 when a record cannot be sealed +// or the send falls short (#3380). +fn cli_send_sealed(sock: ptr, ctx: ptr, content_type: int, msg: byte[]) -> int { + recs = tls13_record.seal_records(ctx, content_type, msg) + if recs == null { return 0 } + rl = bytes.length(recs) + n = cli_send_view(sock, bytes.view(recs)[0..rl]) + bytes.wipe_free(recs) + if n != rl { return 0 } + return 1 +} + +// The dummy change_cipher_spec record of middlebox compatibility mode (RFC +// 8446 D.4): 14 03 03 00 01 01. The client always sends a non-empty +// legacy_session_id, which 4.1.2 ties to that mode, so it sends this record +// too: right after the ClientHello when it offers early data, else right +// before its second flight, which is an alert when it refuses the server's +// flight (#3322). BoringSSL refuses a client that does not. +fn send_dummy_ccs(sock: ptr) { + ccs = bytes.new(6) + bytes.set(ccs, 0, 0x14) + bytes.set(ccs, 1, 0x03) + bytes.set(ccs, 2, 0x03) + bytes.set(ccs, 3, 0x00) + bytes.set(ccs, 4, 0x01) + bytes.set(ccs, 5, 0x01) + cli_send_view(sock, bytes.view(ccs)[0..6]) + bytes.wipe_free(ccs) +} + // Send a plaintext handshake record: type 0x16 (handshake), legacy version // 0x0301 for the very first flight (RFC 8446 §5.1 allows 0x0301/0x0303). fn send_plaintext_handshake(sock: ptr, msg: byte[]) -> string { + return send_plaintext_handshake_v(sock, msg, 0x01) +} + +// The same, with legacy_record_version 0x03 `minor`: a ClientHello after a +// HelloRetryRequest goes at 0x0303 (RFC 8446 5.1, #3325). +fn send_plaintext_handshake_v(sock: ptr, msg: byte[], minor: int) -> string { if msg.len < 0 { panic("tls13_client.send_plaintext_handshake: the `msg` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } int msg_len = msg.len total = 5 + msg_len @@ -578,44 +754,89 @@ fn send_plaintext_handshake(sock: ptr, msg: byte[]) -> string { bytes.set(rec, total - 1, 0) bytes.set(rec, 0, 0x16) // handshake bytes.set(rec, 1, 0x03) - bytes.set(rec, 2, 0x01) // legacy_record_version 0x0301 + bytes.set(rec, 2, minor) // legacy_record_version bytes.set(rec, 3, (msg_len >> 8) & 0xFF) bytes.set(rec, 4, msg_len & 0xFF) bytes.copy_from_slice(rec, 5, msg[0..msg_len]) n = cli_send_view(sock, bytes.view(rec)[0..total]) - bytes.free(rec) + bytes.wipe_free(rec) if n != total { return "failed to send handshake record" } return "" } -// Fill a std.bytes buffer of length n with CSPRNG bytes. +// A std.bytes buffer of n CSPRNG bytes, or null when the CSPRNG fails or +// returns short. The callers MUST check: these bytes become the ephemeral +// key-share scalars and the client random, and handing back zeros instead +// would make every session's keys predictable. fn rand_into(n: int) -> ptr { - s, _sn, err = cryptography.random_bytes(n) - b = bytes.new(n) - if n > 0 { bytes.set(b, n - 1, 0) } - if string.equals(err, "") == 1 { - bytes.copy_from_string(b, 0, s, n) - } + b, err = cryptography.random_buffer(n) + if string.equals(err, "") != 1 { return null } return b } +// A fresh secp256r1 private scalar in [1, n-1] (32 random bytes fall outside +// it with probability about 2^-32: draw again), or null without randomness. +fn fresh_p256_priv() -> ptr { + while true { + k = rand_into(32) + if k == null { return null } + if p256.private_key_valid(k) == 1 { return k } + bytes.wipe_free(k) + } +} + +// A fresh secp384r1 private scalar in [1, n-1], or null without randomness, +// for the share a HelloRetryRequest asks for (#3369). +fn fresh_p384_priv() -> ptr { + while true { + k = rand_into(48) + if k == null { return null } + if p384.private_key_valid(k) == 1 { return k } + bytes.wipe_free(k) + } +} + +// Free the key exchange secrets of a full handshake (each may be null). +fn free_hello_keys(dk: @frees ptr, p256_priv: @frees ptr, p384_priv: @frees ptr) { + if dk != null { bytes.wipe_free(dk) } + if p256_priv != null { bytes.wipe_free(p256_priv) } + if p384_priv != null { bytes.wipe_free(p384_priv) } +} + +// Close the socket of a handshake that failed once what the server has +// already sent is read and dropped (#3368, tcp.close_drained): closing a +// socket that holds unread bytes sends a TCP reset, and a peer that gets +// one may discard the alert just sent before reading it. +fn close_drained(sock: ptr) { + tcp.close_drained(sock) +} + fn cleanup_hello(pub: ptr, rnd: ptr, sid: ptr, ch: ptr) { - bytes.free(pub); bytes.free(rnd); bytes.free(sid); bytes.free(ch) + bytes.wipe_free(pub); bytes.wipe_free(rnd); bytes.wipe_free(sid); bytes.wipe_free(ch) } -// Build the client key_share public value for `group` from a 32-byte private -// scalar. x25519 -> the 32-byte public; secp256r1 -> 04 || X(32) || Y(32) (the -// 65-byte uncompressed point). Returns (pubkey, pubkey_len). +// Build the client key_share public value for `group` from its private +// scalar. x25519 -> the 32-byte public; secp256r1 -> 04 || X(32) || Y(32), +// secp384r1 -> 04 || X(48) || Y(48) (the uncompressed points, 65 and 97 +// bytes). Returns (pubkey, pubkey_len). fn keyshare_for_group(group: int, priv: ptr) -> (ptr, int) { - if group == tls13_hs.GROUP_SECP256R1 { - px, py = p256.scalar_mult_base(priv) - pt = bytes.new(65) - bytes.set(pt, 64, 0) + if group == tls13_hs.GROUP_SECP256R1 || group == tls13_hs.GROUP_SECP384R1 { + cl = 32 + px = null + py = null + if group == tls13_hs.GROUP_SECP256R1 { + px, py = p256.scalar_mult_base(priv) + } else { + cl = 48 + px, py = p384.scalar_mult_base(priv) + } + pt = bytes.new(1 + 2 * cl) + bytes.set(pt, 2 * cl, 0) bytes.set(pt, 0, 0x04) - bytes.copy_from_bytes(pt, 1, px, 0, 32) - bytes.copy_from_bytes(pt, 33, py, 0, 32) - bytes.free(px); bytes.free(py) - return pt, 65 + bytes.copy_from_bytes(pt, 1, px, 0, cl) + bytes.copy_from_bytes(pt, 1 + cl, py, 0, cl) + bytes.wipe_free(px); bytes.wipe_free(py) + return pt, 1 + 2 * cl } // default: x25519 pub = x25519.base_point(priv) @@ -623,19 +844,27 @@ fn keyshare_for_group(group: int, priv: ptr) -> (ptr, int) { } // Compute the ECDHE shared secret for `group` from our private scalar and the -// server's key_share. x25519 -> agree(); secp256r1 -> ECDH x-coordinate from -// the server's uncompressed point. Returns (shared, "") or (null, err). +// server's key_share. x25519 -> agree(); secp256r1 and secp384r1 -> the ECDH +// x coordinate from the server's uncompressed point, which ecdh refuses off +// the curve. Returns (shared, "") or (null, err). fn ecdhe_shared(group: int, priv: ptr, server_ks: ptr, server_ks_len: int) -> (ptr, string) { - if group == tls13_hs.GROUP_SECP256R1 { - if server_ks_len != 65 { return null, "secp256r1 key_share not a 65-byte point" } - if (bytes.get(server_ks, 0) & 0xFF) != 0x04 { return null, "secp256r1 key_share not uncompressed" } - sx = bytes.new(32); bytes.set(sx, 31, 0) - sy = bytes.new(32); bytes.set(sy, 31, 0) - bytes.copy_from_bytes(sx, 0, server_ks, 1, 32) - bytes.copy_from_bytes(sy, 0, server_ks, 33, 32) - sh = p256.ecdh(priv, sx, sy) - bytes.free(sx); bytes.free(sy) - if sh == null { return null, "secp256r1 ECDH failed" } + if group == tls13_hs.GROUP_SECP256R1 || group == tls13_hs.GROUP_SECP384R1 { + cl = 32 + name = "secp256r1" + if group == tls13_hs.GROUP_SECP384R1 { + cl = 48 + name = "secp384r1" + } + if server_ks_len != 1 + 2 * cl { return null, "${name} key_share not a ${1 + 2 * cl}-byte point" } + if (bytes.get(server_ks, 0) & 0xFF) != 0x04 { return null, "${name} key_share not uncompressed" } + sx = bytes.new(cl); bytes.set(sx, cl - 1, 0) + sy = bytes.new(cl); bytes.set(sy, cl - 1, 0) + bytes.copy_from_bytes(sx, 0, server_ks, 1, cl) + bytes.copy_from_bytes(sy, 0, server_ks, 1 + cl, cl) + sh = null + if group == tls13_hs.GROUP_SECP256R1 { sh = p256.ecdh(priv, sx, sy) } else { sh = p384.ecdh(priv, sx, sy) } + bytes.wipe_free(sx); bytes.wipe_free(sy) + if sh == null { return null, "${name} ECDH failed" } return sh, "" } return x25519.agree(priv, server_ks) @@ -671,6 +900,25 @@ connect_mtls(host: string, port: int, x25519_priv: ptr, client_cert: ptr, client return connect_ex(host, port, x25519_priv, client_cert, client_cert_len, client_key) } +// connect_socket(sock, host, cafile) -> (ClientConn ptr, "") or (null, +// error): the handshake and verification of connect() for `host`, over +// `sock`, an already-connected std.tcp socket that the connection then owns +// (it is closed on failure too), trusting the PEM bundle `cafile` instead of +// the system store when it is not "". For a program that puts its own bytes +// on the connection before TLS starts, or runs TLS through a tunnel it +// built; the BoGo shim (tests/conformance/bogo, #1298) is one. +connect_socket(sock: @keep ptr, host: string, cafile: string) -> (ptr, string) { + if sock == null { return null, "connect_socket: the socket is null" } + priv = rand_into(32) + if priv == null { + tcp.close(sock) + return null, "no randomness for the key share" + } + conn, err = connect_full(host, 443, priv, null, 0, null, sock, 0, cafile) + bytes.wipe_free(priv) + return conn, err +} + fn connect_ex(host: string, port: int, x25519_priv: ptr, client_cert: ptr, client_cert_len: int, client_key: ptr) -> (ptr, string) { return connect_full(host, port, x25519_priv, client_cert, client_cert_len, client_key, null, 0, "") } @@ -682,13 +930,18 @@ fn connect_ex(host: string, port: int, x25519_priv: ptr, client_cert: ptr, clien // drive this over a forward-proxy CONNECT tunnel (#1849): the // tunnel is established in C, and the handshake then runs // end-to-end against `host` through it. -// insecure 1 skips certificate verification entirely. Present for parity -// with the OpenSSL backend's set_insecure; it is exactly as -// dangerous here as there. +// insecure a bit set (the bridge's verify_mode): 1 skips certificate +// verification entirely, for parity with the OpenSSL backend's +// set_insecure, exactly as dangerous here as there; 2 lets any +// trusted certificate end a path (set_partial_chain, #3311). // cafile a PEM bundle to trust INSTEAD of the system store (parity with // set_cafile), or "" for the normal resolution. fn connect_full(host: string, port: int, x25519_priv: ptr, client_cert: ptr, client_cert_len: int, client_key: ptr, adopt_sock: @keep ptr, insecure: int, cafile: string) -> (ptr, string) { sock = adopt_sock + if client_cert != null && client_key != null && p256.private_key_valid(client_key) == 0 { + if sock != null { tcp.close(sock) } + return null, "the client key is not a P-256 private key (32 bytes, in [1, n-1])" + } if sock == null { sock, dial_err = tcp.connect(host, port) if string.equals(dial_err, "") != 1 { return null, dial_err } @@ -699,12 +952,24 @@ fn connect_full(host: string, port: int, x25519_priv: ptr, client_cert: ptr, cli // X25519MLKEM768 first (the hybrid post-quantum group, RFC 10024), then // x25519 and secp256r1. The hybrid share reuses the caller's x25519 // scalar, as Chrome and Go do, so one X25519 public covers both entries. + // secp384r1 is named too, without a share, as BoringSSL does: a server + // that wants it asks in a HelloRetryRequest (#3369). + p256_priv = fresh_p256_priv() + rnd = rand_into(32) + sid = rand_into(32) + if p256_priv == null || rnd == null || sid == null { + if p256_priv != null { bytes.wipe_free(p256_priv) } + if rnd != null { bytes.wipe_free(rnd) } + if sid != null { bytes.wipe_free(sid) } + tcp.close(sock) + return null, "no randomness available for the handshake" + } ek, dk, kerr = mlkem.mlkem768_keygen() if kerr != "" { + bytes.wipe_free(p256_priv); bytes.wipe_free(rnd); bytes.wipe_free(sid) tcp.close(sock) return null, kerr } - p256_priv = rand_into(32) x_pub, x_pub_len = keyshare_for_group(tls13_hs.GROUP_X25519, x25519_priv) p_pub, p_pub_len = keyshare_for_group(tls13_hs.GROUP_SECP256R1, p256_priv) @@ -714,95 +979,174 @@ fn connect_full(host: string, port: int, x25519_priv: ptr, client_cert: ptr, cli bytes.set(hybrid, ek_len + 32 - 1, 0) bytes.copy_from_bytes(hybrid, 0, ek, 0, ek_len) bytes.copy_from_bytes(hybrid, ek_len, x_pub, 0, 32) - bytes.free(ek) // dk carries what decapsulation needs + bytes.wipe_free(ek) // dk carries what decapsulation needs - rnd = rand_into(32) - sid = rand_into(32) - ch = tls13_hs.client_hello_full(rnd, bytes.view(sid)[0..32], tls13_hs.GROUP_X25519MLKEM768, bytes.view(hybrid)[0..ek_len + 32], tls13_hs.GROUP_X25519, bytes.view(x_pub)[0..x_pub_len], tls13_hs.GROUP_SECP256R1, bytes.view(p_pub)[0..p_pub_len], host) - bytes.free(p_pub); bytes.free(hybrid) // copied into the CH + // secp384r1 is named with no share, for a server that asks for it in a + // HelloRetryRequest (#3369). + ch = tls13_hs.client_hello_offer(rnd, bytes.view(sid)[0..32], tls13_hs.GROUP_X25519MLKEM768, bytes.view(hybrid)[0..ek_len + 32], tls13_hs.GROUP_X25519, bytes.view(x_pub)[0..x_pub_len], tls13_hs.GROUP_SECP256R1, bytes.view(p_pub)[0..p_pub_len], host, tls13_hs.GROUP_SECP384R1) + bytes.wipe_free(p_pub); bytes.wipe_free(hybrid) // copied into the CH pub = x_pub // cleanup_hello frees this ch_len = bytes.length(ch) tr = transcript_new() transcript_add(tr, bytes.view(ch)[0..ch_len]) + p384_priv = null serr = send_plaintext_handshake(sock, bytes.view(ch)[0..ch_len]) if string.equals(serr, "") != 1 { - bytes.free(dk); bytes.free(p256_priv) + free_hello_keys(dk, p256_priv, p384_priv) cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); tcp.close(sock) return null, serr } rio = recio_new(sock) - shrec, shlen, sherr = recio_next_hs(rio) - if string.equals(sherr, "") != 1 { - bytes.free(dk); bytes.free(p256_priv) - cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio); tcp.close(sock) - return null, sherr - } - sh_msg_len = shlen - 5 - sh = bytes.new(sh_msg_len) - bytes.set(sh, sh_msg_len - 1, 0) - bytes.copy_from_bytes(sh, 0, shrec, 5, sh_msg_len) - parsed = tls13_hs.ShParsed { version: 0, cipher_suite: 0, group: 0, key_share: null, key_share_len: 0, is_hello_retry: 0, psk_accepted: 0 } - perr = tls13_hs.parse_server_hello(bytes.view(sh)[0..sh_msg_len], &parsed) - if string.equals(perr, "") == 1 && parsed.is_hello_retry == 1 { - // Every group we implement already has a key share in this - // ClientHello, so a HelloRetryRequest names either one of them, which - // RFC 8446 §4.1.4 says the client MUST abort on, or a group we cannot - // do. - perr = "server sent a HelloRetryRequest, but every group we support was already offered" - } - if string.equals(perr, "") != 1 { - bytes.free(dk); bytes.free(p256_priv) - if parsed.key_share != null { bytes.free(parsed.key_share) } - bytes.free(sh); bytes.free(shrec) - cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio); tcp.close(sock) - return null, perr + // The ServerHello, after at most one HelloRetryRequest (RFC 8446 4.1.4, + // #3325). Every group but secp384r1 has a share here, so a + // HelloRetryRequest asks for a secp384r1 share (#3369) or a cookie; the + // second ClientHello is the first with that share alone, or the cookie. + // `shares` are the groups the ClientHello the server answers has shares + // for: a ServerHello must pick one of them (4.2.8). + shares = GB_ALL + retried = 0 + hrr_suite = 0 + ccs_sent = 0 + sh = null + shrec = null + sh_msg_len = 0 + parsed = tls13_hs.ShParsed { version: 0, cipher_suite: 0, group: 0, key_share: null, key_share_len: 0, is_hello_retry: 0, psk_accepted: 0, alert: 0 } + reading = 1 + while reading == 1 { + shrec2, shlen, sherr = recio_next_hs(rio) + if string.equals(sherr, "") != 1 { + if rio.alert != 0 { send_plain_alert(sock, rio.alert) } + free_hello_keys(dk, p256_priv, p384_priv) + cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio); close_drained(sock) + return null, sherr + } + shrec = shrec2 + sh_msg_len = shlen - 5 + sh = bytes.new(sh_msg_len) + bytes.copy_from_bytes(sh, 0, shrec, 5, sh_msg_len) + perr = tls13_hs.parse_server_hello(bytes.view(sh)[0..sh_msg_len], &parsed) + if string.equals(perr, "") == 1 { + ha, herr = check_hello_fields(&parsed, sh, sid, retried, hrr_suite, GB_ALL | GB_SECP384R1, shares) + if ha != 0 { + parsed.alert = ha + perr = herr + } + } + if string.equals(perr, "") == 1 && parsed.psk_accepted == 1 { + // A full handshake offers no PSK (RFC 8446 4.2, #3315). + parsed.alert = ALERT_UNSUPPORTED_EXTENSION + perr = "server hello: pre_shared_key answers nothing the client sent" + } + if string.equals(perr, "") != 1 { + // The alert the ServerHello earned, in the clear (#3315). + if parsed.alert != 0 { send_plain_alert(sock, parsed.alert) } + free_hello_keys(dk, p256_priv, p384_priv) + if parsed.key_share != null { bytes.wipe_free(parsed.key_share) } + bytes.wipe_free(sh); bytes.wipe_free(shrec) + cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio); close_drained(sock) + return null, perr + } + if parsed.is_hello_retry == 1 { + // Transcript: message_hash(ClientHello1), the HelloRetryRequest, + // then the second ClientHello (4.4.1); the change_cipher_spec + // goes before it (D.4), and its record says 0x0303 (5.1). + _ha, _hk, halgo, _hh, _he = suite_params(parsed.cipher_suite) + tr = transcript_retry(tr, bytes.view(ch)[0..ch_len], bytes.view(sh)[0..sh_msg_len], halgo) + pch = tls13_hs.PskCh { buf: null, buf_len: 0, binder_off: 0, truncated_len: 0 } + // check_hello_fields let through only a group named without a + // share: secp384r1, whose share replaces the others (4.1.2). + share = bytes.new(0) + share_len = 0 + if parsed.group == tls13_hs.GROUP_SECP384R1 { + p384_priv = fresh_p384_priv() + if p384_priv == null { + bytes.wipe_free(share) + free_hello_keys(dk, p256_priv, p384_priv) + bytes.wipe_free(sh); bytes.wipe_free(shrec) + cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio); close_drained(sock) + return null, "no randomness available for the handshake" + } + bytes.wipe_free(share) + share, share_len = keyshare_for_group(tls13_hs.GROUP_SECP384R1, p384_priv) + shares = GB_SECP384R1 + } + tls13_hs.client_hello_retry(bytes.view(ch)[0..ch_len], parsed.group, bytes.view(share)[0..share_len], bytes.view(sh)[parsed.cookie_off..parsed.cookie_off + parsed.cookie_len], &pch) + bytes.wipe_free(share) + bytes.wipe_free(ch) + ch = pch.buf + ch_len = pch.buf_len + transcript_add(tr, bytes.view(ch)[0..ch_len]) + send_dummy_ccs(sock) + ccs_sent = 1 + serr2 = send_plaintext_handshake_v(sock, bytes.view(ch)[0..ch_len], 0x03) + bytes.wipe_free(sh); bytes.wipe_free(shrec) + sh = null + shrec = null + if string.equals(serr2, "") != 1 { + free_hello_keys(dk, p256_priv, p384_priv) + cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio); close_drained(sock) + return null, serr2 + } + retried = 1 + hrr_suite = parsed.cipher_suite + } else { + reading = 0 + } } transcript_add(tr, bytes.view(sh)[0..sh_msg_len]) - // The shared secret for the group the server chose. + // The shared secret for the group the server chose: the one it names + // must be offered (illegal_parameter), its share must be there + // (missing_extension) and a valid point (illegal_parameter, RFC 8446 + // 4.2.8, 7.4.2). shared = null shared_len = 32 agerr = "" - if parsed.group == tls13_hs.GROUP_X25519MLKEM768 { + ag_alert = ALERT_ILLEGAL_PARAMETER + if parsed.key_share == null { + agerr = "server hello: no key_share" + ag_alert = ALERT_MISSING_EXTENSION + } else if parsed.group == tls13_hs.GROUP_X25519MLKEM768 { shared, agerr = hybrid_shared(dk, x25519_priv, parsed.key_share, parsed.key_share_len) shared_len = 64 - } else if parsed.group == tls13_hs.GROUP_X25519 || parsed.group == tls13_hs.GROUP_SECP256R1 { + } else if parsed.group == tls13_hs.GROUP_X25519 || parsed.group == tls13_hs.GROUP_SECP256R1 || parsed.group == tls13_hs.GROUP_SECP384R1 { priv = x25519_priv if parsed.group == tls13_hs.GROUP_SECP256R1 { priv = p256_priv } + if parsed.group == tls13_hs.GROUP_SECP384R1 { + priv = p384_priv + shared_len = 48 + } shared, agerr = ecdhe_shared(parsed.group, priv, parsed.key_share, parsed.key_share_len) } else { agerr = "server chose a group we did not offer" } - bytes.free(dk); bytes.free(p256_priv) + free_hello_keys(dk, p256_priv, p384_priv) if string.equals(agerr, "") != 1 || shared == null { if string.equals(agerr, "") == 1 { agerr = "key exchange failed" } - if parsed.key_share != null { bytes.free(parsed.key_share) } - bytes.free(sh); bytes.free(shrec) - cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio); tcp.close(sock) + send_plain_alert(sock, ag_alert) + if shared != null { bytes.wipe_free(shared) } + if parsed.key_share != null { bytes.wipe_free(parsed.key_share) } + bytes.wipe_free(sh); bytes.wipe_free(shrec) + cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio); close_drained(sock) return null, agerr } - // Negotiate the record AEAD + key-schedule hash from the chosen suite. - _rec_aead, _rec_key_len, _hash_algo, _hash_len, suite_err = suite_params(parsed.cipher_suite) - if string.equals(suite_err, "") != 1 { - if parsed.key_share != null { bytes.free(parsed.key_share) } - bytes.free(shared); bytes.free(sh); bytes.free(shrec) - cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio); tcp.close(sock) - return null, suite_err - } - // Derive keys from the shared secret (32 bytes classical, 64 hybrid) and - // run the shared post-ServerHello handshake tail (flight read, auth, - // Finished). - result, result_err = run_handshake_tail(sock, rio, tr, host, bytes.view(shared)[0..shared_len], parsed.cipher_suite, client_cert, client_cert_len, client_key, insecure, cafile) + // Derive keys from the shared secret (32 bytes classical, 48 for + // secp384r1, 64 hybrid) and run the shared post-ServerHello handshake + // tail (flight read, auth, Finished). check_hello_fields made sure the + // suite is one offered. + offered = tls13_hs.OFFER_ALPN | tls13_hs.OFFER_STATUS + if string.length(host) > 0 { offered = offered | tls13_hs.OFFER_SNI } + result, result_err = run_handshake_tail(sock, rio, tr, host, bytes.view(shared)[0..shared_len], parsed.cipher_suite, client_cert, client_cert_len, client_key, insecure, cafile, offered, ccs_sent) - bytes.free(shared) - if parsed.key_share != null { bytes.free(parsed.key_share) } - bytes.free(sh); bytes.free(shrec) + bytes.wipe_free(shared) + if parsed.key_share != null { bytes.wipe_free(parsed.key_share) } + bytes.wipe_free(sh); bytes.wipe_free(shrec) cleanup_hello(pub, rnd, sid, ch); transcript_free(tr) if result == null { - recio_free(rio); tcp.close(sock) + recio_free(rio); close_drained(sock) } else { rc = result as *ClientConn rc.group = parsed.group @@ -810,6 +1154,83 @@ fn connect_full(host: string, port: int, x25519_priv: ptr, client_cert: ptr, cli return result, result_err } +// The groups a ClientHello names in supported_groups, and the ones it +// carries a key share for, as bits (check_hello_fields). GB_ALL are the +// full handshake's shares; it names GB_SECP384R1 without one (#3369). +const GB_X25519MLKEM768 = 1 +const GB_X25519 = 2 +const GB_SECP256R1 = 4 +const GB_ALL = 7 +const GB_SECP384R1 = 8 + +fn group_bit(g: int) -> int { + if g == tls13_hs.GROUP_X25519MLKEM768 { return GB_X25519MLKEM768 } + if g == tls13_hs.GROUP_X25519 { return GB_X25519 } + if g == tls13_hs.GROUP_SECP256R1 { return GB_SECP256R1 } + if g == tls13_hs.GROUP_SECP384R1 { return GB_SECP384R1 } + return 0 +} + +// The ServerHello or HelloRetryRequest `p` (parsed from `sh`) against the +// ClientHello it answers, whose legacy_session_id is the 32 bytes of `sid`, +// whose supported_groups are the bits `named` and whose shares are the +// bits `shared`; `retried` 1 after a HelloRetryRequest for suite +// `hrr_suite`. (0, "") or the alert and why, all illegal_parameter (RFC +// 8446 4.1.3, 4.1.4; #3318, #3325): a session id echo that is not the one +// sent; a suite the client did not offer; a second HelloRetryRequest +// (unexpected_message); a HelloRetryRequest naming a group not offered or +// one with a share already, or changing nothing; a ServerHello after one +// with another suite, or whose key_share is for a group the ClientHello +// has no share of: after a HelloRetryRequest that asked for a group, any +// other (4.2.8, #3369). +fn check_hello_fields(p: *ShParsed, sh: ptr, sid: ptr, retried: int, hrr_suite: int, named: int, shared: int) -> (int, string) { + if p.session_id_len != 32 || bytes_equal(sid, bytes.view(sh)[p.session_id_off..p.session_id_off + 32]) != 1 { + return ALERT_ILLEGAL_PARAMETER, "server hello: legacy_session_id_echo is not the session id the client sent" + } + _a, _k, _h, _hl, serr = suite_params(p.cipher_suite) + if serr != "" { return ALERT_ILLEGAL_PARAMETER, "server hello: cipher suite ${p.cipher_suite} was not offered" } + if p.is_hello_retry == 1 { + if retried == 1 { return ALERT_UNEXPECTED_MESSAGE, "hello retry request: a second one" } + if p.group != 0 { + gb = group_bit(p.group) + if gb == 0 || (named & gb) == 0 { return ALERT_ILLEGAL_PARAMETER, "hello retry request: group ${p.group} was not offered" } + if (shared & gb) != 0 { return ALERT_ILLEGAL_PARAMETER, "hello retry request: group ${p.group} has a key share in the ClientHello already" } + } else if p.cookie_len == 0 { + return ALERT_ILLEGAL_PARAMETER, "hello retry request: it asks for no change to the ClientHello" + } + return 0, "" + } + if retried == 1 && p.cipher_suite != hrr_suite { + return ALERT_ILLEGAL_PARAMETER, "server hello: cipher suite ${p.cipher_suite}, not the HelloRetryRequest's ${hrr_suite}" + } + if p.key_share != null && (shared & group_bit(p.group)) == 0 { + return ALERT_ILLEGAL_PARAMETER, "server hello: group ${p.group} has no key share in the ClientHello" + } + return 0, "" +} + +// The transcript after a HelloRetryRequest (RFC 8446 4.4.1): `tr`, which +// held the first ClientHello `ch1`, freed, and a new one holding +// message_hash (254, a 24-bit Hash.length, Hash(ch1)) and then `hrr`, +// hashed with `algo`, the HelloRetryRequest's suite's. +transcript_retry(tr: @frees ptr, ch1: byte[], hrr: byte[], algo: string) -> ptr { + transcript_free(tr) + h = hash_bytes_algo(ch1, algo) + hl = bytes.length(h) + mh = bytes.new(4 + hl) + bytes.set(mh, 0, 254) + bytes.set(mh, 1, 0) + bytes.set(mh, 2, 0) + bytes.set(mh, 3, hl) + bytes.copy_from_bytes(mh, 4, h, 0, hl) + bytes.wipe_free(h) + t = transcript_new() + transcript_add(t, bytes.view(mh)[0..4 + hl]) + transcript_add(t, hrr) + bytes.wipe_free(mh) + return t +} + // X25519MLKEM768 shared secret (RFC 10024): the server share is the ML-KEM // ciphertext (1088) followed by its X25519 public (32), and the secret is the // ML-KEM shared secret followed by the X25519 one, 64 bytes. ML-KEM comes @@ -821,28 +1242,29 @@ fn hybrid_shared(dk: ptr, x25519_priv: ptr, server_ks: ptr, server_ks_len: int) sxpub = bytes.new(32); bytes.set(sxpub, 31, 0) bytes.copy_from_bytes(sxpub, 0, server_ks, 1088, 32) mlk_ss, merr = mlkem.mlkem768_decaps(dk, ct) - bytes.free(ct) + bytes.wipe_free(ct) if merr != "" { - bytes.free(sxpub) + bytes.wipe_free(sxpub) return null, merr } x_ss, xerr = ecdhe_shared(tls13_hs.GROUP_X25519, x25519_priv, sxpub, 32) - bytes.free(sxpub) + bytes.wipe_free(sxpub) if xerr != "" || x_ss == null { - bytes.free(mlk_ss) - if x_ss != null { bytes.free(x_ss) } + bytes.wipe_free(mlk_ss) + if x_ss != null { bytes.wipe_free(x_ss) } if xerr == "" { xerr = "x25519 agreement failed" } return null, xerr } shared = bytes.new(64); bytes.set(shared, 63, 0) bytes.copy_from_bytes(shared, 0, mlk_ss, 0, 32) bytes.copy_from_bytes(shared, 32, x_ss, 0, 32) - bytes.free(mlk_ss); bytes.free(x_ss) + bytes.wipe_free(mlk_ss); bytes.wipe_free(x_ss) return shared, "" } // The key exchange group the handshake settled on: tls13_hs.GROUP_X25519MLKEM768 -// (0x11EC), GROUP_X25519 (0x001D) or GROUP_SECP256R1 (0x0017). +// (0x11EC), GROUP_X25519 (0x001D), GROUP_SECP256R1 (0x0017) or, after a +// HelloRetryRequest, GROUP_SECP384R1 (0x0018). conn_group(conn: ptr) -> int { if conn == null { return 0 } c = conn as *ClientConn @@ -853,8 +1275,9 @@ conn_group(conn: ptr) -> int { // X25519MLKEM768 itself (#2483): connect() with a fresh x25519 scalar. connect_pq(host: string, port: int) -> (ptr, string) { priv = rand_into(32) + if priv == null { return null, "no randomness available for the key share" } conn, err = connect(host, port, priv) - bytes.free(priv) + bytes.wipe_free(priv) return conn, err } @@ -867,9 +1290,12 @@ connect_pq(host: string, port: int) -> (ptr, string) { // cleanup; on success ownership of sock+rio moves into the returned conn. // Returns (conn, "") or (null, err). Does NOT free `shared`, `tr`, sh/shrec, // or the ClientHello — the caller does. -// insecure: 1 skips server authentication entirely (set_insecure parity). +// insecure: the verify_mode bits (connect_full): 1 skips server +// authentication entirely (set_insecure parity), 2 asks for partial chains. // cafile: a PEM bundle to trust instead of the system store, or "". -fn run_handshake_tail(sock: @keep ptr, rio: @keep ptr, tr: ptr, host: string, shared: byte[], cipher_suite: int, client_cert: ptr, client_cert_len: int, client_key: ptr, insecure: int, cafile: string) -> (ptr, string) { +// ccs_sent: 1 when the dummy change_cipher_spec went already, before a +// second ClientHello (#3325). +fn run_handshake_tail(sock: @keep ptr, rio: @keep ptr, tr: ptr, host: string, shared: byte[], cipher_suite: int, client_cert: ptr, client_cert_len: int, client_key: ptr, insecure: int, cafile: string, offered: int, ccs_sent: int) -> (ptr, string) { rec_aead, rec_key_len, hash_algo, hash_len, suite_err = suite_params(cipher_suite) if string.equals(suite_err, "") != 1 { return null, suite_err } @@ -884,29 +1310,28 @@ fn run_handshake_tail(sock: @keep ptr, rio: @keep ptr, tr: ptr, host: string, sh th_through_cert = null server_verify_data = null _svd_len = 0 - done = 0 ferr = "" - guard = 0 - while done == 0 { - guard = guard + 1 - if guard > 128 { ferr = "flight too long"; done = 1 } - nrec, nlen, nerr = recio_next(rio) - if string.equals(nerr, "") != 1 { ferr = nerr; done = 1 } - if string.equals(nerr, "") == 1 { - ct = bytes.get(nrec, 0) & 0xFF - if ct == 0x14 { bytes.free(nrec) } else { - opened = tls13_record.OpenedRecord { content_type: 0, plaintext: null, plaintext_len: 0 } - oerr = tls13_record.open_record(srv_ctx, bytes.view(nrec)[0..nlen], &opened) - if string.equals(oerr, "") != 1 { ferr = oerr; done = 1 } else { - if opened.content_type == 22 { - bytes.copy_from_bytes(fl, fllen, opened.plaintext, 0, opened.plaintext_len) - fllen = fllen + opened.plaintext_len - } - } - if opened.plaintext != null { bytes.free(opened.plaintext) } - bytes.free(nrec) - if flight_complete(fl, fllen) == 1 { done = 1 } - } + fllen, ferr, falert = read_flight(rio, srv_ctx, fl) + // The second flight, an alert or the client's own, starts with the + // dummy change_cipher_spec (RFC 8446 D.4, #3322), unless it came before + // a second ClientHello. + if ccs_sent == 0 { send_dummy_ccs(sock) } + if string.equals(ferr, "") != 1 && falert != 0 { + fa_ctx = tls13_record.new_aead(rec_aead, bytes.view(hs_client_key(keys))[0..rec_key_len], hs_client_iv(keys)) + send_alert_hs(sock, fa_ctx, ALERT_LEVEL_FATAL, falert) + tls13_record.free_ctx(fa_ctx) + } + + // The flight's messages in their order, each parsed to its last byte; + // the extensions answer only what the ClientHello offered, each where + // it may appear (RFC 8446 4.2, #3315, #3316, #3324). + if string.equals(ferr, "") == 1 { + ee_alert, ee_err = check_flight(fl, fllen, offered, 1, hash_len) + if ee_alert != 0 { + ferr = ee_err + ee_ctx = tls13_record.new_aead(rec_aead, bytes.view(hs_client_key(keys))[0..rec_key_len], hs_client_iv(keys)) + send_alert_hs(sock, ee_ctx, ALERT_LEVEL_FATAL, ee_alert) + tls13_record.free_ctx(ee_ctx) } } @@ -919,7 +1344,8 @@ fn run_handshake_tail(sock: @keep ptr, rio: @keep ptr, tr: ptr, host: string, sh result_err = ferr if string.equals(ferr, "") == 1 { auth_err = "" - if insecure == 1 { + auth_alert = 0 + if (insecure & 1) != 0 { // set_insecure parity: no chain, no validity window, no hostname // pin. The connection is encrypted but UNAUTHENTICATED, so an // active attacker is undetectable -- exactly as dangerous as the @@ -937,14 +1363,17 @@ fn run_handshake_tail(sock: @keep ptr, rio: @keep ptr, tr: ptr, host: string, sh auth_err = "cannot load trust store: ${tsErr}" } } else { - auth_err = authenticate_server(fl, fllen, th_through_cert, hash_len, host, anchors) + purpose = tls13_cert.PURPOSE_SERVER + if (insecure & 2) != 0 { purpose = purpose | tls13_cert.TRUST_PARTIAL_CHAIN } + auth_alert, auth_err = authenticate_server(fl, fllen, th_through_cert, hash_len, host, anchors, purpose) tls13_cert.free_anchors(anchors) } } if string.equals(auth_err, "") != 1 { result_err = auth_err + if auth_alert == 0 { auth_alert = alert_for_auth_error(auth_err) } alert_ctx = tls13_record.new_aead(rec_aead, bytes.view(hs_client_key(keys))[0..rec_key_len], hs_client_iv(keys)) - send_alert_hs(sock, alert_ctx, ALERT_LEVEL_FATAL, alert_for_auth_error(auth_err)) + send_alert_hs(sock, alert_ctx, ALERT_LEVEL_FATAL, auth_alert) tls13_record.free_ctx(alert_ctx) } else { expected = finished_verify_data_algo(hs_server_secret(keys), bytes.view(th_before_finished)[0..hash_len], hash_algo, hash_len) @@ -972,95 +1401,85 @@ fn run_handshake_tail(sock: @keep ptr, rio: @keep ptr, tr: ptr, host: string, sh // CertificateVerify signing the transcript through the client // Certificate with the client key (§4.4.3, "client" context). // - otherwise: send an EMPTY Certificate (the correct decline). + // Every message of the client's flight is sealed and sent in + // as many records as it takes; `sent` says whether all went + // (#3380). + sent = 1 + cv_refused = "" if flight_has_cert_request(fl, fllen) == 1 { if client_cert != null && client_key != null { ccert = build_client_certificate(bytes.view(client_cert)[0..client_cert_len]) - ccert_rec = tls13_record.seal_record(cli_hs_ctx, tls13_record.CONTENT_HANDSHAKE, bytes.view(ccert)) - cli_send_view(sock, bytes.view(ccert_rec)) + sent = sent & cli_send_sealed(sock, cli_hs_ctx, tls13_record.CONTENT_HANDSHAKE, bytes.view(ccert)) transcript_add(tr, bytes.view(ccert)) // CertificateVerify: sign the transcript-through-client- - // Certificate. ECDSA-P256 only in this cut. + // Certificate. ECDSA-P256 only in this cut, the + // nonce RFC 6979's hedged with fresh bytes, and the + // signature checked against the client's own + // certificate before it is sent (#3376): one that + // does not verify ends the handshake with + // internal_error, and no Finished follows. th_ccv = transcript_hash_algo(tr, hash_algo) - knonce = rand_into(32) - cvsig = tls13_cert.sign_client_cert_verify_ecdsa_p256(client_key, bytes.view(th_ccv)[0..hash_len], knonce) - ccv = build_cert_verify_msg(0x0403, bytes.view(cvsig)) - ccv_rec = tls13_record.seal_record(cli_hs_ctx, tls13_record.CONTENT_HANDSHAKE, bytes.view(ccv)) - cli_send_view(sock, bytes.view(ccv_rec)) - transcript_add(tr, bytes.view(ccv)) - bytes.free(th_ccv); bytes.free(knonce); bytes.free(cvsig) - bytes.free(ccert); bytes.free(ccert_rec); bytes.free(ccv); bytes.free(ccv_rec) + cvsig, cv_why = sign_client_cert_verify_checked(client_cert, client_cert_len, client_key, bytes.view(th_ccv)[0..hash_len]) + if cvsig == null { + send_alert_hs(sock, cli_hs_ctx, ALERT_LEVEL_FATAL, ALERT_INTERNAL_ERROR) + cv_refused = cv_why + sent = 0 + } else { + ccv = build_cert_verify_msg(0x0403, bytes.view(cvsig)) + sent = sent & cli_send_sealed(sock, cli_hs_ctx, tls13_record.CONTENT_HANDSHAKE, bytes.view(ccv)) + transcript_add(tr, bytes.view(ccv)) + bytes.wipe_free(cvsig); bytes.wipe_free(ccv) + } + bytes.wipe_free(th_ccv); bytes.wipe_free(ccert) } else { ccert = build_empty_certificate() - ccert_rec = tls13_record.seal_record(cli_hs_ctx, tls13_record.CONTENT_HANDSHAKE, bytes.view(ccert)) - cli_send_view(sock, bytes.view(ccert_rec)) + sent = sent & cli_send_sealed(sock, cli_hs_ctx, tls13_record.CONTENT_HANDSHAKE, bytes.view(ccert)) transcript_add(tr, bytes.view(ccert)) - bytes.free(ccert); bytes.free(ccert_rec) + bytes.wipe_free(ccert) } } // Client Finished MAC is over the transcript through the client // Certificate (if any) — RFC 8446 §4.4.4. th_cvd = transcript_hash_algo(tr, hash_algo) cvd = finished_verify_data_algo(hs_client_secret(keys), bytes.view(th_cvd)[0..hash_len], hash_algo, hash_len) - bytes.free(th_cvd) + bytes.wipe_free(th_cvd) cfin = build_finished_msg(cvd) - cfin_rec = tls13_record.seal_record(cli_hs_ctx, tls13_record.CONTENT_HANDSHAKE, bytes.view(cfin)) - cli_send_view(sock, bytes.view(cfin_rec)) + if cv_refused == "" { + sent = sent & cli_send_sealed(sock, cli_hs_ctx, tls13_record.CONTENT_HANDSHAKE, bytes.view(cfin)) + } transcript_add(tr, bytes.view(cfin)) th_cf = transcript_hash_algo(tr, hash_algo) res_master = tls13_kdf.derive_secret(hash_algo, bytes.view(ms)[0..hash_len], "res master", bytes.view(th_cf)[0..hash_len]) - bytes.free(th_cf) - ck = tls13_ks.write_key(hash_algo, bytes.view(c_ap)[0..hash_len], rec_key_len) - civ = tls13_ks.write_iv(hash_algo, c_ap) - sk = tls13_ks.write_key(hash_algo, bytes.view(s_ap)[0..hash_len], rec_key_len) - siv = tls13_ks.write_iv(hash_algo, s_ap) - conn = malloc(sizeof(ClientConn)) as *ClientConn - conn.sock = sock - conn.rio = rio as ptr - conn.app_send = tls13_record.new_aead(rec_aead, bytes.view(ck)[0..rec_key_len], civ) as ptr - conn.app_recv = tls13_record.new_aead(rec_aead, bytes.view(sk)[0..rec_key_len], siv) as ptr - conn.res_master = res_master - conn.res_hash_algo = str_to_bytes_local(hash_algo) - conn.res_hash_len = hash_len - conn.group = 0 // connect_full sets it - result = conn as ptr - bytes.free(ck); bytes.free(civ); bytes.free(sk); bytes.free(siv) - bytes.free(cvd); bytes.free(cfin); bytes.free(cfin_rec) + bytes.wipe_free(th_cf) + // The connection keeps c_ap and s_ap for KeyUpdate (#3320); + // connect_full sets its group. + if sent == 1 { + result = new_client_conn(sock, rio as ptr, cipher_suite, c_ap, s_ap, res_master, 0) + } else { + tls13_ks.free_secret(c_ap); tls13_ks.free_secret(s_ap); tls13_ks.free_secret(res_master) + result_err = "handshake: the client's flight could not be sent" + if cv_refused != "" { result_err = "handshake: ${cv_refused}" } + } + bytes.wipe_free(cvd); bytes.wipe_free(cfin) tls13_record.free_ctx(cli_hs_ctx) - bytes.free(ms); bytes.free(c_ap); bytes.free(s_ap); bytes.free(th_sf) + tls13_ks.free_secret(ms); bytes.wipe_free(th_sf) } - bytes.free(expected) + bytes.wipe_free(expected) } } - if th_through_cert != null { bytes.free(th_through_cert) } - if th_before_finished != null { bytes.free(th_before_finished) } - if server_verify_data != null { bytes.free(server_verify_data) } - bytes.free(fl) + if th_through_cert != null { bytes.wipe_free(th_through_cert) } + if th_before_finished != null { bytes.wipe_free(th_before_finished) } + if server_verify_data != null { bytes.wipe_free(server_verify_data) } + bytes.wipe_free(fl) tls13_record.free_ctx(srv_ctx) free_hs_keys(keys) - bytes.free(th_chsh) + bytes.wipe_free(th_chsh) return result, result_err } // ---- connect() helpers ---- -// Stateless: is a complete Finished(20) handshake message present in the -// accumulated flight buffer `fl[0..flen]`? -fn flight_complete(fl: ptr, flen: int) -> int { - p = 0 - cont = 1 - while cont == 1 { - if p + 4 > flen { cont = 0 } else { - ml = ((bytes.get(fl, p + 1) & 0xFF) << 16) | ((bytes.get(fl, p + 2) & 0xFF) << 8) | (bytes.get(fl, p + 3) & 0xFF) - if p + 4 + ml > flen { cont = 0 } else { - if (bytes.get(fl, p) & 0xFF) == HS_FINISHED { return 1 } - p = p + 4 + ml - } - } - } - return 0 -} - // Did the server send a CertificateRequest (type 13) in this flight? That is // the mutual-TLS ask — the client must then send its own Certificate (empty if // it has none) before its Finished (RFC 8446 §4.3.2 / §4.4.2). @@ -1117,7 +1536,7 @@ fn add_flight_capture_prefinished(tr: ptr, fl: ptr, flen: int, hash_algo: string bytes.set(sub, total - 1, 0) bytes.copy_from_bytes(sub, 0, fl, p, total) transcript_add(tr, bytes.view(sub)[0..total]) - bytes.free(sub) + bytes.wipe_free(sub) // Snapshot the hash THROUGH Certificate (used by CertificateVerify). if mt == HS_CERTIFICATE { th_cert = transcript_hash_algo(tr, hash_algo) } p = p + total @@ -1232,6 +1651,30 @@ fn build_client_certificate(cert_der: byte[]) -> ptr { return m } +// The client CertificateVerify signature with `key` (a P-256 scalar) over +// `transcript_hash`, checked against the key of the client's own certificate +// (`cert`, DER) before it is given back (#3376): a signature computed wrongly +// can disclose the key, so one that does not verify is wiped and never sent. +// The signature and "", or null and why. +fn sign_client_cert_verify_checked(cert: ptr, cert_len: int, key: ptr, transcript_hash: byte[]) -> (ptr, string) { + sig = tls13_cert.sign_client_cert_verify_ecdsa_p256(key, transcript_hash) + if sig == null { return null, "the client's private key could not sign (not a P-256 scalar, or no randomness)" } + leaf = tls13_cert.LeafCert { tbs: null } + tls13_cert.init_leaf(&leaf) + perr = tls13_cert.parse_certificate(bytes.view(cert)[0..cert_len], &leaf) + ok = 0 + if string.equals(perr, "") == 1 { + ok = tls13_cert.client_cert_verify_holds(&leaf, transcript_hash, bytes.view(sig)) + } + tls13_cert.free_leaf(&leaf) + if ok != 1 { + bytes.wipe_free(sig) + if string.equals(perr, "") != 1 { return null, "the client's certificate does not parse: ${perr}" } + return null, "the client's signature does not verify with its certificate's key, so it was not sent (a fault while signing, or a key that is not the certificate's)" + } + return sig, "" +} + // A client CertificateVerify message (RFC 8446 §4.4.3): type 15, body = // SignatureScheme(2) || signature<2>. `scheme` is the code point (e.g. 0x0403); // `sig`/`sig_len` is the DER signature. @@ -1287,17 +1730,27 @@ fn hs_handshake_secret(k: ptr) -> ptr { kk = k as *HsKeys; return kk.handshake_s // ---- application data over a completed connection ---- -// Send application data as one encrypted record. +// Send application data, in encrypted records of at most 2^14 bytes each +// (#3380). // // `data` is a std.bytes HANDLE, not bytes.data(b) -- see the module header. // Passing the raw data pointer compiles and segfaults inside seal_record. +// +// A KeyUpdate the server asked for goes first (RFC 8446 4.6.3, #3320). conn_send(conn: ptr, data: byte[]) -> string { if data.len < 0 { panic("tls13_client.conn_send: the `data` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } c = conn as *ClientConn - rec = tls13_record.seal_record(c.app_send as ptr, tls13_record.CONTENT_APPLICATION_DATA, data) + if c.closed == 2 { return "send: the connection failed earlier" } + if c.ku_pending == 1 { + kerr = send_key_update(c, tls13_hs.KEY_UPDATE_NOT_REQUESTED) + if kerr != "" { return kerr } + } + // Data over 2^14 bytes goes in several records (#3380). + rec = tls13_record.seal_records(c.app_send as ptr, tls13_record.CONTENT_APPLICATION_DATA, data) + if rec == null { return "send: the record could not be sealed" } n = cli_send_view(c.sock, bytes.view(rec)) tot = bytes.length(rec) - bytes.free(rec) + bytes.wipe_free(rec) if n != tot { return "send failed" } return "" } @@ -1314,6 +1767,7 @@ const ALERT_LEVEL_FATAL = 2 const ALERT_CLOSE_NOTIFY = 0 const ALERT_UNEXPECTED_MESSAGE = 10 const ALERT_BAD_RECORD_MAC = 20 +const ALERT_RECORD_OVERFLOW = 22 const ALERT_HANDSHAKE_FAILURE = 40 const ALERT_BAD_CERTIFICATE = 42 const ALERT_CERTIFICATE_EXPIRED = 45 @@ -1323,7 +1777,10 @@ const ALERT_DECODE_ERROR = 50 const ALERT_DECRYPT_ERROR = 51 const ALERT_PROTOCOL_VERSION = 70 const ALERT_INTERNAL_ERROR = 80 +const ALERT_UNSUPPORTED_EXTENSION = 110 const ALERT_NO_APPLICATION_PROTOCOL = 120 +const ALERT_USER_CANCELED = 90 +const ALERT_MISSING_EXTENSION = 109 // Seal + send an encrypted TLS alert on an established connection (uses the // client's application write context). Best-effort: a send failure is ignored @@ -1333,22 +1790,240 @@ fn send_alert_conn(c: *ClientConn, level: int, desc: int) -> string { bytes.set(body, 0, level & 0xFF) bytes.set(body, 1, desc & 0xFF) rec = tls13_record.seal_record(c.app_send as ptr, tls13_record.CONTENT_ALERT, bytes.view(body)[0..2]) + bytes.wipe_free(body) + // A context at its sequence limit seals nothing (#3380). + if rec == null { return "alert: the record could not be sealed" } cli_send_view(c.sock, bytes.view(rec)) - bytes.free(body) - bytes.free(rec) + bytes.wipe_free(rec) return "" } // Send a fatal alert during the HANDSHAKE (before app keys exist), using the // given handshake write context. Same wire shape; best-effort. +// A fatal alert in the clear, before any handshake key exists. +fn send_plain_alert(sock: ptr, desc: int) { + b = bytes.new(7) + bytes.set(b, 0, 0x15) + bytes.set(b, 1, 0x03) + bytes.set(b, 2, 0x03) + bytes.set(b, 3, 0x00) + bytes.set(b, 4, 0x02) + bytes.set(b, 5, ALERT_LEVEL_FATAL) + bytes.set(b, 6, desc & 0xFF) + cli_send_view(sock, bytes.view(b)[0..7]) + bytes.wipe_free(b) +} + +// Read the server's encrypted flight (EncryptedExtensions through Finished) +// into `fl` under `srv_ctx`: (its length, "", 0), or (0, why, the alert to +// send). RFC 8446 5 (#3317): one dummy change_cipher_spec (the single +// byte 1) may come before the encrypted records and is dropped, and any +// other, a plaintext record, application data or an empty handshake record +// is unexpected_message; the record that ends with the Finished must end +// there, as a handshake message may not span the key change that follows +// it (5.1). An alert from the server ends the flight with its name. +// +// The flight may come in records of any size, down to one byte each (5.1, +// #3366), so what bounds it is its bytes, not its records: at most +// MAX_FLIGHT_BYTES, each message within flight_scan's limits. Every record +// read either adds a byte, is the one change_cipher_spec or a counted +// warning alert, or ends the flight, so the loop ends. +const MAX_FLIGHT_BYTES = 524288 + +fn read_flight(rio: *RecordIO, srv_ctx: ptr, fl: ptr) -> (int, string, int) { + fllen = 0 + warnings = 0 + while 1 == 1 { + rec, rlen, err = recio_next(rio) + if string.equals(err, "") != 1 { return 0, err, rio.alert } + ct = bytes.get(rec, 0) & 0xFF + if ct == 0x14 { + ok = 0 + // One in the whole handshake: one before the ServerHello counts + // (recio_next_hs, #3379). + if rlen == 6 && (bytes.get(rec, 5) & 0xFF) == 1 && rio.ccs == 0 && fllen == 0 { ok = 1 } + bytes.wipe_free(rec) + if ok == 0 { return 0, "record: a change_cipher_spec other than the one compatibility mode allows", ALERT_UNEXPECTED_MESSAGE } + rio.ccs = 1 + } else if ct != 0x17 { + bytes.wipe_free(rec) + return 0, "record: a plaintext record of type ${ct} inside the encrypted flight", ALERT_UNEXPECTED_MESSAGE + } else { + opened = tls13_record.OpenedRecord { content_type: 0, plaintext: null, plaintext_len: 0, alert: 0 } + oerr = tls13_record.open_record(srv_ctx, bytes.view(rec)[0..rlen], &opened) + bytes.wipe_free(rec) + if string.equals(oerr, "") != 1 { return 0, oerr, opened.alert } + t = opened.content_type + n = opened.plaintext_len + if t == tls13_record.CONTENT_HANDSHAKE && n > 0 { + bytes.copy_from_bytes(fl, fllen, opened.plaintext, 0, n) + fllen = fllen + n + bytes.wipe_free(opened.plaintext) + if fllen > MAX_FLIGHT_BYTES { return 0, "handshake: the server's flight is over ${MAX_FLIGHT_BYTES} bytes", ALERT_UNEXPECTED_MESSAGE } + fin_end, salert, swhy = flight_scan(fl, fllen) + if salert != 0 { return 0, swhy, salert } + if fin_end >= 0 { + if fin_end != fllen { return 0, "handshake: data after the server Finished in its record", ALERT_UNEXPECTED_MESSAGE } + return fllen, "", 0 + } + } else if t == tls13_record.CONTENT_ALERT { + // As after the handshake (alert_event, #3323): a warning + // user_canceled is read past, a close_notify here ends the + // handshake unfinished, any other alert is an error. + level = -1 + d = 0 + if n == 2 { + level = bytes.get(opened.plaintext, 0) & 0xFF + d = bytes.get(opened.plaintext, 1) & 0xFF + } + if opened.plaintext != null { bytes.wipe_free(opened.plaintext) } + if level < 0 { return 0, "alert: a record that is not two bytes", ALERT_DECODE_ERROR } + aev, aal, awhy = alert_event(level, d, warnings) + if aev == EV_CLOSED { return 0, "the server closed the connection during the handshake", 0 } + if aev == EV_ERROR { + if aal == 0 { return 0, "the server sent alert ${alert_name(d)}", 0 } + return 0, awhy, aal + } + warnings = warnings + 1 + } else { + if opened.plaintext != null { bytes.wipe_free(opened.plaintext) } + return 0, "handshake: a record of type ${t} (or an empty one) inside the server's flight", ALERT_UNEXPECTED_MESSAGE + } + } + } +} + +// The largest message the server's flight may hold, and the largest +// Certificate (a chain of several large certificates). +const MAX_FLIGHT_MESSAGE = 65536 +const MAX_FLIGHT_CERTIFICATE = 262144 + +// The messages of the server's flight so far, fl[0, flen): (the offset just +// past the first complete Finished, or -1 when there is none yet, 0, ""); +// or (-1, the alert, why), as soon as a message's header is in, for a +// message whose header says more bytes than its kind may have (#3366), or +// of a type the flight cannot hold (unexpected_message, RFC 8446 6.2, +// #3367): only EncryptedExtensions, CertificateRequest, Certificate, +// CertificateVerify and Finished belong there, so waiting for a Finished +// past any other would wait for a message that is not coming. Their order +// is check_flight's. +fn flight_scan(fl: ptr, flen: int) -> (int, int, string) { + p = 0 + while p + 4 <= flen { + mt = bytes.get(fl, p) & 0xFF + ml = ((bytes.get(fl, p + 1) & 0xFF) << 16) | ((bytes.get(fl, p + 2) & 0xFF) << 8) | (bytes.get(fl, p + 3) & 0xFF) + if mt != HS_ENCRYPTED_EXTENSIONS && mt != HS_CERTIFICATE_REQUEST && mt != HS_CERTIFICATE && mt != HS_CERTIFICATE_VERIFY && mt != HS_FINISHED { + return -1, ALERT_UNEXPECTED_MESSAGE, "handshake: a message of type ${mt} in the server's flight" + } + limit = MAX_FLIGHT_MESSAGE + if mt == HS_CERTIFICATE { limit = MAX_FLIGHT_CERTIFICATE } + if ml > limit { return -1, ALERT_UNEXPECTED_MESSAGE, "handshake: a message of type ${mt} and ${ml} bytes in the server's flight, over ${limit}" } + if p + 4 + ml > flen { return -1, 0, "" } + if mt == HS_FINISHED { return p + 4 + ml, 0, "" } + p = p + 4 + ml + } + return -1, 0, "" +} + +// The server flight's EncryptedExtensions, which must come first, against +// what the ClientHello offered (RFC 8446 4.2, #3315): (0, "") or the alert +// and why. The one protocol ALPN may name is the one offered, http/1.1. +fn check_flight_ee(fl: ptr, flen: int, offered: int) -> (int, string) { + if flen < 4 || (bytes.get(fl, 0) & 0xFF) != HS_ENCRYPTED_EXTENSIONS { + return ALERT_UNEXPECTED_MESSAGE, "the server's encrypted flight does not start with EncryptedExtensions" + } + ml = ((bytes.get(fl, 1) & 0xFF) << 16) | ((bytes.get(fl, 2) & 0xFF) << 8) | (bytes.get(fl, 3) & 0xFF) + if 4 + ml > flen || ml < 2 { + return ALERT_DECODE_ERROR, "encrypted extensions: truncated" + } + el = ((bytes.get(fl, 4) & 0xFF) << 8) | (bytes.get(fl, 5) & 0xFF) + if 2 + el != ml { + return ALERT_DECODE_ERROR, "encrypted extensions: the extensions do not fill the message" + } + a, err = tls13_hs.check_extensions(tls13_hs.MSG_ENCRYPTED_EXTENSIONS, bytes.view(fl)[6..6 + el], offered) + if a != 0 { return a, "encrypted extensions: ${err}" } + p = 6 + while p + 4 <= 6 + el { + t = ((bytes.get(fl, p) & 0xFF) << 8) | (bytes.get(fl, p + 1) & 0xFF) + n = ((bytes.get(fl, p + 2) & 0xFF) << 8) | (bytes.get(fl, p + 3) & 0xFF) + if t == 16 && alpn_is_http11(fl, p + 7, n - 3) == 0 { + return ALERT_ILLEGAL_PARAMETER, "encrypted extensions: ALPN names a protocol the client did not offer" + } + p = p + 4 + n + } + return 0, "" +} + +// The server's encrypted flight, message by message, in the order RFC 8446 +// 2 gives it: EncryptedExtensions; then, in a full handshake (`full` 1), an +// optional CertificateRequest, the Certificate and the CertificateVerify; +// then the Finished, which read_flight made sure ends the flight. (0, "") +// or the alert and why: unexpected_message for a message out of its place +// (#3324); decode_error for one that does not fill its body exactly, a +// CertificateVerify whose signature leaves bytes over (#3324); +// decrypt_error for a Finished whose verify_data is not `hash_len` long +// (RFC 8446 4.4.4: the Finished is wrong, #3357); and what each message's own +// checks say: the extensions answer only what the ClientHello `offered` +// (#3315), and so do the Certificate's entries (#3316). +fn check_flight(fl: ptr, flen: int, offered: int, full: int, hash_len: int) -> (int, string) { + a, err = check_flight_ee(fl, flen, offered) + if a != 0 { return a, err } + p = 4 + (((bytes.get(fl, 1) & 0xFF) << 16) | ((bytes.get(fl, 2) & 0xFF) << 8) | (bytes.get(fl, 3) & 0xFF)) + // What came last: 0 EncryptedExtensions, 1 CertificateRequest, + // 2 Certificate, 3 CertificateVerify, 4 Finished. + last = 0 + while p < flen { + if p + 4 > flen { return ALERT_DECODE_ERROR, "the server's flight is truncated" } + mt = bytes.get(fl, p) & 0xFF + ml = ((bytes.get(fl, p + 1) & 0xFF) << 16) | ((bytes.get(fl, p + 2) & 0xFF) << 8) | (bytes.get(fl, p + 3) & 0xFF) + if p + 4 + ml > flen { return ALERT_DECODE_ERROR, "the server's flight is truncated" } + if mt == HS_CERTIFICATE_REQUEST && full == 1 && last == 0 { + a, err = tls13_hs.check_certificate_request(bytes.view(fl)[p + 4..p + 4 + ml]) + last = 1 + } else if mt == HS_CERTIFICATE && full == 1 && last <= 1 { + a, err = tls13_hs.check_certificate(bytes.view(fl)[p + 4..p + 4 + ml], offered) + last = 2 + } else if mt == HS_CERTIFICATE_VERIFY && last == 2 { + // algorithm(2) || signature<2>, nothing after. + if ml < 4 || 4 + (((bytes.get(fl, p + 6) & 0xFF) << 8) | (bytes.get(fl, p + 7) & 0xFF)) != ml { + return ALERT_DECODE_ERROR, "certificate verify: the signature does not fill the message" + } + last = 3 + } else if mt == HS_FINISHED && (last == 3 || (full == 0 && last == 0)) { + if ml != hash_len { return ALERT_DECRYPT_ERROR, "finished: verify_data is ${ml} bytes, not ${hash_len}" } + last = 4 + } else { + return ALERT_UNEXPECTED_MESSAGE, "handshake: a message of type ${mt} out of its place in the server's flight" + } + if a != 0 { return a, err } + p = p + 4 + ml + } + if last != 4 { return ALERT_UNEXPECTED_MESSAGE, "handshake: the server's flight does not end with its Finished" } + return 0, "" +} + +// 1 when fl[off, off + n) is "http/1.1". +fn alpn_is_http11(fl: ptr, off: int, n: int) -> int { + want = "http/1.1" + if n != 8 { return 0 } + i = 0 + while i < 8 { + if (bytes.get(fl, off + i) & 0xFF) != (string_char_at_n(want, 8, i) & 0xFF) { return 0 } + i = i + 1 + } + return 1 +} + fn send_alert_hs(sock: ptr, hs_ctx: ptr, level: int, desc: int) { body = bytes.new(2) bytes.set(body, 0, level & 0xFF) bytes.set(body, 1, desc & 0xFF) rec = tls13_record.seal_record(hs_ctx, tls13_record.CONTENT_ALERT, bytes.view(body)[0..2]) + bytes.wipe_free(body) + // A context at its sequence limit seals nothing (#3380). + if rec == null { return } cli_send_view(sock, bytes.view(rec)) - bytes.free(body) - bytes.free(rec) + bytes.wipe_free(rec) } // Choose the fatal AlertDescription that best fits a server-authentication @@ -1380,66 +2055,287 @@ fn alert_name(desc: int) -> string { if desc == ALERT_PROTOCOL_VERSION { return "protocol_version" } if desc == ALERT_INTERNAL_ERROR { return "internal_error" } if desc == ALERT_NO_APPLICATION_PROTOCOL { return "no_application_protocol" } + if desc == ALERT_UNSUPPORTED_EXTENSION { return "unsupported_extension" } + if desc == ALERT_USER_CANCELED { return "user_canceled" } + if desc == ALERT_MISSING_EXTENSION { return "missing_extension" } + if desc == ALERT_RECORD_OVERFLOW { return "record_overflow" } return "unknown_alert" } +// Empty application-data records the client reads past in a row before it +// refuses the stream as a denial of service (#3317), as BoringSSL bounds them. +const MAX_EMPTY_RECORDS = 32 + +// KeyUpdates (#3320) and user_canceled alerts (#3323) the client reads +// past without application data between them before it refuses the stream, +// the bounds BoringSSL keeps. +const MAX_KEY_UPDATES = 32 +const MAX_WARNING_ALERTS = 4 + +// The longest NewSessionTicket body: lifetime, age_add, nonce<0..255>, +// ticket<1..2^16-1>, extensions<0..2^16-1>. +const MAX_TICKET_BODY = 131338 + +// What one record after the handshake brought (conn_next). +const EV_NONE = 0 // nothing for the caller: an empty record, a KeyUpdate, a user_canceled alert +const EV_DATA = 1 // application data +const EV_CLOSED = 2 // the server's close_notify +const EV_TICKET = 3 // a NewSessionTicket, now in c.ticket +const EV_ERROR = 4 // the connection failed; its alert, if any, was sent + +// A fatal alert under the connection's application-traffic keys. +fn send_alert_app(c: *ClientConn, desc: int) { + send_alert_hs(c.sock, c.app_send, ALERT_LEVEL_FATAL, desc) +} + +// Send fatal alert `desc` and fail with `why`, as conn_next returns it. +fn conn_fail(c: *ClientConn, desc: int, why: string) -> (int, ptr, int, string) { + send_alert_app(c, desc) + return EV_ERROR, null, 0, why +} + +// What an alert record (level, description) after the handshake means +// (RFC 8446 6, #3323): (event, alert to send, why). A warning close_notify +// closes the stream. A warning user_canceled is read past, as BoringSSL, +// OpenSSL and NSS do (the close_notify that should follow ends the stream), +// at most MAX_WARNING_ALERTS in a row. Every other alert is an error +// whatever its level claims, and TLS 1.3 has no other warning: one at +// warning level is answered with decode_error, as BoringSSL does. A level +// that is neither is illegal_parameter. A fatal alert ends the connection +// with its name and no answer. +alert_event(level: int, desc: int, warnings: int) -> (int, int, string) { + if level == ALERT_LEVEL_WARNING { + if desc == ALERT_CLOSE_NOTIFY { return EV_CLOSED, 0, "" } + if desc == ALERT_USER_CANCELED { + if warnings + 1 > MAX_WARNING_ALERTS { return EV_ERROR, ALERT_UNEXPECTED_MESSAGE, "alert: more than ${MAX_WARNING_ALERTS} user_canceled alerts in a row" } + return EV_NONE, 0, "" + } + return EV_ERROR, ALERT_DECODE_ERROR, "alert: ${alert_name(desc)} (${desc}) as a non-fatal alert, which TLS 1.3 does not have" + } + if level == ALERT_LEVEL_FATAL { + return EV_ERROR, 0, string.concat("tls alert: ", alert_name(desc)) + } + return EV_ERROR, ALERT_ILLEGAL_PARAMETER, "alert: level ${level} is neither 1 nor 2" +} + +// Read one record after the handshake and act on it: (event, data, length, +// why), the data fresh for EV_DATA. RFC 8446 5 (#3317): a change_cipher_spec +// or a plaintext record, a record that does not open or is too long, an +// empty handshake record, and more than MAX_EMPTY_RECORDS empty +// application-data records in a row are refused, and so is a record of +// another type inside a handshake message that spans records (5.1). Alerts +// as alert_event says (#3323). Handshake messages as conn_handshake says. +// Every refusal sends its alert before it returns. +fn conn_next(c: *ClientConn) -> (int, ptr, int, string) { + rio = c.rio as *RecordIO + rec, rlen, e = recio_next(rio) + if string.equals(e, "") != 1 { + if rio.alert != 0 { send_alert_app(c, rio.alert) } + return EV_ERROR, null, 0, e + } + ct = bytes.get(rec, 0) & 0xFF + if ct != 0x17 { + bytes.wipe_free(rec) + return conn_fail(c, ALERT_UNEXPECTED_MESSAGE, "record: a plaintext record of type ${ct} after the handshake") + } + opened = tls13_record.OpenedRecord { content_type: 0, plaintext: null, plaintext_len: 0, alert: 0 } + oerr = tls13_record.open_record(c.app_recv as ptr, bytes.view(rec)[0..rlen], &opened) + bytes.wipe_free(rec) + if string.equals(oerr, "") != 1 { + return conn_fail(c, opened.alert, oerr) + } + t = opened.content_type + n = opened.plaintext_len + if t != tls13_record.CONTENT_HANDSHAKE && c.hs_pend_len > 0 { + if opened.plaintext != null { bytes.wipe_free(opened.plaintext) } + return conn_fail(c, ALERT_UNEXPECTED_MESSAGE, "record: a record of type ${t} inside a handshake message") + } + if t == tls13_record.CONTENT_APPLICATION_DATA { + if n > 0 { + c.empties = 0 + c.key_updates = 0 + c.warnings = 0 + return EV_DATA, opened.plaintext, n, "" + } + if opened.plaintext != null { bytes.wipe_free(opened.plaintext) } + c.empties = c.empties + 1 + if c.empties > MAX_EMPTY_RECORDS { + return conn_fail(c, ALERT_UNEXPECTED_MESSAGE, "record: more than ${MAX_EMPTY_RECORDS} empty records in a row") + } + return EV_NONE, null, 0, "" + } + if t == tls13_record.CONTENT_ALERT { + level = -1 + desc = 0 + if n == 2 { + level = bytes.get(opened.plaintext, 0) & 0xFF + desc = bytes.get(opened.plaintext, 1) & 0xFF + } + if opened.plaintext != null { bytes.wipe_free(opened.plaintext) } + if level < 0 { return conn_fail(c, ALERT_DECODE_ERROR, "alert: a record that is not two bytes") } + ev, a, why = alert_event(level, desc, c.warnings) + if ev == EV_NONE { c.warnings = c.warnings + 1 } + if a != 0 { send_alert_app(c, a) } + return ev, null, 0, why + } + // A handshake record: an empty one is no message at all. + if n == 0 { + if opened.plaintext != null { bytes.wipe_free(opened.plaintext) } + return conn_fail(c, ALERT_UNEXPECTED_MESSAGE, "record: an empty handshake record after the handshake") + } + if c.hs_pend == null { + c.hs_pend = bytes.new(n) + c.hs_pend_len = 0 + } + bytes.copy_from_bytes(c.hs_pend, c.hs_pend_len, opened.plaintext, 0, n) + c.hs_pend_len = c.hs_pend_len + n + bytes.wipe_free(opened.plaintext) + return conn_handshake(c) +} + +// Drop the first `k` bytes of the pending handshake bytes. +fn hs_pend_consume(c: *ClientConn, k: int) { + rest = c.hs_pend_len - k + if rest <= 0 { + bytes.wipe_free(c.hs_pend) + c.hs_pend = null + c.hs_pend_len = 0 + return + } + nb = bytes.new(rest) + bytes.copy_from_bytes(nb, 0, c.hs_pend, k, rest) + bytes.wipe_free(c.hs_pend) + c.hs_pend = nb + c.hs_pend_len = rest +} + +// Act on every whole handshake message in c.hs_pend (RFC 8446 4.6): a +// NewSessionTicket checked and kept in c.ticket (#3319), a KeyUpdate that +// moves the receiving keys on and, when it asks, has the next send update +// the sending keys first (#3320). A KeyUpdate must end its record (5.1), +// and at most MAX_KEY_UPDATES may come without application data. Any other +// message is unexpected_message: this client offers no post-handshake +// authentication. A message is judged by its header as soon as that is +// in, so a bogus length never grows the buffer past a ticket's. +fn conn_handshake(c: *ClientConn) -> (int, ptr, int, string) { + ev = EV_NONE + while c.hs_pend_len >= 4 { + mt = bytes.get(c.hs_pend, 0) & 0xFF + ml = ((bytes.get(c.hs_pend, 1) & 0xFF) << 16) | ((bytes.get(c.hs_pend, 2) & 0xFF) << 8) | (bytes.get(c.hs_pend, 3) & 0xFF) + if mt == tls13_hs.HS_KEY_UPDATE { + if ml != 1 { return conn_fail(c, ALERT_DECODE_ERROR, "key update: the body is not one byte") } + } else if mt == tls13_hs.HS_NEW_SESSION_TICKET { + if ml > MAX_TICKET_BODY { return conn_fail(c, ALERT_DECODE_ERROR, "new session ticket: longer than a ticket can be") } + } else { + return conn_fail(c, ALERT_UNEXPECTED_MESSAGE, "handshake: a message of type ${mt} after the handshake") + } + if c.hs_pend_len < 4 + ml { return ev, null, 0, "" } + if mt == tls13_hs.HS_KEY_UPDATE { + req, ka, kerr = tls13_hs.key_update_request(bytes.view(c.hs_pend)[4..5]) + if ka != 0 { return conn_fail(c, ka, kerr) } + if c.hs_pend_len != 5 { return conn_fail(c, ALERT_UNEXPECTED_MESSAGE, "handshake: data after a KeyUpdate in its record") } + c.key_updates = c.key_updates + 1 + if c.key_updates > MAX_KEY_UPDATES { return conn_fail(c, ALERT_UNEXPECTED_MESSAGE, "key update: more than ${MAX_KEY_UPDATES} without application data") } + _a, _k, algo, _h, _e = suite_params(c.suite) + c.recv_secret = tls13_ks.key_update(algo, c.app_recv, c.recv_secret) + if req == tls13_hs.KEY_UPDATE_REQUESTED { c.ku_pending = 1 } + } else { + st, na, nerr = ticket_from_nst(c, bytes.view(c.hs_pend)[4..4 + ml]) + if na != 0 { return conn_fail(c, na, nerr) } + if c.ticket != null { free_ticket(c.ticket) } + c.ticket = st + ev = EV_TICKET + } + hs_pend_consume(c, 4 + ml) + } + return ev, null, 0, "" +} + +// Send a KeyUpdate asking (`request` 1) or not (0) for the server's, under +// the current sending keys, then move them on (RFC 8446 4.6.3, 7.2). +fn send_key_update(c: *ClientConn, request: int) -> string { + m = tls13_hs.key_update_msg(request) + rec = tls13_record.seal_record(c.app_send as ptr, tls13_record.CONTENT_HANDSHAKE, bytes.view(m)[0..5]) + bytes.wipe_free(m) + if rec == null { return "key update: the record could not be sealed" } + n = cli_send_view(c.sock, bytes.view(rec)) + tot = bytes.length(rec) + bytes.wipe_free(rec) + if n != tot { return "key update: send failed" } + _a, _k, algo, _h, _e = suite_params(c.suite) + c.send_secret = tls13_ks.key_update(algo, c.app_send, c.send_secret) + c.ku_pending = 0 + return "" +} + +// conn_key_update(conn, request) -> "" or why: update the connection's +// sending keys now with a KeyUpdate (RFC 8446 4.6.3, #3320); `request` +// tls13_hs.KEY_UPDATE_REQUESTED (1) asks the server to update its own, +// which the next conn_recv takes in its stride, KEY_UPDATE_NOT_REQUESTED +// (0) does not. A long-lived connection calls it before a key has +// protected too much data (5.5). +conn_key_update(conn: ptr, request: int) -> string { + c = conn as *ClientConn + if request != tls13_hs.KEY_UPDATE_NOT_REQUESTED && request != tls13_hs.KEY_UPDATE_REQUESTED { + return "key update: request ${request} is neither KEY_UPDATE_NOT_REQUESTED nor KEY_UPDATE_REQUESTED" + } + if c.closed != 0 { return "key update: the connection is closed" } + return send_key_update(c, request) +} + // Receive the next application-data record's plaintext (fresh bytes; caller -// frees via bytes.free). Skips post-handshake handshake records (e.g. session -// tickets, KeyUpdate is not handled in the subset). Returns (plaintext, len, ""). +// frees via bytes.free): (data, len, ""), (null, 0, "") at the server's +// close_notify, or (null, 0, error). Session tickets are kept for +// conn_get_ticket, KeyUpdates acted on, and what conn_next refuses ends +// the connection with its alert sent. conn_recv(conn: ptr) -> (ptr, int, string) { c = conn as *ClientConn - rio = c.rio as *RecordIO - looking = 1 - out = null - outlen = 0 - rerr = "" - while looking == 1 { - rec, rlen, e = recio_next(rio) - if string.equals(e, "") != 1 { rerr = e; looking = 0 } else { - ct = bytes.get(rec, 0) & 0xFF - if ct == 0x14 { bytes.free(rec) } else { - opened = tls13_record.OpenedRecord { content_type: 0, plaintext: null, plaintext_len: 0 } - oerr = tls13_record.open_record(c.app_recv as ptr, bytes.view(rec)[0..rlen], &opened) - bytes.free(rec) - if string.equals(oerr, "") != 1 { rerr = oerr; looking = 0 } else { - if opened.content_type == tls13_record.CONTENT_APPLICATION_DATA { - out = opened.plaintext - outlen = opened.plaintext_len - looking = 0 - } else { - if opened.content_type == tls13_record.CONTENT_ALERT { - // An inbound alert: surface it as a typed result. A - // clean close_notify is EOF (rerr stays ""); any - // other alert is an error naming the description. - desc = 0 - if opened.plaintext_len >= 2 { desc = bytes.get(opened.plaintext, 1) & 0xFF } - if desc != ALERT_CLOSE_NOTIFY { - rerr = string.concat("tls alert: ", alert_name(desc)) - } - looking = 0 - } - // else: post-handshake handshake msg (e.g. session - // ticket) — skip and keep reading. - if opened.plaintext != null { bytes.free(opened.plaintext) } - } - } - } + if c.held != null { + d = c.held + dn = c.held_len + c.held = null + c.held_len = 0 + return d, dn, "" + } + if c.closed == 1 { return null, 0, "" } + if c.closed == 2 { return null, 0, "recv: the connection failed earlier" } + while 1 == 1 { + ev, data, n, err = conn_next(c) + if ev == EV_DATA { return data, n, "" } + if ev == EV_CLOSED { + c.closed = 1 + return null, 0, "" + } + if ev == EV_ERROR { + c.closed = 2 + return null, 0, err } } - return out, outlen, rerr } close_conn(conn: ptr) { c = conn as *ClientConn // Graceful close: send an encrypted close_notify so the peer sees an // orderly shutdown rather than a bare TCP RST (RFC 8446 §6.1). - send_alert_conn(c, ALERT_LEVEL_WARNING, ALERT_CLOSE_NOTIFY) + if c.closed != 2 { send_alert_conn(c, ALERT_LEVEL_WARNING, ALERT_CLOSE_NOTIFY) } tls13_record.free_ctx(c.app_send as ptr) tls13_record.free_ctx(c.app_recv as ptr) - if c.res_master != null { bytes.free(c.res_master) } - if c.res_hash_algo != null { bytes.free(c.res_hash_algo) } + tls13_ks.free_secret(c.send_secret) + tls13_ks.free_secret(c.recv_secret) + if c.res_master != null { tls13_ks.free_secret(c.res_master) } + if c.res_hash_algo != null { bytes.wipe_free(c.res_hash_algo) } + if c.hs_pend != null { bytes.wipe_free(c.hs_pend) } + if c.held != null { bytes.wipe_free(c.held) } + if c.ticket != null { free_ticket(c.ticket) } recio_free(c.rio as *RecordIO) - tcp.close(c.sock) + // A connection that failed sent its alert (EV_ERROR), and may hold what + // the server sent after (a record too long to read): drained first, so + // the alert is not lost to a reset, as a failed handshake's is not. + if c.closed == 2 { + tcp.close_drained(c.sock) + } else { + tcp.close(c.sock) + } libc_free(conn) } @@ -1456,122 +2352,93 @@ struct SessionTicket { hash_algo: ptr // hash name bytes ("sha256"/"sha384") hash_len: int max_early_data: int // early_data ext's max_early_data_size (0 = no 0-RTT) -} - -// Read post-handshake records until a NewSessionTicket (RFC 8446 §4.6.1) -// arrives, and parse it into a SessionTicket carrying this connection's -// resumption secret. Returns (ticket, "") or (null, err). The caller frees the -// ticket with free_ticket. Servers send tickets right after the handshake, so -// call this before the first conn_send if you want to capture one. + cipher_suite: int // the suite of the connection that got it, which 0-RTT data must use (#3339) +} + +// The application data conn_get_ticket keeps for conn_recv while it waits +// for a ticket, at most (#3356): a server sends its tickets right after the +// handshake. +const MAX_HELD_FOR_TICKET = 262144 + +// The latest NewSessionTicket (RFC 8446 4.6.1) as a SessionTicket carrying +// this connection's resumption secret and cipher suite: (ticket, "") or +// (null, err). One that came already is taken at once; else records are +// read until one comes, any application data meanwhile kept for the next +// conn_recv. The caller frees the ticket with free_ticket. Servers send +// tickets right after the handshake. +// Past MAX_HELD_FOR_TICKET bytes of data it gives up, the connection still +// open and the data still for conn_recv. conn_get_ticket(conn: ptr) -> (ptr, string) { c = conn as *ClientConn - rio = c.rio as *RecordIO - looking = 1 - out = null - err = "" - while looking == 1 { - rec, rlen, e = recio_next(rio) - if string.equals(e, "") != 1 { err = e; looking = 0 } else { - ct = bytes.get(rec, 0) & 0xFF - if ct == 0x14 { bytes.free(rec) } else { - opened = tls13_record.OpenedRecord { content_type: 0, plaintext: null, plaintext_len: 0 } - oerr = tls13_record.open_record(c.app_recv as ptr, bytes.view(rec)[0..rlen], &opened) - bytes.free(rec) - if string.equals(oerr, "") != 1 { err = oerr; looking = 0 } else { - if opened.content_type == tls13_record.CONTENT_HANDSHAKE { - // May contain one or more handshake messages; find a NST (type 4). - t = find_and_parse_nst(bytes.view(opened.plaintext)[0..opened.plaintext_len], c) - if t != null { out = t; looking = 0 } - } - if opened.plaintext != null { bytes.free(opened.plaintext) } - } + if c.ticket != null { + t = c.ticket + c.ticket = null + return t, "" + } + if c.closed == 1 { return null, "the server closed the connection before sending a NewSessionTicket" } + if c.closed == 2 { return null, "the connection failed earlier" } + while 1 == 1 { + ev, data, n, err = conn_next(c) + if ev == EV_TICKET { + t2 = c.ticket + c.ticket = null + return t2, "" + } + if ev == EV_DATA { + // Kept for the next conn_recv, after what is kept already. + if c.held == null { + c.held = data + c.held_len = n + } else { + bytes.copy_from_bytes(c.held, c.held_len, data, 0, n) + c.held_len = c.held_len + n + bytes.wipe_free(data) } + // A server that streams this much first is not sending a + // ticket now; keeping more would grow without bound. + if c.held_len >= MAX_HELD_FOR_TICKET { return null, "no NewSessionTicket within the first ${MAX_HELD_FOR_TICKET} bytes of application data (kept for conn_recv)" } } - } - return out, err -} - -// Scan a decrypted handshake fragment for a NewSessionTicket (msg type 4) and -// parse the first one into a SessionTicket. Returns null if none present. -fn find_and_parse_nst(buf: byte[], c: *ClientConn) -> ptr { - if buf.len < 0 { panic("tls13_client.find_and_parse_nst: the `buf` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } - int len = buf.len - p = 0 - while p + 4 <= len { - mt = buf[p] & 0xFF - ml = ((buf[p + 1] & 0xFF) << 16) | ((buf[p + 2] & 0xFF) << 8) | (buf[p + 3] & 0xFF) - if p + 4 + ml > len { return null } - if mt == 4 { - return parse_nst_body(buf, p + 4, ml, c) + if ev == EV_CLOSED { + c.closed = 1 + return null, "the server closed the connection before sending a NewSessionTicket" } - p = p + 4 + ml - } - return null -} - -// Parse a NewSessionTicket body (RFC 8446 §4.6.1): -// ticket_lifetime(4) ticket_age_add(4) ticket_nonce<1> ticket<2> extensions<2> -fn parse_nst_body(buf: byte[], off: int, body_len: int, c: *ClientConn) -> ptr { - p = off - end = off + body_len - if p + 8 > end { return null } - lifetime = ((buf[p] & 0xFF) << 24) | ((buf[p + 1] & 0xFF) << 16) | ((buf[p + 2] & 0xFF) << 8) | (buf[p + 3] & 0xFF) - p = p + 4 - age_add = ((buf[p] & 0xFF) << 24) | ((buf[p + 1] & 0xFF) << 16) | ((buf[p + 2] & 0xFF) << 8) | (buf[p + 3] & 0xFF) - p = p + 4 - if p + 1 > end { return null } - nonce_len = buf[p] & 0xFF - p = p + 1 - if p + nonce_len > end { return null } - nonce = bytes.new(nonce_len) - if nonce_len > 0 { bytes.set(nonce, nonce_len - 1, 0); bytes.copy_from_slice(nonce, 0, buf[p..p + nonce_len]) } - p = p + nonce_len - if p + 2 > end { return null } - tk_len = ((buf[p] & 0xFF) << 8) | (buf[p + 1] & 0xFF) - p = p + 2 - if p + tk_len > end { bytes.free(nonce); return null } - ticket = bytes.new(tk_len) - if tk_len > 0 { bytes.set(ticket, tk_len - 1, 0); bytes.copy_from_slice(ticket, 0, buf[p..p + tk_len]) } - p = p + tk_len - // extensions<2>: scan for early_data (42) -> max_early_data_size (uint32). - max_early = 0 - if p + 2 <= end { - ext_total = ((buf[p] & 0xFF) << 8) | (buf[p + 1] & 0xFF) - p = p + 2 - ext_end = p + ext_total - if ext_end > end { ext_end = end } - while p + 4 <= ext_end { - et = ((buf[p] & 0xFF) << 8) | (buf[p + 1] & 0xFF) - el = ((buf[p + 2] & 0xFF) << 8) | (buf[p + 3] & 0xFF) - p = p + 4 - if et == 42 { - if el >= 4 && p + 4 <= ext_end { - max_early = ((buf[p] & 0xFF) << 24) | ((buf[p + 1] & 0xFF) << 16) | ((buf[p + 2] & 0xFF) << 8) | (buf[p + 3] & 0xFF) - } - } - p = p + el + if ev == EV_ERROR { + c.closed = 2 + return null, err } } +} +// A NewSessionTicket body checked (tls13_hs.parse_new_session_ticket, +// #3319) and made a SessionTicket with this connection's resumption secret, +// hash and cipher suite: (ticket, 0, "") or (null, alert, why). +fn ticket_from_nst(c: *ClientConn, body: byte[]) -> (ptr, int, string) { + np = tls13_hs.NstParsed { lifetime: 0, age_add: 0, nonce_off: 0, nonce_len: 0, ticket_off: 0, ticket_len: 0, max_early_data: 0, alert: 0 } + perr = tls13_hs.parse_new_session_ticket(body, &np) + if perr != "" { return null, np.alert, perr } + nonce = bytes.new(np.nonce_len) + if np.nonce_len > 0 { bytes.copy_from_slice(nonce, 0, body[np.nonce_off..np.nonce_off + np.nonce_len]) } + ticket = bytes.new(np.ticket_len) + bytes.copy_from_slice(ticket, 0, body[np.ticket_off..np.ticket_off + np.ticket_len]) st = malloc(sizeof(SessionTicket)) as *SessionTicket st.ticket = ticket - st.ticket_len = tk_len + st.ticket_len = np.ticket_len st.nonce = nonce - st.nonce_len = nonce_len - st.age_add = age_add - st.lifetime = lifetime - // Copy the connection's resumption secret + hash context into the ticket. + st.nonce_len = np.nonce_len + st.age_add = np.age_add + st.lifetime = np.lifetime hl = c.res_hash_len rm = bytes.new(hl) - if hl > 0 { bytes.set(rm, hl - 1, 0); bytes.copy_from_bytes(rm, 0, c.res_master, 0, hl) } + if hl > 0 { bytes.copy_from_bytes(rm, 0, c.res_master, 0, hl) } st.res_master = rm ha_len = bytes.length(c.res_hash_algo) ha = bytes.new(ha_len) - if ha_len > 0 { bytes.set(ha, ha_len - 1, 0); bytes.copy_from_bytes(ha, 0, c.res_hash_algo, 0, ha_len) } + if ha_len > 0 { bytes.copy_from_bytes(ha, 0, c.res_hash_algo, 0, ha_len) } st.hash_algo = ha st.hash_len = hl - st.max_early_data = max_early - return st as ptr + st.max_early_data = np.max_early_data + st.cipher_suite = c.suite + return st as ptr, 0, "" } // Does this ticket permit 0-RTT early data (server advertised early_data in the @@ -1582,6 +2449,19 @@ ticket_max_early_data(t: ptr) -> int { st = t as *SessionTicket; return st.max_e ticket_len(t: ptr) -> int { st = t as *SessionTicket; return st.ticket_len } ticket_nonce_len(t: ptr) -> int { st = t as *SessionTicket; return st.nonce_len } ticket_lifetime(t: ptr) -> int { st = t as *SessionTicket; return st.lifetime } +// The cipher suite of the connection the ticket came on, which 0-RTT data +// sent with it uses (RFC 8446 4.2.10, #3339). +ticket_cipher_suite(t: ptr) -> int { st = t as *SessionTicket; return st.cipher_suite } + +// 1 when the server accepted the 0-RTT data connect_resume_0rtt sent, so it +// was delivered; 0 when it was not (no 0-RTT offered, the server refused it +// or asked for a HelloRetryRequest), and the caller sends it again over the +// connection if it still wants it delivered (#3336). +conn_early_accepted(conn: ptr) -> int { + if conn == null { return 0 } + c = conn as *ClientConn + return c.early_accepted +} // Resume a TLS 1.3 session with a previously captured NewSessionTicket // (RFC 8446 §2.2). Sends a ClientHello carrying the PSK (from the ticket) with @@ -1617,6 +2497,14 @@ connect_resume_0rtt(host: string, port: int, x25519_priv: ptr, ticket: ptr, earl // Core resume, optionally with 0-RTT early data (early_data == null / len 0 for // a plain resume). +// +// The PSK's checks (RFC 8446 4.2.11, #3318): the server must take the one +// identity offered, with a suite of the ticket's hash. 0-RTT data goes under +// the suite the ticket was issued under (4.2.10, #3339), and the server that +// takes it must have picked that suite. A HelloRetryRequest (#3325) may ask +// for a secp256r1 share (the first ClientHello carries only x25519's); the +// second ClientHello carries no early data, and its binder is computed again +// over the new transcript. fn connect_resume_ed(host: string, port: int, x25519_priv: ptr, ticket: ptr, early_data: byte[]) -> (ptr, string) { if early_data.len < 0 { panic("tls13_client.connect_resume_ed: the `early_data` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } int early_len = early_data.len @@ -1626,18 +2514,28 @@ fn connect_resume_ed(host: string, port: int, x25519_priv: ptr, ticket: ptr, ear hl = st.hash_len want_0rtt = 0 if early_data != null && early_len > 0 { want_0rtt = 1 } + // Early data is protected with the ticket's own suite (4.2.10, #3339); + // a ticket that does not say which sends none. + ed_aead, ed_key_len, ed_algo, _ed_hl, ed_err = suite_params(st.cipher_suite) + if string.equals(ed_err, "") != 1 || ed_algo != algo { want_0rtt = 0 } // PSK = HKDF-Expand-Label(res_master, "resumption", ticket_nonce, Hash.len) psk = tls13_ks.psk_from_resumption(algo, st.res_master, hl, bytes.view(st.nonce)[0..st.nonce_len]) sock, dial_err = tcp.connect(host, port) - if string.equals(dial_err, "") != 1 { bytes.free(psk); return null, dial_err } + if string.equals(dial_err, "") != 1 { tls13_ks.free_secret(psk); return null, dial_err } pub = x25519.base_point(x25519_priv) rnd = rand_into(32) sid = rand_into(32) + if rnd == null || sid == null { + if rnd != null { bytes.wipe_free(rnd) } + if sid != null { bytes.wipe_free(sid) } + bytes.wipe_free(pub); tls13_ks.free_secret(psk); tcp.close(sock) + return null, "no randomness available for the handshake" + } - // Build the PSK ClientHello with a zero binder placeholder (+ early_data ext - // when sending 0-RTT). + // The PSK ClientHello (+ early_data when sending 0-RTT), its binder + // computed over the truncated ClientHello and patched in. pch = tls13_hs.PskCh { buf: null, buf_len: 0, binder_off: 0, truncated_len: 0 } // obfuscated_ticket_age = 0 is accepted by openssl (it doesn't enforce age // for its own tickets in the common config); real freshness needs the @@ -1645,127 +2543,247 @@ fn connect_resume_ed(host: string, port: int, x25519_priv: ptr, ticket: ptr, ear tls13_hs.client_hello_psk(rnd, bytes.view(sid)[0..32], pub, host, bytes.view(st.ticket)[0..st.ticket_len], 0, hl, want_0rtt, &pch) ch = pch.buf ch_len = pch.buf_len - - // Compute the PSK binder over the truncated ClientHello and patch it in. - // binder = HMAC(binder_finished_key, Transcript-Hash(truncated CH)). early = tls13_ks.early_secret_psk(algo, bytes.view(psk)[0..hl]) - bkey = tls13_ks.binder_key(algo, early) - bfk = finished_key_algo(bkey, algo, hl) - trunc_hash = hash_bytes_algo(bytes.view(ch)[0..pch.truncated_len], algo) - binder = hmac.bytes(algo, bytes.view(bfk)[0..hl], bytes.view(trunc_hash)[0..hl]) - bp = 0 - while bp < hl { bytes.set(ch, pch.binder_off + bp, bytes.get(binder, bp) & 0xFF); bp = bp + 1 } - bytes.free(bkey); bytes.free(bfk); bytes.free(trunc_hash); bytes.free(binder) - tr = transcript_new() + patch_binder(ch, pch.binder_off, pch.truncated_len, early, algo, hl, tr) transcript_add(tr, bytes.view(ch)[0..ch_len]) serr = send_plaintext_handshake(sock, bytes.view(ch)[0..ch_len]) if string.equals(serr, "") != 1 { - bytes.free(early); bytes.free(psk); cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); tcp.close(sock) + tls13_ks.free_secret(early); tls13_ks.free_secret(psk); cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); tcp.close(sock) return null, serr } - // 0-RTT: immediately send the early data as application_data records under - // the client_early_traffic_secret = Derive-Secret(early, "c e traffic", - // Transcript-Hash(ClientHello)). We need the record AEAD/key-len for the - // ticket's suite; both 0-RTT suites here use the ticket's hash. - ed_aead, ed_key_len = early_aead_params(algo) + // Middlebox compatibility (D.4, #3322): with early data, the dummy + // change_cipher_spec goes right after the ClientHello. + ccs_sent = 0 + if want_0rtt == 1 { + send_dummy_ccs(sock) + ccs_sent = 1 + } + + // 0-RTT: the early data goes now, as application_data records under the + // client_early_traffic_secret = Derive-Secret(early, "c e traffic", + // ClientHello). The context stays: EndOfEarlyData goes under it too + // (4.5, #3336). + ed_ctx = null if want_0rtt == 1 { ch_hash = hash_bytes_algo(bytes.view(ch)[0..ch_len], algo) ets = tls13_ks.early_traffic_secret(algo, early, bytes.view(ch_hash)[0..hl]) ek = tls13_ks.write_key(algo, bytes.view(ets)[0..hl], ed_key_len) eiv = tls13_ks.write_iv(algo, ets) ed_ctx = tls13_record.new_aead(ed_aead, bytes.view(ek)[0..ed_key_len], eiv) - ed_rec = tls13_record.seal_record(ed_ctx, tls13_record.CONTENT_APPLICATION_DATA, early_data) - cli_send_view(sock, bytes.view(ed_rec)) - bytes.free(ch_hash); bytes.free(ets); bytes.free(ek); bytes.free(eiv) - tls13_record.free_ctx(ed_ctx); bytes.free(ed_rec) + // The early data in as many records as it takes (#3380); a short + // send ends the attempt before the ServerHello is read. + ed_sent = cli_send_sealed(sock, ed_ctx, tls13_record.CONTENT_APPLICATION_DATA, early_data) + bytes.wipe_free(ch_hash); tls13_ks.free_secret(ets); tls13_ks.free_secret(ek); tls13_ks.free_secret(eiv) + if ed_sent == 0 { + tls13_record.free_ctx(ed_ctx) + tls13_ks.free_secret(early); tls13_ks.free_secret(psk); cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); tcp.close(sock) + return null, "resume: the early data could not be sent" + } } - bytes.free(early) rio = recio_new(sock) - shrec, shlen, sherr = recio_next_hs(rio) - if string.equals(sherr, "") != 1 { - bytes.free(psk); cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio); tcp.close(sock) - return null, sherr + // The ServerHello, after at most one HelloRetryRequest. + p256_priv = null + retried = 0 + hrr_suite = 0 + sh = null + shrec = null + sh_msg_len = 0 + err = "" + alert = 0 + parsed = tls13_hs.ShParsed { version: 0, cipher_suite: 0, group: 0, key_share: null, key_share_len: 0, is_hello_retry: 0, psk_accepted: 0, alert: 0 } + reading = 1 + while reading == 1 { + shrec2, shlen, sherr = recio_next_hs(rio) + if string.equals(sherr, "") != 1 { + err = sherr + alert = rio.alert + reading = 0 + } else { + shrec = shrec2 + sh_msg_len = shlen - 5 + sh = bytes.new(sh_msg_len) + bytes.copy_from_bytes(sh, 0, shrec, 5, sh_msg_len) + perr = tls13_hs.parse_server_hello(bytes.view(sh)[0..sh_msg_len], &parsed) + if string.equals(perr, "") == 1 { + shares = GB_X25519 + if p256_priv != null { shares = GB_SECP256R1 } + ha, herr = check_hello_fields(&parsed, sh, sid, retried, hrr_suite, GB_X25519 | GB_SECP256R1, shares) + if ha != 0 { + parsed.alert = ha + perr = herr + } + } + if string.equals(perr, "") != 1 { + err = perr + alert = parsed.alert + reading = 0 + } else if parsed.is_hello_retry == 1 { + _ha, _hk, halgo, _hh, _he = suite_params(parsed.cipher_suite) + if halgo != algo { + // The second ClientHello may offer no PSK of another + // hash (4.2.11), and without the PSK there is nothing + // to resume. + err = "resume: the HelloRetryRequest chose a suite of another hash than the ticket's" + alert = ALERT_HANDSHAKE_FAILURE + reading = 0 + } else { + // A secp256r1 share when asked for; else the same one. + share = bytes.new(0) + share_len = 0 + if parsed.group == tls13_hs.GROUP_SECP256R1 { + p256_priv = fresh_p256_priv() + if p256_priv != null { + bytes.wipe_free(share) + share, share_len = keyshare_for_group(tls13_hs.GROUP_SECP256R1, p256_priv) + } + } + if parsed.group == tls13_hs.GROUP_SECP256R1 && p256_priv == null { + bytes.wipe_free(share) + err = "no randomness available for the handshake" + alert = ALERT_INTERNAL_ERROR + reading = 0 + } else { + tr = transcript_retry(tr, bytes.view(ch)[0..ch_len], bytes.view(sh)[0..sh_msg_len], algo) + pch2 = tls13_hs.PskCh { buf: null, buf_len: 0, binder_off: 0, truncated_len: 0 } + tls13_hs.client_hello_retry(bytes.view(ch)[0..ch_len], parsed.group, bytes.view(share)[0..share_len], bytes.view(sh)[parsed.cookie_off..parsed.cookie_off + parsed.cookie_len], &pch2) + bytes.wipe_free(share) + bytes.wipe_free(ch) + ch = pch2.buf + ch_len = pch2.buf_len + patch_binder(ch, pch2.binder_off, pch2.truncated_len, early, algo, hl, tr) + transcript_add(tr, bytes.view(ch)[0..ch_len]) + if ccs_sent == 0 { + send_dummy_ccs(sock) + ccs_sent = 1 + } + serr2 = send_plaintext_handshake_v(sock, bytes.view(ch)[0..ch_len], 0x03) + bytes.wipe_free(sh); bytes.wipe_free(shrec) + sh = null + shrec = null + if string.equals(serr2, "") != 1 { + err = serr2 + reading = 0 + } + // The early data sent is lost: no 0-RTT after a + // HelloRetryRequest (4.2.10). + want_0rtt = 0 + retried = 1 + hrr_suite = parsed.cipher_suite + } + } + } else { + reading = 0 + } + } } - sh_msg_len = shlen - 5 - sh = bytes.new(sh_msg_len) - bytes.set(sh, sh_msg_len - 1, 0) - bytes.copy_from_bytes(sh, 0, shrec, 5, sh_msg_len) - transcript_add(tr, bytes.view(sh)[0..sh_msg_len]) - parsed = tls13_hs.ShParsed { version: 0, cipher_suite: 0, group: 0, key_share: null, key_share_len: 0, is_hello_retry: 0, psk_accepted: 0 } - perr = tls13_hs.parse_server_hello(bytes.view(sh)[0..sh_msg_len], &parsed) - if string.equals(perr, "") != 1 { - bytes.free(psk); bytes.free(sh); bytes.free(shrec) - cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio); tcp.close(sock) - return null, perr - } - if parsed.psk_accepted != 1 { - if parsed.key_share != null { bytes.free(parsed.key_share) } - bytes.free(psk); bytes.free(sh); bytes.free(shrec) - cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio); tcp.close(sock) - return null, "server rejected the PSK (no resumption)" - } - - // ECDHE (psk_dhe_ke): the server still sent a key_share. - shared, agerr = ecdhe_shared(tls13_hs.GROUP_X25519, x25519_priv, parsed.key_share, parsed.key_share_len) - if string.equals(agerr, "") != 1 { - if parsed.key_share != null { bytes.free(parsed.key_share) } - bytes.free(psk); bytes.free(sh); bytes.free(shrec) - cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio); tcp.close(sock) - return null, agerr + tls13_ks.free_secret(early) + + // The PSK checks, then the key share: psk_dhe_ke over the group this + // ClientHello has a share for. + if err == "" && parsed.psk_accepted != 1 { + err = "server rejected the PSK (no resumption)" } - rec_aead, rec_key_len, hash_algo, hash_len, suite_err = suite_params(parsed.cipher_suite) - if string.equals(suite_err, "") != 1 { - if parsed.key_share != null { bytes.free(parsed.key_share) } - bytes.free(psk); bytes.free(shared); bytes.free(sh); bytes.free(shrec) - cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio); tcp.close(sock) - return null, suite_err + rec_aead, rec_key_len, hash_algo, hash_len, _suite_err = suite_params(parsed.cipher_suite) + if err == "" && parsed.selected_identity != 0 { + err = "server hello: selected_identity ${parsed.selected_identity}, but the client offered one PSK" + alert = ALERT_ILLEGAL_PARAMETER } + if err == "" && hash_algo != algo { + err = "server hello: a cipher suite whose hash is not the PSK's" + alert = ALERT_ILLEGAL_PARAMETER + } + want_group = tls13_hs.GROUP_X25519 + if p256_priv != null { want_group = tls13_hs.GROUP_SECP256R1 } + if err == "" && parsed.key_share == null { + err = "server hello: no key_share, but the client offered only psk_dhe_ke" + alert = ALERT_MISSING_EXTENSION + } + if err == "" && parsed.group != want_group { + err = "server hello: key_share group ${parsed.group}, not the one the ClientHello has a share for" + alert = ALERT_ILLEGAL_PARAMETER + } + shared = null + if err == "" { + priv = x25519_priv + if p256_priv != null { priv = p256_priv } + sh2, agerr = ecdhe_shared(parsed.group, priv, parsed.key_share, parsed.key_share_len) + shared = sh2 + if string.equals(agerr, "") != 1 || shared == null { + if string.equals(agerr, "") == 1 { agerr = "key exchange failed" } + err = agerr + alert = ALERT_ILLEGAL_PARAMETER + } + } + if p256_priv != null { bytes.wipe_free(p256_priv) } + if err != "" { + if alert != 0 { send_plain_alert(sock, alert) } + if ed_ctx != null { tls13_record.free_ctx(ed_ctx) } + if shared != null { bytes.wipe_free(shared) } + if parsed.key_share != null { bytes.wipe_free(parsed.key_share) } + if sh != null { bytes.wipe_free(sh) } + if shrec != null { bytes.wipe_free(shrec) } + tls13_ks.free_secret(psk) + cleanup_hello(pub, rnd, sid, ch); transcript_free(tr); recio_free(rio); close_drained(sock) + return null, err + } + transcript_add(tr, bytes.view(sh)[0..sh_msg_len]) // Handshake keys keyed off the PSK early secret (RFC 8446 §7.1 resumption). + shared_len = bytes.length(shared) th_chsh = transcript_hash_algo(tr, hash_algo) - keys = derive_handshake_keys_psk(bytes.view(psk)[0..hl], bytes.view(shared)[0..32], bytes.view(th_chsh)[0..hash_len], rec_key_len, hash_algo) + keys = derive_handshake_keys_psk(bytes.view(psk)[0..hl], bytes.view(shared)[0..shared_len], bytes.view(th_chsh)[0..hash_len], rec_key_len, hash_algo) srv_ctx = tls13_record.new_aead(rec_aead, bytes.view(hs_server_key(keys))[0..rec_key_len], hs_server_iv(keys)) // Read the abbreviated flight (EncryptedExtensions + Finished; NO cert). fl = bytes.new(16384) bytes.set(fl, 0, 0) fllen = 0 - done = 0 ferr = "" - guard = 0 - while done == 0 { - guard = guard + 1 - if guard > 64 { ferr = "resume: flight too long"; done = 1 } else { - nrec, nlen, nerr = recio_next(rio) - if string.equals(nerr, "") != 1 { ferr = nerr; done = 1 } else { - ct2 = bytes.get(nrec, 0) & 0xFF - if ct2 == 0x14 { bytes.free(nrec) } else { - op = tls13_record.OpenedRecord { content_type: 0, plaintext: null, plaintext_len: 0 } - oe = tls13_record.open_record(srv_ctx, bytes.view(nrec)[0..nlen], &op) - bytes.free(nrec) - if string.equals(oe, "") != 1 { ferr = oe; done = 1 } else { - if op.content_type == tls13_record.CONTENT_HANDSHAKE { - bytes.copy_from_bytes(fl, fllen, op.plaintext, 0, op.plaintext_len) - fllen = fllen + op.plaintext_len - if flight_complete(fl, fllen) == 1 { done = 1 } - } - if op.plaintext != null { bytes.free(op.plaintext) } - } - } - } + fllen, ferr, falert = read_flight(rio, srv_ctx, fl) + // The second flight starts with the dummy change_cipher_spec (D.4, + // #3322) unless it went already, after the first ClientHello with early + // data or before the second. + if ccs_sent == 0 { send_dummy_ccs(sock) } + if string.equals(ferr, "") != 1 && falert != 0 { + fa_ctx = tls13_record.new_aead(rec_aead, bytes.view(hs_client_key(keys))[0..rec_key_len], hs_client_iv(keys)) + send_alert_hs(sock, fa_ctx, ALERT_LEVEL_FATAL, falert) + tls13_record.free_ctx(fa_ctx) + } + + // EncryptedExtensions then Finished, nothing else (RFC 8446 2.2, #3324); + // the extensions answer only what the resuming ClientHello offered + // (4.2, #3315). + if string.equals(ferr, "") == 1 { + r_offered = tls13_hs.OFFER_ALPN | tls13_hs.OFFER_PSK + if string.length(host) > 0 { r_offered = r_offered | tls13_hs.OFFER_SNI } + if want_0rtt == 1 { r_offered = r_offered | tls13_hs.OFFER_EARLY } + ree_alert, ree_err = check_flight(fl, fllen, r_offered, 0, hash_len) + if ree_alert != 0 { + ferr = ree_err + ree_ctx = tls13_record.new_aead(rec_aead, bytes.view(hs_client_key(keys))[0..rec_key_len], hs_client_iv(keys)) + send_alert_hs(sock, ree_ctx, ALERT_LEVEL_FATAL, ree_alert) + tls13_record.free_ctx(ree_ctx) } } // Did the server ACCEPT 0-RTT? It echoes early_data (ext 42) in its - // EncryptedExtensions. On accept we must send end_of_early_data before our - // Finished; on reject the early data was dropped (caller resends it). + // EncryptedExtensions, and must have picked the ticket's suite (4.2.10, + // #3339). On accept, EndOfEarlyData goes before the client Finished; on + // reject the early data was dropped (conn_early_accepted says so). zero_rtt_accepted = 0 if want_0rtt == 1 && string.equals(ferr, "") == 1 { zero_rtt_accepted = flight_has_early_data_ext(fl, fllen) + if zero_rtt_accepted == 1 && parsed.cipher_suite != st.cipher_suite { + // The server that took the early data reads the client under + // the early traffic keys until EndOfEarlyData (4.5), so the + // alert goes under them. + ferr = "encrypted extensions: early data accepted under cipher suite ${parsed.cipher_suite}, not the ticket's ${st.cipher_suite}" + send_alert_hs(sock, ed_ctx, ALERT_LEVEL_FATAL, ALERT_ILLEGAL_PARAMETER) + } } result = null @@ -1777,81 +2795,91 @@ fn connect_resume_ed(host: string, port: int, x25519_priv: ptr, ticket: ptr, ear expected = finished_verify_data_algo(hs_server_secret(keys), bytes.view(th_before_finished)[0..hash_len], hash_algo, hash_len) if bytes_equal(expected, bytes.view(server_verify_data)[0..hash_len]) != 1 { result_err = "resume: server Finished MAC verification failed" + rf_ctx = tls13_record.new_aead(rec_aead, bytes.view(hs_client_key(keys))[0..rec_key_len], hs_client_iv(keys)) + send_alert_hs(sock, rf_ctx, ALERT_LEVEL_FATAL, ALERT_DECRYPT_ERROR) + tls13_record.free_ctx(rf_ctx) } else { + // The application traffic secrets are over ClientHello..server + // Finished (7.1), whether or not early data was accepted: the + // EndOfEarlyData after it is the client Finished's only (#3336). th_sf = transcript_hash_algo(tr, hash_algo) ms = tls13_ks.master_secret(hash_algo, hs_handshake_secret(keys)) c_ap = tls13_ks.traffic_secret(hash_algo, ms, "c ap traffic", bytes.view(th_sf)[0..hash_len]) s_ap = tls13_ks.traffic_secret(hash_algo, ms, "s ap traffic", bytes.view(th_sf)[0..hash_len]) cli_hs_ctx = tls13_record.new_aead(rec_aead, bytes.view(hs_client_key(keys))[0..rec_key_len], hs_client_iv(keys)) - // If 0-RTT was accepted, signal the end of early data (RFC 8446 §4.5) - // encrypted under the client handshake keys, and fold it into the - // transcript before computing our Finished. + // If 0-RTT was accepted, signal the end of early data (RFC 8446 + // 4.5) under the early traffic keys, and fold it into the + // transcript the client Finished covers. + sent = 1 // every record of the client's flight sealed and sent (#3380) if zero_rtt_accepted == 1 { eoed = build_end_of_early_data() - eoed_rec = tls13_record.seal_record(cli_hs_ctx, tls13_record.CONTENT_HANDSHAKE, bytes.view(eoed)) - cli_send_view(sock, bytes.view(eoed_rec)) + sent = sent & cli_send_sealed(sock, ed_ctx, tls13_record.CONTENT_HANDSHAKE, bytes.view(eoed)) transcript_add(tr, bytes.view(eoed)) - bytes.free(eoed); bytes.free(eoed_rec) - // recompute th_sf now that end_of_early_data is in the transcript - bytes.free(th_sf) - th_sf = transcript_hash_algo(tr, hash_algo) - bytes.free(c_ap); bytes.free(s_ap) - c_ap = tls13_ks.traffic_secret(hash_algo, ms, "c ap traffic", bytes.view(th_sf)[0..hash_len]) - s_ap = tls13_ks.traffic_secret(hash_algo, ms, "s ap traffic", bytes.view(th_sf)[0..hash_len]) + bytes.wipe_free(eoed) } - cvd = finished_verify_data_algo(hs_client_secret(keys), bytes.view(th_sf)[0..hash_len], hash_algo, hash_len) + th_cv = transcript_hash_algo(tr, hash_algo) + cvd = finished_verify_data_algo(hs_client_secret(keys), bytes.view(th_cv)[0..hash_len], hash_algo, hash_len) + bytes.wipe_free(th_cv) cfin = build_finished_msg(cvd) - cfin_rec = tls13_record.seal_record(cli_hs_ctx, tls13_record.CONTENT_HANDSHAKE, bytes.view(cfin)) - cli_send_view(sock, bytes.view(cfin_rec)) + sent = sent & cli_send_sealed(sock, cli_hs_ctx, tls13_record.CONTENT_HANDSHAKE, bytes.view(cfin)) transcript_add(tr, bytes.view(cfin)) th_cf = transcript_hash_algo(tr, hash_algo) res_master2 = tls13_kdf.derive_secret(hash_algo, bytes.view(ms)[0..hash_len], "res master", bytes.view(th_cf)[0..hash_len]) - bytes.free(th_cf) - ck = tls13_ks.write_key(hash_algo, bytes.view(c_ap)[0..hash_len], rec_key_len) - civ = tls13_ks.write_iv(hash_algo, c_ap) - sk = tls13_ks.write_key(hash_algo, bytes.view(s_ap)[0..hash_len], rec_key_len) - siv = tls13_ks.write_iv(hash_algo, s_ap) - conn = malloc(sizeof(ClientConn)) as *ClientConn - conn.sock = sock - conn.rio = rio as ptr - conn.app_send = tls13_record.new_aead(rec_aead, bytes.view(ck)[0..rec_key_len], civ) as ptr - conn.app_recv = tls13_record.new_aead(rec_aead, bytes.view(sk)[0..rec_key_len], siv) as ptr - conn.res_master = res_master2 - conn.res_hash_algo = str_to_bytes_local(hash_algo) - conn.res_hash_len = hash_len - conn.group = tls13_hs.GROUP_X25519 // psk_dhe_ke over the x25519 share - result = conn as ptr - bytes.free(ck); bytes.free(civ); bytes.free(sk); bytes.free(siv) - bytes.free(cvd); bytes.free(cfin); bytes.free(cfin_rec) + bytes.wipe_free(th_cf) + // The connection keeps c_ap and s_ap for KeyUpdate (#3320). + if sent == 1 { + result = new_client_conn(sock, rio as ptr, parsed.cipher_suite, c_ap, s_ap, res_master2, parsed.group) + rc = result as *ClientConn + rc.early_accepted = zero_rtt_accepted + } else { + tls13_ks.free_secret(c_ap); tls13_ks.free_secret(s_ap); tls13_ks.free_secret(res_master2) + result_err = "resume: the client's flight could not be sent" + } + bytes.wipe_free(cvd); bytes.wipe_free(cfin) tls13_record.free_ctx(cli_hs_ctx) - bytes.free(ms); bytes.free(c_ap); bytes.free(s_ap); bytes.free(th_sf) + tls13_ks.free_secret(ms); bytes.wipe_free(th_sf) } - bytes.free(expected) - if th_through_cert != null { bytes.free(th_through_cert) } - if th_before_finished != null { bytes.free(th_before_finished) } - if server_verify_data != null { bytes.free(server_verify_data) } + bytes.wipe_free(expected) + if th_through_cert != null { bytes.wipe_free(th_through_cert) } + if th_before_finished != null { bytes.wipe_free(th_before_finished) } + if server_verify_data != null { bytes.wipe_free(server_verify_data) } } - bytes.free(fl) + if ed_ctx != null { tls13_record.free_ctx(ed_ctx) } + bytes.wipe_free(fl) tls13_record.free_ctx(srv_ctx) free_hs_keys(keys) - bytes.free(th_chsh); bytes.free(shared); bytes.free(psk) - if parsed.key_share != null { bytes.free(parsed.key_share) } - bytes.free(sh); bytes.free(shrec) + bytes.wipe_free(th_chsh); bytes.wipe_free(shared); tls13_ks.free_secret(psk) + if parsed.key_share != null { bytes.wipe_free(parsed.key_share) } + bytes.wipe_free(sh); bytes.wipe_free(shrec) cleanup_hello(pub, rnd, sid, ch); transcript_free(tr) if result == null { - recio_free(rio); tcp.close(sock) + recio_free(rio); close_drained(sock) } return result, result_err } -// The record AEAD id + key length for the 0-RTT early-data cipher, inferred -// from the ticket's hash (SHA-384 => AES-256-GCM; SHA-256 => AES-128-GCM, the -// common openssl default). This must match the suite the server will pick; -// openssl reuses the ticket's suite for 0-RTT. -fn early_aead_params(algo: string) -> (int, int) { - if algo == "sha384" { return tls13_record.AEAD_AES_256_GCM, 32 } - return tls13_record.AEAD_AES_128_GCM, 16 +// Patch the PSK binder of the ClientHello `ch` at `binder_off`: +// HMAC(finished key of the binder_key from `early`, Transcript-Hash(what +// `prefix` holds, then ch's first `truncated_len` bytes)) (RFC 8446 +// 4.2.11.2). `prefix` is empty for a first ClientHello, and holds +// message_hash and the HelloRetryRequest for a second (#3325). +fn patch_binder(ch: ptr, binder_off: int, truncated_len: int, early: ptr, algo: string, hl: int, prefix: ptr) { + bkey = tls13_ks.binder_key(algo, early) + bfk = finished_key_algo(bkey, algo, hl) + pt = prefix as *Transcript + t = transcript_new() + if pt.len > 0 { transcript_add(t, bytes.view(pt.buf)[0..pt.len]) } + transcript_add(t, bytes.view(ch)[0..truncated_len]) + th = transcript_hash_algo(t, algo) + transcript_free(t) + binder = hmac.bytes(algo, bytes.view(bfk)[0..hl], bytes.view(th)[0..hl]) + bp = 0 + while bp < hl { + bytes.set(ch, binder_off + bp, bytes.get(binder, bp) & 0xFF) + bp = bp + 1 + } + tls13_ks.free_secret(bkey); tls13_ks.free_secret(bfk); bytes.wipe_free(th); tls13_ks.free_secret(binder) } // SHA-256/384 of a bytes buffer prefix (fresh Hash.len buffer). @@ -1865,10 +2893,10 @@ fn hash_bytes_algo(data: byte[], algo: string) -> ptr { free_ticket(t: ptr) { if t == null { return } st = t as *SessionTicket - if st.ticket != null { bytes.free(st.ticket) } - if st.nonce != null { bytes.free(st.nonce) } - if st.res_master != null { bytes.free(st.res_master) } - if st.hash_algo != null { bytes.free(st.hash_algo) } + if st.ticket != null { bytes.wipe_free(st.ticket) } + if st.nonce != null { bytes.wipe_free(st.nonce) } + if st.res_master != null { bytes.wipe_free(st.res_master) } + if st.hash_algo != null { bytes.wipe_free(st.hash_algo) } libc_free(t) } @@ -1983,29 +3011,6 @@ fn parse_cert_verify(body: byte[]) -> (int, ptr, int, string) { return scheme, sig, sig_len, "" } -// Split an uncompressed EC point (0x04 || X(32) || Y(32)) into pubx/puby. -// Split an uncompressed EC point (0x04 || X || Y) from a leaf SPKI into its -// affine coordinates. Handles P-256 (65-byte, 32-byte coords) and P-384 -// (97-byte, 48-byte coords). Returns (X, Y, coord_width, "") or (null,null,0,err). -fn split_ec_point(spki_key: byte[]) -> (ptr, ptr, int, string) { - if spki_key.len < 0 { panic("tls13_client.split_ec_point: the `spki_key` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } - int key_len = spki_key.len - coord = 0 - if key_len == 65 { coord = 32 } - if key_len == 97 { coord = 48 } - if coord == 0 { return null, null, 0, "spki: not a P-256/P-384 uncompressed EC point" } - if (spki_key[0] & 0xFF) != 0x04 { return null, null, 0, "spki: not uncompressed (0x04)" } - px = bytes.new(coord) - py = bytes.new(coord) - bytes.set(px, coord - 1, 0) - bytes.set(py, coord - 1, 0) - bytes.copy_from_slice(px, 0, spki_key[1..1 + coord]) - bytes.copy_from_slice(py, 0, spki_key[1 + coord..1 + coord + coord]) - return px, py, coord, "" -} - -// Authenticate the server: extract the leaf public key from Certificate and -// verify the CertificateVerify signature over `th_thru_cert`. Fails closed. // Load the trust anchors a connection verifies against. Returns (anchors, "") // or (null, why) -- and connect() fails closed on null, so a store that cannot // be found is a refused connection, never an unauthenticated one. @@ -2020,7 +3025,7 @@ fn split_ec_point(spki_key: byte[]) -> (ptr, ptr, int, string) { // reads it. // 3. On Windows, every certificate of the ROOT system store together with // the first bundle file of the list std.http.client's OpenSSL path looks -// for too. std/net/aether_http.c owns both, for both stacks, so the two +// for too. std/net/aether_tls_trust.c owns both, for both stacks, so the two // cannot look in different places again (#2675). Elsewhere these come // back empty and the next step decides. // 4. The OS defaults: the first bundle file that exists, else the first @@ -2092,7 +3097,7 @@ fn first_existing_file3(p1: string, p2: string, p3: string) -> string { return "" } -// The Windows trust anchors (#2675), from std/net/aether_http.c, which +// The Windows trust anchors (#2675), from std/net/aether_tls_trust.c, which // std.http.client's OpenSSL path reads too: the ROOT system store as PEM text, // and the first bundle file of the shared list (the bundle beside the // program, MSYS2's, Git for Windows'). Both, rather than the store alone, @@ -2148,7 +3153,9 @@ fn add_cert_dir_list(anchors: ptr, list: string) -> string { return first_err } -fn authenticate_server(fl: ptr, flen: int, th_thru_cert: ptr, th_len: int, host: string, anchors: ptr) -> string { +// Authenticate the server from its flight `fl`: (0, "") or the alert to send +// and why. +fn authenticate_server(fl: ptr, flen: int, th_thru_cert: ptr, th_len: int, host: string, anchors: ptr, purpose: int) -> (int, string) { // The full parsed leaf is kept alive for the whole function so that, after // the CertificateVerify signature check, we can run RFC 5280/6125 policy // (validity window, hostname/SAN, chain to a trusted anchor) over it. @@ -2157,8 +3164,6 @@ fn authenticate_server(fl: ptr, flen: int, th_thru_cert: ptr, th_len: int, host: // Server-sent intermediate CAs (everything in the Certificate list after // the leaf). Heap-allocated *LeafCert entries; freed at function end. intermediates = list_new() - leaf_px = null - leaf_py = null cv_scheme = 0 cv_sig = null cv_sig_len = 0 @@ -2168,6 +3173,7 @@ fn authenticate_server(fl: ptr, flen: int, th_thru_cert: ptr, th_len: int, host: ocsp_staple_len = 0 ocsp_status = tls13_cert.OCSP_ERROR err = "" + alert = 0 p = 0 cont = 1 while cont == 1 { @@ -2190,16 +3196,8 @@ fn authenticate_server(fl: ptr, flen: int, th_thru_cert: ptr, th_len: int, host: if string.equals(err, "") == 1 { err = perr } } else { have_leaf = 1 - // Extract the EC point only if the SPKI is one; a - // non-EC (RSA) leaf leaves leaf_px null, which the - // RSA-PSS dispatch handles. Not an error here. - px, py, _coordw, sperr = split_ec_point(bytes.view(leaf.spki_key)[0..leaf.spki_key_len]) - if string.equals(sperr, "") == 1 { - leaf_px = px - leaf_py = py - } } - bytes.free(der) + bytes.wipe_free(der) // Parse the remaining certs into `intermediates`. parse_intermediates(bytes.view(cbody)[0..ml], intermediates) // Revocation: stash any stapled OCSP response for the @@ -2213,11 +3211,11 @@ fn authenticate_server(fl: ptr, flen: int, th_thru_cert: ptr, th_len: int, host: ocsp_staple_len = ocsp_len ocsp_status = tls13_cert.parse_ocsp_status(bytes.view(ocsp)[0..ocsp_len]) } else { - bytes.free(ocsp) + bytes.wipe_free(ocsp) } } } - bytes.free(cbody) + bytes.wipe_free(cbody) } if mt == HS_CERTIFICATE_VERIFY { vbody = bytes.new(ml) @@ -2231,7 +3229,7 @@ fn authenticate_server(fl: ptr, flen: int, th_thru_cert: ptr, th_len: int, host: cv_sig = sig cv_sig_len = slen } - bytes.free(vbody) + bytes.wipe_free(vbody) } p = p + total } @@ -2241,29 +3239,15 @@ fn authenticate_server(fl: ptr, flen: int, th_thru_cert: ptr, th_len: int, host: if have_leaf == 0 { err = "server did not send a usable Certificate" } else { if cv_sig == null { err = "server did not send CertificateVerify" } - else { - // Dispatch the CertificateVerify by SignatureScheme: - // ECDSA-P256 (0x0403) / ECDSA-P384 (0x0503) -> EC point from - // the leaf SPKI (verify_cert_verify picks the curve/hash) - // RSA-PSS rsae SHA-256 (0x0804) -> RSA key from the leaf SPKI - is_ecdsa = 0 - if cv_scheme == tls13_cert.SCHEME_ECDSA_P256_SHA256 { is_ecdsa = 1 } - if cv_scheme == tls13_cert.SCHEME_ECDSA_P384_SHA384 { is_ecdsa = 1 } - if is_ecdsa == 1 { - if leaf_px == null { - err = "ECDSA CertificateVerify but leaf is not an EC cert" - } else { - ok = tls13_cert.verify_cert_verify(cv_scheme, leaf_px, leaf_py, bytes.view(th_thru_cert)[0..th_len], bytes.view(cv_sig)[0..cv_sig_len]) - if ok != 1 { err = "CertificateVerify signature invalid" } - } - } else { - if cv_scheme == tls13_cert.SCHEME_RSA_PSS_RSAE_SHA256 { - ok = tls13_cert.verify_cert_verify_rsa_pss(bytes.view(leaf.spki_key)[0..leaf.spki_key_len], bytes.view(th_thru_cert)[0..th_len], bytes.view(cv_sig)[0..cv_sig_len]) - if ok != 1 { err = "CertificateVerify signature invalid" } - } else { - err = "unsupported CertificateVerify scheme (ECDSA-P256/P384 and RSA-PSS-SHA256 handled)" - } - } + else if tls13_hs.offers_sig_scheme(cv_scheme) == 0 { + // A server signs with a scheme the ClientHello offered + // (RFC 8446 4.4.3, #3324). + alert = ALERT_ILLEGAL_PARAMETER + err = "CertificateVerify: signature scheme ${cv_scheme} was not offered" + } else { + // The scheme must fit the leaf's key, then the signature must + // verify (#3324); Ed25519 included (#3335). + alert, err = tls13_cert.check_leaf_cert_verify(&leaf, cv_scheme, bytes.view(th_thru_cert)[0..th_len], bytes.view(cv_sig)[0..cv_sig_len]) } } } @@ -2272,7 +3256,8 @@ fn authenticate_server(fl: ptr, flen: int, th_thru_cert: ptr, th_len: int, host: // it claims to be", each failing closed: // 1. validity window (reject expired / not-yet-valid) // 2. hostname / SAN match (reject wrong host) - // 3. chain to a trusted anchor (reject self-signed / untrusted) + // 3. a valid path to a trusted anchor (reject self-signed / untrusted, + // a non-CA issuer, a constraint the path breaks) // Skipped only when anchors == null: that is the signature-only entry used // by verify_flight_auth's recorded-transcript tests, which have no trust // store. connect() always passes a loaded store, so it always enforces. @@ -2284,7 +3269,11 @@ fn authenticate_server(fl: ptr, flen: int, th_thru_cert: ptr, th_len: int, host: herr = tls13_cert.check_hostname(&leaf, host) if string.equals(herr, "") != 1 { err = herr } else { - cerr = tls13_cert.chain_verify_path(&leaf, intermediates, anchors) + // The whole path (#1298): signatures, every certificate's + // validity, basicConstraints / keyUsage / pathLen on the + // issuers, extendedKeyUsage, critical extensions and name + // constraints, searching every candidate path. + cerr = tls13_cert.validate_chain(&leaf, intermediates, anchors, "", purpose, -1) if string.equals(cerr, "") != 1 { err = cerr } else { // Revocation (RFC 6960): now that the chain is trusted, @@ -2310,13 +3299,12 @@ fn authenticate_server(fl: ptr, flen: int, th_thru_cert: ptr, th_len: int, host: } } } - if ocsp_staple != null { bytes.free(ocsp_staple) } + if ocsp_staple != null { bytes.wipe_free(ocsp_staple) } if have_leaf == 1 { tls13_cert.free_leaf(&leaf) } free_intermediates(intermediates) - if leaf_px != null { bytes.free(leaf_px) } - if leaf_py != null { bytes.free(leaf_py) } - if cv_sig != null { bytes.free(cv_sig) } - return err + if cv_sig != null { bytes.wipe_free(cv_sig) } + if string.equals(err, "") != 1 && alert == 0 { alert = alert_for_auth_error(err) } + return alert, err } // Parse every certificate AFTER the leaf in a Certificate message body into @@ -2354,7 +3342,7 @@ fn parse_intermediates(body: byte[], out: ptr) { tls13_cert.free_leaf(mc) libc_free(mc) } - bytes.free(der) + bytes.wipe_free(der) } p = p + cert_len // Skip the entry's extensions<2>. @@ -2403,7 +3391,8 @@ verify_flight_auth(fl: ptr, flen: int, th_thru_cert: ptr) -> string { // Signature-only mode (anchors == null): checks Certificate + // CertificateVerify but not the RFC 5280/6125 policy. connect() enforces // the full policy via a loaded trust store. - return authenticate_server(fl, flen, th_thru_cert, 32, "", null) + _alert, err = authenticate_server(fl, flen, th_thru_cert, 32, "", null, tls13_cert.PURPOSE_SERVER) + return err } // ---- @c_callback bridge for std.http.client (#1849) ------------------------- @@ -2441,42 +3430,44 @@ pure_tls_client_available() -> int { // Why a handshake failed goes back to std/net/aether_http.c through this, so // the https request's error names the cause (untrusted chain, wrong host, a // server that does not speak TLS 1.3, no trust store) instead of a guess. The -// callback ABI returns only a pointer; this is the side channel (thread-local -// on the C side, and the handshake runs on the dialling thread). -extern aether_pure_tls_client_set_error(msg: string) +// callback ABI returns only a pointer; this is the side channel: the callback +// registry's reason, thread-local, and the handshake runs on the dialling +// thread. It is the registry's (#3426) so that this module links nothing of +// the HTTP client. +extern aether_callback_set_reason(reason: string) @c_callback("aether_pure_tls_client_connect") pure_tls_client_connect(fd: int, host: string, verify_mode: int, cafile: string) -> ptr { sock = tcp_socket_from_fd_owned(fd) if sock == null { - aether_pure_tls_client_set_error("could not adopt the connected socket") + aether_callback_set_reason("could not adopt the connected socket") return null } priv = rand_into(32) if priv == null { tcp.close(sock) - aether_pure_tls_client_set_error("no randomness for the key share") + aether_callback_set_reason("no randomness for the key share") return null } ca = "" if cafile != null { ca = cafile } conn, err = connect_full(host, 443, priv, null, 0, null, sock, verify_mode, ca) - bytes.free(priv) + bytes.wipe_free(priv) if string.equals(err, "") != 1 { - aether_pure_tls_client_set_error(err) + aether_callback_set_reason(err) return null } if conn == null { - aether_pure_tls_client_set_error("handshake returned no connection") + aether_callback_set_reason("handshake returned no connection") return null } pc = malloc(sizeof(PureClientConn)) as *PureClientConn if pc == null { close_conn(conn) - aether_pure_tls_client_set_error("out of memory") + aether_callback_set_reason("out of memory") return null } pc.conn = conn @@ -2494,7 +3485,7 @@ pure_tls_client_send(pcp: ptr, buf: ptr, len: int) -> int { bytes.set(b, len - 1, 0) copy(bytes.data(b), buf, len as long) err = conn_send(pc.conn, bytes.view(b)[0..len]) - bytes.free(b) + bytes.wipe_free(b) if string.equals(err, "") != 1 { return -1 } return len } @@ -2516,7 +3507,7 @@ pure_tls_client_recv(pcp: ptr, buf: ptr, len: int) -> int { copy(buf, src, take as long) pc.pending_off = pc.pending_off + take if pc.pending_off >= pc.pending_len { - bytes.free(pc.pending) + bytes.wipe_free(pc.pending) pc.pending = null pc.pending_len = 0 pc.pending_off = 0 @@ -2526,12 +3517,12 @@ pure_tls_client_recv(pcp: ptr, buf: ptr, len: int) -> int { p, n, err = conn_recv(pc.conn) if string.equals(err, "") != 1 { - if p != null { bytes.free(p) } + if p != null { bytes.wipe_free(p) } return -1 } // A clean close_notify is EOF, which recv() reports as 0 -- not an error. if n <= 0 { - if p != null { bytes.free(p) } + if p != null { bytes.wipe_free(p) } return 0 } @@ -2543,7 +3534,7 @@ pure_tls_client_recv(pcp: ptr, buf: ptr, len: int) -> int { pc.pending_len = n pc.pending_off = take } else { - bytes.free(p) + bytes.wipe_free(p) } return take } @@ -2552,7 +3543,7 @@ pure_tls_client_recv(pcp: ptr, buf: ptr, len: int) -> int { pure_tls_client_close(pcp: ptr) { if pcp == null { return } pc = pcp as *PureClientConn - if pc.pending != null { bytes.free(pc.pending) } + if pc.pending != null { bytes.wipe_free(pc.pending) } if pc.conn != null { close_conn(pc.conn) } libc_free(pcp) } diff --git a/std/cryptography/tls13_hs/module.ae b/std/cryptography/tls13_hs/module.ae index 57fa74b05..1ecbcbc2c 100644 --- a/std/cryptography/tls13_hs/module.ae +++ b/std/cryptography/tls13_hs/module.ae @@ -14,8 +14,8 @@ // - supported_groups: x25519 (0x001d), secp256r1 (0x0017) // - key_share: x25519 and/or secp256r1 KeyShareEntry(s); SNI + ALPN http/1.1 // - signature_algorithms: ecdsa_secp256r1_sha256 (0x0403), -// ecdsa_secp384r1_sha384 (0x0503), rsa_pss_rsae_sha256 (0x0804), -// ed25519 (0x0807) +// ecdsa_secp384r1_sha384 (0x0503), ecdsa_secp521r1_sha512 (0x0603), +// rsa_pss_rsae_sha256 (0x0804), ed25519 (0x0807) // // Import with: import std.cryptography.tls13_hs // @@ -30,6 +30,7 @@ import std.bytes import std.list +import std.cryptography.sha2 extern malloc(size: int) -> ptr extern free(p: ptr) @@ -38,12 +39,21 @@ extern string_char_at_n(str: string, known_length: int, index: int) -> int exports( ChParsed, parse_client_hello, free_ch, server_hello, - client_hello, client_hello_sni, client_hello_full, client_hello_pq, client_hello_psk, PskCh, parse_server_hello, - ShParsed, + client_hello, client_hello_sni, client_hello_full, client_hello_offer, client_hello_pq, client_hello_psk, PskCh, parse_server_hello, + ShParsed, check_extensions, check_certificate, check_certificate_request, offers_sig_scheme, + MSG_SERVER_HELLO, MSG_HELLO_RETRY, MSG_ENCRYPTED_EXTENSIONS, MSG_CERTIFICATE_ENTRY, + MSG_NEW_SESSION_TICKET, MSG_CERTIFICATE_REQUEST, + OFFER_SNI, OFFER_ALPN, OFFER_STATUS, OFFER_PSK, OFFER_EARLY, TLS_CHACHA20_POLY1305_SHA256, TLS_AES_128_GCM_SHA256, TLS_AES_256_GCM_SHA384, - GROUP_X25519, GROUP_SECP256R1, GROUP_X25519MLKEM768, + GROUP_X25519, GROUP_SECP256R1, GROUP_SECP384R1, GROUP_X25519MLKEM768, + SIGBIT_ECDSA_P256, SIGBIT_ECDSA_P384, SIGBIT_ECDSA_P521, SIGBIT_RSA_PSS_SHA256, SIGBIT_ED25519, + SIGBIT_RSA_PSS_SHA384, SIGBIT_RSA_PSS_SHA512, HS_ENCRYPTED_EXTENSIONS, HS_CERTIFICATE, HS_CERTIFICATE_VERIFY, HS_FINISHED, - encrypted_extensions, certificate_msg, certificate_verify_msg, finished_msg + HS_NEW_SESSION_TICKET, HS_KEY_UPDATE, KEY_UPDATE_NOT_REQUESTED, KEY_UPDATE_REQUESTED, + encrypted_extensions, certificate_msg, certificate_verify_msg, finished_msg, + key_update_msg, key_update_request, + NstParsed, parse_new_session_ticket, + hello_retry_request, client_hello_retry, GROUPBIT_X25519MLKEM768, GROUPBIT_X25519, GROUPBIT_SECP256R1 ) // Cipher suites (RFC 8446 appendix B.4). @@ -54,6 +64,9 @@ const TLS_CHACHA20_POLY1305_SHA256 = 0x1303 // Named groups (RFC 8446 §4.2.7). const GROUP_X25519 = 0x001d const GROUP_SECP256R1 = 0x0017 +// secp384r1: the client names it in supported_groups without a key share, +// for a server that wants it to ask in a HelloRetryRequest (#3369). +const GROUP_SECP384R1 = 0x0018 // Post-quantum hybrid: X25519 + ML-KEM-768 (draft-kwiatkowski-tls-ecdhe-mlkem; // the group Chrome/Cloudflare default to). Client share = ML-KEM ek(1184) || // X25519 pub(32); server share = ML-KEM ct(1088) || X25519 pub(32). @@ -62,9 +75,21 @@ const GROUP_X25519MLKEM768 = 0x11EC // Signature schemes (RFC 8446 §4.2.3). const SIG_ECDSA_P256_SHA256 = 0x0403 const SIG_ECDSA_P384_SHA384 = 0x0503 +const SIG_ECDSA_P521_SHA512 = 0x0603 const SIG_RSA_PSS_RSAE_SHA256 = 0x0804 +const SIG_RSA_PSS_RSAE_SHA384 = 0x0805 +const SIG_RSA_PSS_RSAE_SHA512 = 0x0806 const SIG_ED25519 = 0x0807 +// The offered schemes a ChParsed records (sig_algs), one bit each. +const SIGBIT_ECDSA_P256 = 1 +const SIGBIT_ECDSA_P384 = 2 +const SIGBIT_ECDSA_P521 = 4 +const SIGBIT_RSA_PSS_SHA256 = 8 +const SIGBIT_ED25519 = 16 +const SIGBIT_RSA_PSS_SHA384 = 32 +const SIGBIT_RSA_PSS_SHA512 = 64 + // Handshake message types (RFC 8446 §4). const HS_CLIENT_HELLO = 1 const HS_SERVER_HELLO = 2 @@ -72,6 +97,12 @@ const HS_ENCRYPTED_EXTENSIONS = 8 const HS_CERTIFICATE = 11 const HS_CERTIFICATE_VERIFY = 15 const HS_FINISHED = 20 +const HS_NEW_SESSION_TICKET = 4 +const HS_KEY_UPDATE = 24 + +// KeyUpdate's request_update (RFC 8446 4.6.3). +const KEY_UPDATE_NOT_REQUESTED = 0 +const KEY_UPDATE_REQUESTED = 1 // Result of parsing a ServerHello. struct ShParsed { @@ -82,6 +113,16 @@ struct ShParsed { key_share_len: int is_hello_retry: int // 1 if this was a HelloRetryRequest (special random) psk_accepted: int // 1 if the SH echoed pre_shared_key (resumption accepted) + alert: int // the alert parse_server_hello says to send, when it refuses + // Where the legacy_session_id_echo is in the message, so the client can + // hold it to the one it sent (RFC 8446 4.1.3, #3318). + session_id_off: int + session_id_len: int + selected_identity: int // pre_shared_key's selected_identity, or -1 (#3318) + // A HelloRetryRequest's cookie (RFC 8446 4.2.2): where its value is in + // the message, length 0 when there is none (#3325). + cookie_off: int + cookie_len: int } // ---- little growable writer over std.bytes (append at a running offset) ---- @@ -146,6 +187,30 @@ fn get24(b: byte[], off: int) -> int { // 4-byte handshake header (msg_type + uint24 length). random must be 32 // bytes; session_id 0..32 bytes (TLS 1.3 middlebox-compat: usually a 32-byte // echo value); x25519_pub is our 32-byte ephemeral public key. +// The signature_algorithms extension (13) every ClientHello sends: each +// scheme the client verifies a CertificateVerify with (RFC 8446 4.2.3). +// Returns the offset past it. +fn put_sig_algs(buf: ptr, at: int) -> int { + off = put16(buf, at, 13) + off = put16(buf, off, 12) // ext body len + off = put16(buf, off, 10) // list len + off = put16(buf, off, SIG_ECDSA_P256_SHA256) + off = put16(buf, off, SIG_ECDSA_P384_SHA384) + off = put16(buf, off, SIG_ECDSA_P521_SHA512) + off = put16(buf, off, SIG_RSA_PSS_RSAE_SHA256) + off = put16(buf, off, SIG_ED25519) + return off +} + +// 1 when `scheme` is one put_sig_algs offers, the only schemes a server may +// sign its CertificateVerify with (RFC 8446 4.4.3), else 0. +offers_sig_scheme(scheme: int) -> int { + if scheme == SIG_ECDSA_P256_SHA256 || scheme == SIG_ECDSA_P384_SHA384 { return 1 } + if scheme == SIG_ECDSA_P521_SHA512 || scheme == SIG_RSA_PSS_RSAE_SHA256 { return 1 } + if scheme == SIG_ED25519 { return 1 } + return 0 +} + client_hello(random: ptr, session_id: byte[], x25519_pub: ptr) -> ptr { return client_hello_sni(random, session_id, x25519_pub, "") } @@ -159,23 +224,31 @@ client_hello_sni(random: ptr, session_id: byte[], x25519_pub: ptr, host: string) // Build a ClientHello offering up to three groups (e.g. X25519MLKEM768, x25519, // secp256r1) in supported_groups, carrying key_shares for each offered group. -// Pass group2=0 or group3=0 to omit the respective entry. +// Pass group2=0 or group3=0 to omit the respective entry. It offers +// psk_key_exchange_modes { psk_dhe_ke }, so a server may send tickets (#3363). client_hello_full(random: ptr, session_id: byte[], group: int, pubkey: byte[], group2: int, pubkey2: byte[], group3: int, pubkey3: byte[], host: string) -> ptr { - if session_id.len < 0 { panic("tls13_hs.client_hello_full: the `session_id` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } + return client_hello_offer(random, session_id, group, pubkey, group2, pubkey2, group3, pubkey3, host, 0) +} + +// client_hello_full, and `named_only` (0 for none) listed last in +// supported_groups with no key share: a group the client takes when a +// HelloRetryRequest asks for it (RFC 8446 4.1.4, #3369). +client_hello_offer(random: ptr, session_id: byte[], group: int, pubkey: byte[], group2: int, pubkey2: byte[], group3: int, pubkey3: byte[], host: string, named_only: int) -> ptr { + if session_id.len < 0 { panic("tls13_hs.client_hello_offer: the `session_id` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } int sid_len = session_id.len pubkey_len = 0 if group != 0 { - if pubkey.len < 0 { panic("tls13_hs.client_hello_full: the `pubkey` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } + if pubkey.len < 0 { panic("tls13_hs.client_hello_offer: the `pubkey` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } pubkey_len = pubkey.len } pubkey2_len = 0 if group2 != 0 { - if pubkey2.len < 0 { panic("tls13_hs.client_hello_full: the `pubkey2` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } + if pubkey2.len < 0 { panic("tls13_hs.client_hello_offer: the `pubkey2` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } pubkey2_len = pubkey2.len } pubkey3_len = 0 if group3 != 0 { - if pubkey3.len < 0 { panic("tls13_hs.client_hello_full: the `pubkey3` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } + if pubkey3.len < 0 { panic("tls13_hs.client_hello_offer: the `pubkey3` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } pubkey3_len = pubkey3.len } // Generous fixed buffer for up to 3 key shares + extensions. @@ -235,6 +308,7 @@ client_hello_full(random: ptr, session_id: byte[], group: int, pubkey: byte[], g if group != 0 { num_groups = num_groups + 1 } if group2 != 0 { num_groups = num_groups + 1 } if group3 != 0 { num_groups = num_groups + 1 } + if named_only != 0 { num_groups = num_groups + 1 } sg_body = 2 + num_groups * 2 off = put16(buf, off, 10) @@ -243,16 +317,10 @@ client_hello_full(random: ptr, session_id: byte[], group: int, pubkey: byte[], g if group != 0 { off = put16(buf, off, group) } if group2 != 0 { off = put16(buf, off, group2) } if group3 != 0 { off = put16(buf, off, group3) } + if named_only != 0 { off = put16(buf, off, named_only) } - // signature_algorithms (13): - // { ecdsa_p256_sha256, ecdsa_p384_sha384, rsa_pss_rsae_sha256, ed25519 } - off = put16(buf, off, 13) - off = put16(buf, off, 10) // ext body len - off = put16(buf, off, 8) // list len - off = put16(buf, off, SIG_ECDSA_P256_SHA256) - off = put16(buf, off, SIG_ECDSA_P384_SHA384) - off = put16(buf, off, SIG_RSA_PSS_RSAE_SHA256) - off = put16(buf, off, SIG_ED25519) + // signature_algorithms (13): every scheme the client verifies. + off = put_sig_algs(buf, off) // status_request (5): request OCSP stapling (RFC 6066 §8). Body = // CertificateStatusRequest { status_type=ocsp(1), responder_id_list<2>=empty, @@ -274,6 +342,15 @@ client_hello_full(random: ptr, session_id: byte[], group: int, pubkey: byte[], g off = put8(buf, off, 8) // ProtocolName length off = put_ascii(buf, off, "http/1.1") + // psk_key_exchange_modes (45): { psk_dhe_ke (1) }, the mode a ticket + // from this connection is resumed with. A server may use only a mode + // the client lists (RFC 8446 4.2.9), and Go and BoringSSL send no + // NewSessionTicket to a client that lists none (#3363). + off = put16(buf, off, 45) + off = put16(buf, off, 2) // ext body len + off = put8(buf, off, 1) // ke_modes length + off = put8(buf, off, 1) // psk_dhe_ke + // key_share (51): a KeyShareEntry {group, key} for each offered group. shares_len = 0 if group != 0 { shares_len = shares_len + 2 + 2 + pubkey_len } @@ -312,7 +389,7 @@ client_hello_full(random: ptr, session_id: byte[], group: int, pubkey: byte[], g out = bytes.new(off) if off > 0 { bytes.set(out, off - 1, 0) } put_bytes(out, 0, bytes.view(buf)[0..off]) - bytes.free(buf) + bytes.wipe_free(buf) return out } @@ -364,13 +441,7 @@ client_hello_pq(random: ptr, session_id: byte[], hybrid_share: ptr, hybrid_len: off = put16(buf, off, GROUP_X25519MLKEM768) off = put16(buf, off, GROUP_X25519) // signature_algorithms - off = put16(buf, off, 13) - off = put16(buf, off, 10) - off = put16(buf, off, 8) - off = put16(buf, off, SIG_ECDSA_P256_SHA256) - off = put16(buf, off, SIG_ECDSA_P384_SHA384) - off = put16(buf, off, SIG_RSA_PSS_RSAE_SHA256) - off = put16(buf, off, SIG_ED25519) + off = put_sig_algs(buf, off) // ALPN http/1.1 off = put16(buf, off, 16) off = put16(buf, off, 11) @@ -399,7 +470,7 @@ client_hello_pq(random: ptr, session_id: byte[], hybrid_share: ptr, hybrid_len: out = bytes.new(off) if off > 0 { bytes.set(out, off - 1, 0) } put_bytes(out, 0, bytes.view(buf)[0..off]) - bytes.free(buf) + bytes.wipe_free(buf) return out } @@ -469,13 +540,7 @@ client_hello_psk(random: ptr, session_id: byte[], x25519_pub: ptr, host: string, off = put16(buf, off, GROUP_X25519) off = put16(buf, off, GROUP_SECP256R1) // signature_algorithms - off = put16(buf, off, 13) - off = put16(buf, off, 10) - off = put16(buf, off, 8) - off = put16(buf, off, SIG_ECDSA_P256_SHA256) - off = put16(buf, off, SIG_ECDSA_P384_SHA384) - off = put16(buf, off, SIG_RSA_PSS_RSAE_SHA256) - off = put16(buf, off, SIG_ED25519) + off = put_sig_algs(buf, off) // ALPN http/1.1 off = put16(buf, off, 16) off = put16(buf, off, 11) @@ -535,7 +600,7 @@ client_hello_psk(random: ptr, session_id: byte[], x25519_pub: ptr, host: string, out = bytes.new(off) if off > 0 { bytes.set(out, off - 1, 0) } put_bytes(out, 0, bytes.view(buf)[0..off]) - bytes.free(buf) + bytes.wipe_free(buf) out_ch.buf = out out_ch.buf_len = off out_ch.binder_off = binder_off @@ -607,7 +672,7 @@ server_hello(random: ptr, session_id: byte[], cipher_suite: int, group: int, pub out = bytes.new(off) bytes.copy_from_bytes(out, 0, buf, 0, off) - bytes.free(buf) + bytes.wipe_free(buf) return out } @@ -632,13 +697,28 @@ struct ChParsed { key_share_len: int has_sni: int // 1 if a server_name extension was present saw_supported_versions: int // 1 if the client offered TLS 1.3 + sig_algs: int // SIGBIT_* of the signature_algorithms schemes offered that a server can sign with + saw_sig_algs: int // 1 if a signature_algorithms extension was present + // The groups supported_groups names that a server takes (GROUPBIT_*), + // and, when no key_share is for one of them, the one a + // HelloRetryRequest should ask for (RFC 8446 4.2.8, #3325), else 0. + groups: int + retry_group: int + has_early_data: int // 1 if an early_data extension was present + has_cookie: int // 1 if a cookie extension was present + alert: int // the alert parse_client_hello says to send, when it refuses (#3357) } +// The groups a ChParsed's `groups` records, one bit each. +const GROUPBIT_X25519MLKEM768 = 1 +const GROUPBIT_X25519 = 2 +const GROUPBIT_SECP256R1 = 4 + // Free the buffers a ChParsed owns. Idempotent. free_ch(c: *ChParsed) { - if c.random != null { bytes.free(c.random) c.random = null } - if c.session_id != null { bytes.free(c.session_id) c.session_id = null } - if c.key_share != null { bytes.free(c.key_share) c.key_share = null } + if c.random != null { bytes.wipe_free(c.random) c.random = null } + if c.session_id != null { bytes.wipe_free(c.session_id) c.session_id = null } + if c.key_share != null { bytes.wipe_free(c.key_share) c.key_share = null } } // Parse a ClientHello handshake message. Returns "" on success. @@ -658,14 +738,24 @@ parse_client_hello(msg: byte[], out: *ChParsed) -> string { out.key_share_len = 0 out.has_sni = 0 out.saw_supported_versions = 0 + out.sig_algs = 0 + out.saw_sig_algs = 0 + out.groups = 0 + out.retry_group = 0 + out.has_early_data = 0 + out.has_cookie = 0 + out.alert = ALERT_DECODE_ERROR if len < 4 { return "client hello: truncated header" } - if get8(msg, 0) != HS_CLIENT_HELLO { return "client hello: wrong msg type" } + if get8(msg, 0) != HS_CLIENT_HELLO { + out.alert = ALERT_UNEXPECTED_MESSAGE + return "client hello: wrong msg type" + } body_len = get24(msg, 1) - if body_len + 4 > len { return "client hello: length overruns buffer" } + if body_len + 4 != len { return "client hello: the message length is not its body's" } p = 4 - // legacy_version (2) -- always 0x0303 in TLS 1.3; the real version is in + // legacy_version (2) -- 0x0303 in TLS 1.3; the real version is in // the supported_versions extension. if p + 2 > len { return "client hello: truncated version" } p = p + 2 @@ -697,7 +787,7 @@ parse_client_hello(msg: byte[], out: *ChParsed) -> string { if p + 2 > len { return "client hello: truncated cipher suites length" } cs_len = get16(msg, p) p = p + 2 - if p + cs_len > len { return "client hello: cipher suites overrun" } + if cs_len < 2 || cs_len % 2 != 0 || p + cs_len > len { return "client hello: malformed cipher suites" } cs_end = p + cs_len while p + 1 < cs_end { cs = get16(msg, p) @@ -708,107 +798,582 @@ parse_client_hello(msg: byte[], out: *ChParsed) -> string { } p = p + 2 } - p = cs_end - if out.cipher_suite == 0 { return "client hello: no mutually supported cipher suite" } - // legacy_compression_methods<1..2^8-1> -- must contain only null(0). + // legacy_compression_methods<1..2^8-1>: null(0) must be among them + // (RFC 5246 7.4.1.2), and TLS 1.3 wants it alone (checked below). if p + 1 > len { return "client hello: truncated compression" } comp_len = get8(msg, p) p = p + 1 - if p + comp_len > len { return "client hello: compression overrun" } + if comp_len < 1 || p + comp_len > len { return "client hello: malformed compression methods" } + comp_null = 0 + k = 0 + while k < comp_len { + if get8(msg, p + k) == 0 { comp_null = 1 } + k = k + 1 + } + comp_only_null = 0 + if comp_len == 1 && comp_null == 1 { comp_only_null = 1 } p = p + comp_len + if comp_null == 0 { + out.alert = ALERT_ILLEGAL_PARAMETER + return "client hello: the compression methods do not include null" + } - // extensions<8..2^16-1> - if p + 2 > len { return "client hello: truncated extensions length" } + // extensions<8..2^16-1>, which fill the message (#3357). + if p + 2 > len { return "client hello: no extensions" } ext_total = get16(msg, p) p = p + 2 ext_end = p + ext_total - if ext_end > len { return "client hello: extensions overrun" } - - while p + 4 <= ext_end { - ext_type = get16(msg, p) - ext_len = get16(msg, p + 2) - d = p + 4 - if d + ext_len > ext_end { return "client hello: extension overruns" } - - if ext_type == 0x0000 { - // server_name: presence is all a server needs here; the name - // itself only matters once there is more than one certificate. - out.has_sni = 1 - } - if ext_type == 0x002b { - // supported_versions<2..254>: list of 2-byte versions, preceded - // by a 1-byte length. 0x0304 is TLS 1.3. - if ext_len >= 1 { - sv_len = get8(msg, d) - q = d + 1 - sv_end = q + sv_len - if sv_end > d + ext_len { sv_end = d + ext_len } - while q + 1 < sv_end { - if get16(msg, q) == 0x0304 { out.saw_supported_versions = 1 } - q = q + 2 - } - } - } - if ext_type == 0x0033 { - // key_share: client_shares<0..2^16-1> of { group(2), len(2), key }. - // Prefer post-quantum X25519MLKEM768 (1216 bytes) if offered, - // otherwise fallback to classical X25519 (32 bytes). - if ext_len >= 2 { - ks_total = get16(msg, d) - q = d + 2 - ks_end = q + ks_total - if ks_end > d + ext_len { ks_end = d + ext_len } - while q + 4 <= ks_end { - g = get16(msg, q) - klen = get16(msg, q + 2) - kstart = q + 4 - if kstart + klen > ks_end { q = ks_end } else { - if g == GROUP_X25519MLKEM768 { - if klen != 1216 { - return "client hello: invalid X25519MLKEM768 key share length" - } - if out.key_share != null { - bytes.free(out.key_share) - out.key_share = null - } - kb = bytes.new(klen) - bytes.copy_from_slice(kb, 0, msg[kstart..kstart + klen]) - out.group = g - out.key_share = kb - out.key_share_len = klen - } else if g == GROUP_X25519 && out.group == 0 { - if klen == 32 { - kb = bytes.new(klen) - bytes.copy_from_slice(kb, 0, msg[kstart..kstart + klen]) - out.group = g - out.key_share = kb - out.key_share_len = klen - } - } - q = kstart + klen + if ext_end != len { return "client hello: the extensions do not fill the message" } + + seen = bytes.new(0) + saw_groups = 0 + saw_key_share = 0 + share_groups = bytes.new(0) + err = "" + while p < ext_end && err == "" { + if p + 4 > ext_end { + err = "client hello: truncated extension" + } else { + ext_type = get16(msg, p) + ext_len = get16(msg, p + 2) + d = p + 4 + if d + ext_len > ext_end { + err = "client hello: extension overruns" + } else { + // One of each (RFC 8446 4.2). + if list16_has(seen, ext_type) == 1 { + err = "client hello: extension ${ext_type} appears twice" + } else { + list16_add(seen, ext_type) + if ext_type == 0x0029 && d + ext_len != ext_end { + // pre_shared_key must be the last extension (4.2.11). + out.alert = ALERT_ILLEGAL_PARAMETER + err = "client hello: pre_shared_key is not the last extension" + } else { + err = ch_extension(msg, ext_type, d, ext_len, out, share_groups) + if ext_type == 0x000a { saw_groups = 1 } + if ext_type == 0x0033 { saw_key_share = 1 } } } } + p = d + ext_len } - p = d + ext_len } + bytes.wipe_free(seen) + bytes.wipe_free(share_groups) + if err != "" { return err } + // What every TLS 1.3 ClientHello carries (RFC 8446 4.1.2, 9.2). if out.saw_supported_versions == 0 { + out.alert = ALERT_PROTOCOL_VERSION return "client hello: no supported_versions offering TLS 1.3" } + if comp_only_null == 0 { + out.alert = ALERT_ILLEGAL_PARAMETER + return "client hello: a TLS 1.3 ClientHello has compression methods other than null alone" + } + if out.cipher_suite == 0 { + out.alert = ALERT_HANDSHAKE_FAILURE + return "client hello: no mutually supported cipher suite" + } + if saw_groups == 0 { + out.alert = ALERT_MISSING_EXTENSION + return "client hello: no supported_groups" + } + if saw_key_share == 0 { + out.alert = ALERT_MISSING_EXTENSION + return "client hello: no key_share" + } if out.group == 0 { - // A real server answers this with a HelloRetryRequest naming a group - // it does support. Not implemented: this returns a clear error so the - // caller fails the handshake rather than silently negotiating - // something it cannot complete. - return "client hello: no key_share for a supported group (HelloRetryRequest not implemented)" + // No share the server can use. A group supported_groups names that + // it takes is asked for with a HelloRetryRequest (RFC 8446 4.2.8, + // #3325): out.retry_group says which, the server's preference + // first. Without one, nothing can be agreed. + if (out.groups & GROUPBIT_X25519MLKEM768) != 0 { + out.retry_group = GROUP_X25519MLKEM768 + } else if (out.groups & GROUPBIT_X25519) != 0 { + out.retry_group = GROUP_X25519 + } else if (out.groups & GROUPBIT_SECP256R1) != 0 { + out.retry_group = GROUP_SECP256R1 + } else { + out.alert = ALERT_HANDSHAKE_FAILURE + return "client hello: no key_share for a supported group, and supported_groups names none the server takes" + } } + out.alert = 0 return "" } +// 1 when the 2-byte list `l` holds `v`. +fn list16_has(l: ptr, v: int) -> int { + k = 0 + while k + 1 < bytes.length(l) { + if (((bytes.get(l, k) & 0xFF) << 8) | (bytes.get(l, k + 1) & 0xFF)) == v { return 1 } + k = k + 2 + } + return 0 +} + +fn list16_add(l: ptr, v: int) { + n = bytes.length(l) + bytes.set(l, n, (v >> 8) & 0xFF) + bytes.set(l, n + 1, v & 0xFF) +} + +// One ClientHello extension, `t` with its body at msg[d, d + n), into +// `out`: "" or why it is refused, out.alert saying with what (decode_error +// unless set). Each body must parse and fill the extension exactly +// (#3357). `share_groups` collects the key_share groups, which may not +// repeat (illegal_parameter, 4.2.8). +fn ch_extension(msg: byte[], t: int, d: int, n: int, out: *ChParsed, share_groups: ptr) -> string { + if t == 0x0000 { + // server_name: ServerNameList<1..2^16-1> of { name_type, HostName<1..2^16-1> }. + if n < 2 || get16(msg, d) + 2 != n || n == 2 { return "client hello: malformed server_name" } + q = d + 2 + while q < d + n { + if q + 3 > d + n { return "client hello: malformed server_name" } + hn = get16(msg, q + 1) + if hn == 0 || q + 3 + hn > d + n { return "client hello: malformed server_name" } + q = q + 3 + hn + } + out.has_sni = 1 + return "" + } + if t == 0x000a { + // supported_groups: named_group_list<2..2^16-1> of 2-byte groups; + // the ones a server takes, for a HelloRetryRequest (#3325). + if n < 2 || get16(msg, d) < 2 || get16(msg, d) % 2 != 0 || get16(msg, d) + 2 != n { + return "client hello: malformed supported_groups" + } + q = d + 2 + while q + 1 < d + n { + sg = get16(msg, q) + if sg == GROUP_X25519MLKEM768 { out.groups = out.groups | GROUPBIT_X25519MLKEM768 } + if sg == GROUP_X25519 { out.groups = out.groups | GROUPBIT_X25519 } + if sg == GROUP_SECP256R1 { out.groups = out.groups | GROUPBIT_SECP256R1 } + q = q + 2 + } + return "" + } + if t == 0x000d { + // signature_algorithms: supported_signature_algorithms<2..2^16-2>, + // 2-byte schemes. Recorded as SIGBIT_* for the ones a server key + // can sign with, so the server picks one the client verifies + // (RFC 8446 4.4.3, #3326). + if n < 2 { return "client hello: malformed signature_algorithms" } + sa_len = get16(msg, d) + if sa_len < 2 || sa_len % 2 != 0 || sa_len + 2 != n { return "client hello: malformed signature_algorithms" } + out.saw_sig_algs = 1 + q = d + 2 + while q + 1 < d + 2 + sa_len { + sch = get16(msg, q) + if sch == SIG_ECDSA_P256_SHA256 { out.sig_algs = out.sig_algs | SIGBIT_ECDSA_P256 } + if sch == SIG_ECDSA_P384_SHA384 { out.sig_algs = out.sig_algs | SIGBIT_ECDSA_P384 } + if sch == SIG_ECDSA_P521_SHA512 { out.sig_algs = out.sig_algs | SIGBIT_ECDSA_P521 } + if sch == SIG_RSA_PSS_RSAE_SHA256 { out.sig_algs = out.sig_algs | SIGBIT_RSA_PSS_SHA256 } + if sch == SIG_RSA_PSS_RSAE_SHA384 { out.sig_algs = out.sig_algs | SIGBIT_RSA_PSS_SHA384 } + if sch == SIG_RSA_PSS_RSAE_SHA512 { out.sig_algs = out.sig_algs | SIGBIT_RSA_PSS_SHA512 } + if sch == SIG_ED25519 { out.sig_algs = out.sig_algs | SIGBIT_ED25519 } + q = q + 2 + } + return "" + } + if t == 0x002b { + // supported_versions<2..254>: 2-byte versions after a 1-byte + // length. 0x0304 is TLS 1.3. + if n < 1 || get8(msg, d) + 1 != n || get8(msg, d) < 2 || get8(msg, d) % 2 != 0 { + return "client hello: malformed supported_versions" + } + q = d + 1 + while q + 1 < d + n { + if get16(msg, q) == 0x0304 { out.saw_supported_versions = 1 } + q = q + 2 + } + return "" + } + if t == 0x0033 { + // key_share: client_shares<0..2^16-1> of { group, key_exchange<1..2^16-1> }, + // filling the extension, each group once (4.2.8). The share of the + // group the server prefers, X25519MLKEM768, X25519, secp256r1, is + // taken; one of those of the wrong size is illegal_parameter. + if n < 2 || get16(msg, d) + 2 != n { return "client hello: malformed key_share" } + q = d + 2 + best = 0 + while q < d + n { + if q + 4 > d + n { return "client hello: malformed key_share" } + g = get16(msg, q) + klen = get16(msg, q + 2) + kstart = q + 4 + if klen == 0 || kstart + klen > d + n { return "client hello: malformed key_share" } + if list16_has(share_groups, g) == 1 { + out.alert = ALERT_ILLEGAL_PARAMETER + return "client hello: two key shares for group ${g}" + } + list16_add(share_groups, g) + rank = 0 + if g == GROUP_X25519MLKEM768 { + rank = 3 + if klen != 1216 { + out.alert = ALERT_ILLEGAL_PARAMETER + return "client hello: invalid X25519MLKEM768 key share length" + } + } else if g == GROUP_X25519 { + rank = 2 + if klen != 32 { + out.alert = ALERT_ILLEGAL_PARAMETER + return "client hello: an X25519 key share that is not 32 bytes" + } + } else if g == GROUP_SECP256R1 { + rank = 1 + if klen != 65 || get8(msg, kstart) != 4 { + out.alert = ALERT_ILLEGAL_PARAMETER + return "client hello: a secp256r1 key share that is not an uncompressed point" + } + } + if rank > best { + best = rank + if out.key_share != null { bytes.wipe_free(out.key_share) } + kb = bytes.new(klen) + bytes.copy_from_slice(kb, 0, msg[kstart..kstart + klen]) + out.group = g + out.key_share = kb + out.key_share_len = klen + } + q = kstart + klen + } + return "" + } + if t == 0x002f { + // certificate_authorities: DistinguishedName authorities<3..2^16-1>, + // each DistinguishedName<1..2^16-1> (4.2.4). + if n < 2 || get16(msg, d) + 2 != n || get16(msg, d) < 3 { return "client hello: malformed certificate_authorities" } + q = d + 2 + while q < d + n { + if q + 2 > d + n { return "client hello: malformed certificate_authorities" } + dl = get16(msg, q) + if dl == 0 || q + 2 + dl > d + n { return "client hello: malformed certificate_authorities" } + q = q + 2 + dl + } + return "" + } + if t == 0x002a { + // early_data is empty in a ClientHello (4.2.10). + if n != 0 { return "client hello: early_data with a body" } + out.has_early_data = 1 + return "" + } + if t == 0x002c { + // cookie<1..2^16-1> (4.2.2). + if n < 3 || get16(msg, d) + 2 != n { return "client hello: malformed cookie" } + out.has_cookie = 1 + return "" + } + if t == 0x002d { + // psk_key_exchange_modes<1..255> of 1-byte modes (4.2.9). + if n < 2 || get8(msg, d) + 1 != n { return "client hello: malformed psk_key_exchange_modes" } + return "" + } + return "" +} + +// ---- which extensions a server's message may carry (RFC 8446 4.2) ---- +// +// check_extensions(kind, exts, offered) -> (alert, error) for the +// extensions<..> block of a message the client received: (0, "") when +// every extension may appear there, else the alert to send and why. +// RFC 8446 4.2: a response to an extension the client did not send is +// refused with unsupported_extension; an extension the client did send but +// which the message may not carry, with illegal_parameter. A block that +// does not parse is decode_error, and a type that appears twice is refused +// as extensions_framed says. Only a CertificateRequest's unknown extensions +// are ignored (4.3.2); its certificate_authorities and signature lists must +// parse (#3365). +// +// `offered` says what the ClientHello carried beyond the extensions every +// ClientHello here has (supported_versions, supported_groups, +// signature_algorithms, key_share): OFFER_* bits. + +const MSG_SERVER_HELLO = 1 +const MSG_HELLO_RETRY = 2 +const MSG_ENCRYPTED_EXTENSIONS = 3 +const MSG_CERTIFICATE_ENTRY = 4 +const MSG_NEW_SESSION_TICKET = 5 +const MSG_CERTIFICATE_REQUEST = 6 + +const OFFER_SNI = 1 // server_name +const OFFER_ALPN = 2 // application_layer_protocol_negotiation +const OFFER_STATUS = 4 // status_request +const OFFER_PSK = 8 // pre_shared_key and psk_key_exchange_modes +const OFFER_EARLY = 16 // early_data + +const ALERT_DECODE_ERROR = 50 +const ALERT_ILLEGAL_PARAMETER = 47 +const ALERT_MISSING_EXTENSION = 109 +const ALERT_UNSUPPORTED_EXTENSION = 110 +const ALERT_UNEXPECTED_MESSAGE = 10 +const ALERT_HANDSHAKE_FAILURE = 40 +const ALERT_PROTOCOL_VERSION = 70 + +// 1 when the ClientHello (with `offered`) carried extension `t`. +fn ext_offered(t: int, offered: int) -> int { + if t == 43 || t == 10 || t == 13 || t == 51 { return 1 } + if t == 0 { return offered & OFFER_SNI } + if t == 16 { return (offered & OFFER_ALPN) / OFFER_ALPN } + if t == 5 { return (offered & OFFER_STATUS) / OFFER_STATUS } + if t == 41 || t == 45 { return (offered & OFFER_PSK) / OFFER_PSK } + if t == 42 { return (offered & OFFER_EARLY) / OFFER_EARLY } + return 0 +} + +// 1 when message `kind` may carry extension `t` (RFC 8446 4.2's table). +fn ext_allowed(kind: int, t: int) -> int { + if kind == MSG_SERVER_HELLO { + if t == 43 || t == 51 || t == 41 { return 1 } + return 0 + } + if kind == MSG_HELLO_RETRY { + if t == 43 || t == 51 || t == 44 { return 1 } + return 0 + } + if kind == MSG_ENCRYPTED_EXTENSIONS { + if t == 0 || t == 10 || t == 16 || t == 42 { return 1 } + return 0 + } + if kind == MSG_CERTIFICATE_ENTRY { + if t == 5 || t == 18 { return 1 } + return 0 + } + if kind == MSG_NEW_SESSION_TICKET { + if t == 42 || t == 62 { return 1 } + return 0 + } + return 1 +} + +// 1 when this client knows extension `t`: what it sends or parses, and the +// ones its peers' messages may carry. A NewSessionTicket ignores every +// other (RFC 8446 4.6.1). +fn ext_recognized(t: int) -> int { + if t == 0 || t == 5 || t == 10 || t == 13 || t == 16 || t == 18 { return 1 } + if t == 41 || t == 42 || t == 43 || t == 44 || t == 45 || t == 51 || t == 62 { return 1 } + return 0 +} + +// The body rules of the extensions a server may answer with: server_name +// and early_data empty in EncryptedExtensions, ALPN naming exactly one +// protocol, a status_request in a CertificateEntry holding an OCSP +// response; a CertificateRequest's certificate_authorities and signature +// lists well formed. (alert, error) as check_extensions. +fn ext_body_check(kind: int, t: int, b: byte[], p: int, n: int) -> (int, string) { + if kind == MSG_CERTIFICATE_REQUEST && t == 47 { + // DistinguishedName authorities<3..2^16-1>, each + // DistinguishedName<1..2^16-1> (RFC 8446 4.2.4): a sender with no + // name to list omits the extension. + if n < 2 || get16(b, p) != n - 2 || n - 2 < 3 { + return ALERT_DECODE_ERROR, "extensions: certificate_authorities must list a name and fill its extension" + } + q = p + 2 + while q < p + n { + if q + 2 > p + n { return ALERT_DECODE_ERROR, "extensions: a truncated name in certificate_authorities" } + dl = get16(b, q) + if dl == 0 || q + 2 + dl > p + n { return ALERT_DECODE_ERROR, "extensions: a name in certificate_authorities is empty or overruns it" } + q = q + 2 + dl + } + } + if kind == MSG_CERTIFICATE_REQUEST && (t == 13 || t == 50) { + // SignatureScheme supported_signature_algorithms<2..2^16-2> (4.2.3). + if n < 4 || get16(b, p) != n - 2 || (n - 2) % 2 != 0 { + return ALERT_DECODE_ERROR, "extensions: a signature algorithm list that is empty, odd or does not fill its extension" + } + } + if kind == MSG_ENCRYPTED_EXTENSIONS && (t == 0 || t == 42) && n != 0 { + return ALERT_DECODE_ERROR, "encrypted extensions: extension ${t} must be empty" + } + if kind == MSG_ENCRYPTED_EXTENSIONS && t == 16 { + // ProtocolNameList<2> holding exactly one ProtocolName<1>. + if n < 3 || get16(b, p) != n - 2 || get8(b, p + 2) != n - 3 || n - 3 < 1 { + return ALERT_DECODE_ERROR, "encrypted extensions: ALPN must name exactly one protocol" + } + } + if kind == MSG_NEW_SESSION_TICKET && t == 42 && n != 4 { + return ALERT_DECODE_ERROR, "extensions: early_data must hold max_early_data_size" + } + if kind == MSG_NEW_SESSION_TICKET && t == 62 { + // TLS flags (draft-ietf-tls-tlsflags): flags<1..255>, minimally + // encoded, so the last byte is never zero (#3319). + if n < 2 || get8(b, p) != n - 1 { + return ALERT_DECODE_ERROR, "extensions: flags must hold one to 255 bytes" + } + if get8(b, p + n - 1) == 0 { + return ALERT_ILLEGAL_PARAMETER, "extensions: flags end in a zero byte" + } + } + return 0, "" +} + +check_extensions(kind: int, exts: byte[], offered: int) -> (int, string) { + if exts.len < 0 { panic("tls13_hs.check_extensions: the `exts` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } + int n = exts.len + // The block first, whole: each extension inside it, and no type twice, + // before any one is judged, so that a repeated extension is refused as + // one whatever else is wrong with it. + fa, ferr = extensions_framed(kind, exts) + if fa != 0 { return fa, ferr } + p = 0 + while p < n { + t = get16(exts, p) + el = get16(exts, p + 2) + if kind == MSG_NEW_SESSION_TICKET { + // A ticket's extensions answer nothing: the ones this client + // does not know are ignored, the ones it knows must belong in a + // ticket and parse (RFC 8446 4.6.1, 4.2, #3319). + if ext_recognized(t) == 1 && ext_allowed(kind, t) == 0 { + return ALERT_ILLEGAL_PARAMETER, "extensions: extension ${t} may not appear in a NewSessionTicket" + } + na, nerr = ext_body_check(kind, t, exts, p + 4, el) + if na != 0 { return na, nerr } + } else if kind == MSG_CERTIFICATE_REQUEST { + ra, rerr = ext_body_check(kind, t, exts, p + 4, el) + if ra != 0 { return ra, rerr } + } else { + cookie = 0 + if kind == MSG_HELLO_RETRY && t == 44 { cookie = 1 } + if ext_offered(t, offered) == 0 && cookie == 0 { + return ALERT_UNSUPPORTED_EXTENSION, "extensions: extension ${t} answers nothing the client sent" + } + if ext_allowed(kind, t) == 0 { + return ALERT_ILLEGAL_PARAMETER, "extensions: extension ${t} may not appear in this message" + } + ba, berr = ext_body_check(kind, t, exts, p + 4, el) + if ba != 0 { return ba, berr } + } + p = p + 4 + el + } + return 0, "" +} + +// The extensions block `exts` of message `kind` as a block: (0, "") when +// every extension lies inside it and no type comes twice (RFC 8446 4.2). +// One that overruns is decode_error; a repeated type is decode_error in +// EncryptedExtensions, where the block is one list of answers, and +// illegal_parameter in the other messages, as BoringSSL sends them. +fn extensions_framed(kind: int, exts: byte[]) -> (int, string) { + int n = exts.len + seen = bytes.new(0) + p = 0 + while p < n { + if p + 4 > n { + bytes.wipe_free(seen) + return ALERT_DECODE_ERROR, "extensions: truncated" + } + t = get16(exts, p) + el = get16(exts, p + 2) + if p + 4 + el > n { + bytes.wipe_free(seen) + return ALERT_DECODE_ERROR, "extensions: extension ${t} overruns its block" + } + // The types seen so far, two bytes each. + k = 0 + while k + 1 < bytes.length(seen) { + if (((bytes.get(seen, k) & 0xFF) << 8) | (bytes.get(seen, k + 1) & 0xFF)) == t { + bytes.wipe_free(seen) + if kind == MSG_ENCRYPTED_EXTENSIONS { return ALERT_DECODE_ERROR, "extensions: extension ${t} appears twice" } + return ALERT_ILLEGAL_PARAMETER, "extensions: extension ${t} appears twice" + } + k = k + 2 + } + sl = bytes.length(seen) + bytes.set(seen, sl, (t >> 8) & 0xFF) + bytes.set(seen, sl + 1, t & 0xFF) + p = p + 4 + el + } + bytes.wipe_free(seen) + return 0, "" +} + +// check_certificate(body, offered) -> (alert, error) for the body of a +// server's Certificate message (RFC 8446 4.4.2): an empty +// certificate_request_context, a certificate_list of at least one entry +// that fills the message exactly, each entry's cert_data non-empty and its +// extensions what check_extensions allows in a CertificateEntry, a +// status_request holding exactly one OCSP CertificateStatus. (0, "") when +// it is well formed. +check_certificate(body: byte[], offered: int) -> (int, string) { + if body.len < 0 { panic("tls13_hs.check_certificate: the `body` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } + int n = body.len + if n < 4 { return ALERT_DECODE_ERROR, "certificate: truncated" } + if get8(body, 0) != 0 { return ALERT_DECODE_ERROR, "certificate: a server's certificate_request_context must be empty" } + list_len = get24(body, 1) + if 4 + list_len != n { return ALERT_DECODE_ERROR, "certificate: the certificate list does not fill the message" } + if list_len == 0 { return ALERT_DECODE_ERROR, "certificate: no certificate" } + p = 4 + while p < n { + if p + 3 > n { return ALERT_DECODE_ERROR, "certificate: truncated entry" } + cl = get24(body, p) + if cl == 0 || p + 3 + cl + 2 > n { return ALERT_DECODE_ERROR, "certificate: a cert_data overruns the list" } + q = p + 3 + cl + el = get16(body, q) + if q + 2 + el > n { return ALERT_DECODE_ERROR, "certificate: an entry's extensions overrun the list" } + a, err = check_extensions(MSG_CERTIFICATE_ENTRY, body[q + 2..q + 2 + el], offered) + if a != 0 { return a, "certificate: ${err}" } + // A status_request answer is one CertificateStatus { status_type + // ocsp(1), OCSPResponse<1..2^24-1> } (RFC 8446 4.4.2.1, RFC 6066 8). + r = q + 2 + while r + 4 <= q + 2 + el { + t = get16(body, r) + tl = get16(body, r + 2) + if t == 5 { + if tl < 5 || get8(body, r + 4) != 1 || 4 + get24(body, r + 5) != tl || get24(body, r + 5) == 0 { + return ALERT_DECODE_ERROR, "certificate: a status_request answer is not one OCSP response" + } + } + r = r + 4 + tl + } + p = q + 2 + el + } + return 0, "" +} + +// check_certificate_request(body) -> (alert, error) for the body of a +// CertificateRequest in the handshake (RFC 8446 4.3.2): an empty +// certificate_request_context (only post-handshake authentication, which +// this client does not offer, fills it) and extensions that fill the rest, +// each once, are decode_error and illegal_parameter otherwise; without +// signature_algorithms, missing_extension (109) (#3324). An empty +// certificate_authorities, one with bytes after its names, or a signature +// list that is empty, odd or short is decode_error (4.2.4, 4.2.3, #3365). +check_certificate_request(body: byte[]) -> (int, string) { + if body.len < 0 { panic("tls13_hs.check_certificate_request: the `body` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } + int n = body.len + if n < 3 { return ALERT_DECODE_ERROR, "certificate request: truncated" } + if get8(body, 0) != 0 { return ALERT_DECODE_ERROR, "certificate request: a handshake's certificate_request_context must be empty" } + if 3 + get16(body, 1) != n { return ALERT_DECODE_ERROR, "certificate request: the extensions do not fill the message" } + a, err = check_extensions(MSG_CERTIFICATE_REQUEST, body[3..n], 0) + if a != 0 { return a, "certificate request: ${err}" } + p = 3 + while p + 4 <= n { + if get16(body, p) == 13 { return 0, "" } + p = p + 4 + get16(body, p + 2) + } + return ALERT_MISSING_EXTENSION, "certificate request: no signature_algorithms" +} + +// parse_server_hello(msg, out) -> "" or why the ServerHello (or +// HelloRetryRequest) is refused; out.alert is then the alert to send: +// decode_error for a message that does not parse, protocol_version for one +// that is not TLS 1.3, else what check_extensions or the field says. parse_server_hello(msg: byte[], out: *ShParsed) -> string { if msg.len < 0 { panic("tls13_hs.parse_server_hello: the `msg` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } + out.alert = 0 + err = parse_server_hello_fields(msg, out) + if err != "" && out.alert == 0 { + out.alert = ALERT_ILLEGAL_PARAMETER + } + return err +} + +fn parse_server_hello_fields(msg: byte[], out: *ShParsed) -> string { int len = msg.len out.version = 0 out.cipher_suite = 0 @@ -817,45 +1382,68 @@ parse_server_hello(msg: byte[], out: *ShParsed) -> string { out.key_share_len = 0 out.is_hello_retry = 0 out.psk_accepted = 0 + out.session_id_off = 0 + out.session_id_len = 0 + out.selected_identity = -1 + out.cookie_off = 0 + out.cookie_len = 0 - if len < 4 { return "server hello: truncated header" } + if len < 4 { out.alert = ALERT_DECODE_ERROR; return "server hello: truncated header" } if get8(msg, 0) != HS_SERVER_HELLO { return "server hello: wrong msg type" } body_len = get24(msg, 1) - if body_len + 4 > len { return "server hello: length overruns buffer" } + if body_len + 4 > len { out.alert = ALERT_DECODE_ERROR; return "server hello: length overruns buffer" } p = 4 - // legacy_version (2) — ignored, real version is in supported_versions - if p + 2 > len { return "server hello: truncated version" } + // legacy_version (2): TLS 1.3 fixes it at 0x0303, the real version + // travelling in supported_versions (RFC 8446 4.1.3, #3318). + if p + 2 > len { out.alert = ALERT_DECODE_ERROR; return "server hello: truncated version" } + if get16(msg, p) != 0x0303 { return "server hello: legacy_version ${get16(msg, p)} is not 0x0303" } p = p + 2 // random[32] — a HelloRetryRequest is a ServerHello whose random equals the // special SHA-256("HelloRetryRequest") sentinel (RFC 8446 §4.1.3). - if p + 32 > len { return "server hello: truncated random" } + if p + 32 > len { out.alert = ALERT_DECODE_ERROR; return "server hello: truncated random" } if is_hrr_random(msg, p) == 1 { out.is_hello_retry = 1 } p = p + 32 // legacy_session_id_echo<0..32> - if p + 1 > len { return "server hello: truncated session id len" } + if p + 1 > len { out.alert = ALERT_DECODE_ERROR; return "server hello: truncated session id len" } sid_len = get8(msg, p) + if sid_len > 32 || p + 1 + sid_len > len { out.alert = ALERT_DECODE_ERROR; return "server hello: bad legacy_session_id_echo" } + out.session_id_off = p + 1 + out.session_id_len = sid_len p = p + 1 + sid_len // cipher_suite (2) - if p + 2 > len { return "server hello: truncated cipher suite" } + if p + 2 > len { out.alert = ALERT_DECODE_ERROR; return "server hello: truncated cipher suite" } out.cipher_suite = get16(msg, p) p = p + 2 - // legacy_compression_method (1) — must be 0 - if p + 1 > len { return "server hello: truncated compression" } - if get8(msg, p) != 0 { return "server hello: nonzero compression method" } + // legacy_compression_method (1): 0, the only value the field may hold + // (RFC 8446 4.1.3), so any other is a message that does not parse + // (decode_error, as BoringSSL sends, #3364). + if p + 1 > len { out.alert = ALERT_DECODE_ERROR; return "server hello: truncated compression" } + if get8(msg, p) != 0 { out.alert = ALERT_DECODE_ERROR; return "server hello: nonzero compression method" } p = p + 1 // extensions<6..2^16-1> - if p + 2 > len { return "server hello: truncated extensions length" } + if p + 2 > len { out.alert = ALERT_DECODE_ERROR; return "server hello: truncated extensions length" } ext_total = get16(msg, p) p = p + 2 ext_end = p + ext_total - if ext_end > len { return "server hello: extensions overrun" } + if ext_end > len { out.alert = ALERT_DECODE_ERROR; return "server hello: extensions overrun" } + // The extensions end the message (RFC 8446 4.1.3, #3318). + if ext_end != 4 + body_len { out.alert = ALERT_DECODE_ERROR; return "server hello: the extensions do not fill the message" } + kind = MSG_SERVER_HELLO + if out.is_hello_retry == 1 { kind = MSG_HELLO_RETRY } + // Every extension the ClientHello may carry counts as offered here: + // the client refuses a pre_shared_key it did not offer itself. + xa, xerr = check_extensions(kind, msg[p..ext_end], OFFER_SNI | OFFER_ALPN | OFFER_STATUS | OFFER_PSK | OFFER_EARLY) + if xa != 0 { + out.alert = xa + return "server hello: ${xerr}" + } while p + 4 <= ext_end { ext_type = get16(msg, p) ext_len = get16(msg, p + 2) p = p + 4 - if p + ext_len > ext_end { return "server hello: extension overrun" } + if p + ext_len > ext_end { out.alert = ALERT_DECODE_ERROR; return "server hello: extension overrun" } if ext_type == 43 { // supported_versions (server form: single selected version) @@ -866,14 +1454,14 @@ parse_server_hello(msg: byte[], out: *ShParsed) -> string { if out.is_hello_retry == 1 { // HRR key_share carries only the selected group (2 bytes), // no key — it tells the client which group to retry with. - if ext_len < 2 { return "server hello: bad HRR key_share" } + if ext_len != 2 { out.alert = ALERT_DECODE_ERROR; return "hello retry request: a key_share that is not one group" } out.group = get16(msg, p) } else { // key_share (server form: single KeyShareEntry {group, key}) - if ext_len < 4 { return "server hello: bad key_share" } + if ext_len < 4 { out.alert = ALERT_DECODE_ERROR; return "server hello: bad key_share" } out.group = get16(msg, p) ks_len = get16(msg, p + 2) - if 4 + ks_len > ext_len { return "server hello: key_share overrun" } + if 4 + ks_len != ext_len { out.alert = ALERT_DECODE_ERROR; return "server hello: a key_share that does not fill its extension" } ks = bytes.new(ks_len) if ks_len > 0 { bytes.set(ks, ks_len - 1, 0) } j = 0 @@ -888,12 +1476,26 @@ parse_server_hello(msg: byte[], out: *ShParsed) -> string { if ext_type == 41 { // pre_shared_key (server form: selected_identity uint16). Its // presence means the server accepted our PSK — resumption is on. + // Which identity it took is the client's to check (#3318). + if ext_len != 2 { out.alert = ALERT_DECODE_ERROR; return "server hello: a pre_shared_key that is not one selected_identity" } out.psk_accepted = 1 + out.selected_identity = get16(msg, p) + } + if ext_type == 44 { + // cookie<1..2^16-1>, only in a HelloRetryRequest (check_extensions + // saw to that), echoed in the second ClientHello (#3325). + if ext_len < 3 || 2 + get16(msg, p) != ext_len { + out.alert = ALERT_DECODE_ERROR + return "hello retry request: a cookie that is empty or does not fill its extension" + } + out.cookie_off = p + 2 + out.cookie_len = ext_len - 2 } p = p + ext_len } if out.version != 0x0304 { + out.alert = 70 return "server hello: not TLS 1.3 (no supported_versions=0x0304): the server negotiated an older TLS version, and this client speaks TLS 1.3 only" } return "" @@ -902,29 +1504,38 @@ parse_server_hello(msg: byte[], out: *ShParsed) -> string { // The HelloRetryRequest sentinel random = SHA-256("HelloRetryRequest") // (RFC 8446 §4.1.3). Returns 1 if the 32 bytes at msg[off..off+32) match. fn is_hrr_random(msg: byte[], off: int) -> int { - // RFC 8446 §4.1.3 HelloRetryRequest special Random = - // SHA-256("HelloRetryRequest"): - // CF 21 AD 74 E5 9A 61 11 BE 1D FA 3A 06 B4 4E 8C - // 84 5C 76 30 04 8B 15 D5 A0 0D 20 3A 15 D5 B2 6E - hrr = bytes.new(32) - bytes.set(hrr, 0, 0xCF); bytes.set(hrr, 1, 0x21); bytes.set(hrr, 2, 0xAD); bytes.set(hrr, 3, 0x74) - bytes.set(hrr, 4, 0xE5); bytes.set(hrr, 5, 0x9A); bytes.set(hrr, 6, 0x61); bytes.set(hrr, 7, 0x11) - bytes.set(hrr, 8, 0xBE); bytes.set(hrr, 9, 0x1D); bytes.set(hrr, 10, 0xFA); bytes.set(hrr, 11, 0x3A) - bytes.set(hrr, 12, 0x06); bytes.set(hrr, 13, 0xB4); bytes.set(hrr, 14, 0x4E); bytes.set(hrr, 15, 0x8C) - bytes.set(hrr, 16, 0x84); bytes.set(hrr, 17, 0x5C); bytes.set(hrr, 18, 0x76); bytes.set(hrr, 19, 0x30) - bytes.set(hrr, 20, 0x04); bytes.set(hrr, 21, 0x8B); bytes.set(hrr, 22, 0x15); bytes.set(hrr, 23, 0xD5) - bytes.set(hrr, 24, 0xA0); bytes.set(hrr, 25, 0x0D); bytes.set(hrr, 26, 0x20); bytes.set(hrr, 27, 0x3A) - bytes.set(hrr, 28, 0x15); bytes.set(hrr, 29, 0xD5); bytes.set(hrr, 30, 0xB2); bytes.set(hrr, 31, 0x6E) + hrr = hrr_random() same = 1 i = 0 while i < 32 { if (msg[off + i] & 0xFF) != (bytes.get(hrr, i) & 0xFF) { same = 0 } i = i + 1 } - bytes.free(hrr) + bytes.wipe_free(hrr) return same } +// The HelloRetryRequest random (RFC 8446 4.1.3), SHA-256 of the ASCII +// "HelloRetryRequest": CF 21 AD 74 E5 9A 61 11 BE 1D 8C 02 1E 65 B8 91 C2 A2 +// 11 16 7A BB 8C 5E 07 9E 09 E2 C8 A8 33 9C. It is computed, not spelled out: +// a hand-copied constant had the second half wrong (#3325), so a real +// server's HelloRetryRequest read as a ServerHello and ours was refused. +// Fresh 32 bytes. +fn hrr_random() -> ptr { + s = "HelloRetryRequest" + n = string_length(s) + t = bytes.new(n) + i = 0 + while i < n { + bytes.set(t, i, string_char_at_n(s, n, i) & 0xFF) + i = i + 1 + } + ctx, _err = sha2.new("sha256") + sha2.update(ctx, bytes.view(t)[0..n]) + bytes.wipe_free(t) + return sha2.final_bytes(ctx) +} + // EncryptedExtensions handshake message (RFC 8446 §4.3.1) // Body: extensions<2> (empty in our subset) encrypted_extensions() -> ptr { @@ -1002,3 +1613,223 @@ finished_msg(vd: ptr, vd_len: int) -> ptr { bytes.copy_from_bytes(m, 4, vd, 0, vd_len) return m } + +// KeyUpdate handshake message (RFC 8446 4.6.3): `request` is +// KEY_UPDATE_NOT_REQUESTED or KEY_UPDATE_REQUESTED. Fresh bytes (caller +// frees). +key_update_msg(request: int) -> ptr { + m = bytes.new(5) + bytes.set(m, 0, HS_KEY_UPDATE) + put24(m, 1, 1) + bytes.set(m, 4, request & 0xFF) + return m +} + +// The request_update of a KeyUpdate body (RFC 8446 4.6.3): (request, 0, "") +// or (-1, alert, why): decode_error for a body that is not one byte, +// illegal_parameter for a value other than KEY_UPDATE_NOT_REQUESTED and +// KEY_UPDATE_REQUESTED. +key_update_request(body: byte[]) -> (int, int, string) { + if body.len < 0 { panic("tls13_hs.key_update_request: the `body` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } + if body.len != 1 { return -1, ALERT_DECODE_ERROR, "key update: the body is not one byte" } + v = body[0] & 0xFF + if v > KEY_UPDATE_REQUESTED { return -1, ALERT_ILLEGAL_PARAMETER, "key update: request_update ${v} is neither 0 nor 1" } + return v, 0, "" +} + +// ---- NewSessionTicket (RFC 8446 4.6.1) ---- + +// A NewSessionTicket body, as parse_new_session_ticket reads it: the +// nonce and the ticket are where they are in the body. +struct NstParsed { + lifetime: int // ticket_lifetime, seconds + age_add: int // ticket_age_add + nonce_off: int + nonce_len: int + ticket_off: int + ticket_len: int + max_early_data: int // early_data's max_early_data_size; 0 without it + alert: int // the alert to send when it is refused +} + +// parse_new_session_ticket(body, out) -> "" or why the body of a +// NewSessionTicket is refused, `out.alert` then saying what to send +// (#3319): decode_error for a body that does not parse, an empty ticket +// (ticket<1..2^16-1>) and bytes after the extensions; what check_extensions +// says about those (a duplicate is illegal_parameter, an early_data that is +// not four bytes decode_error, TLS flags empty or ending in a zero byte +// decode_error or illegal_parameter; an extension this client does not +// know is ignored, as 4.6.1 asks). +parse_new_session_ticket(body: byte[], out: *NstParsed) -> string { + if body.len < 0 { panic("tls13_hs.parse_new_session_ticket: the `body` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } + int n = body.len + out.lifetime = 0 + out.age_add = 0 + out.nonce_off = 0 + out.nonce_len = 0 + out.ticket_off = 0 + out.ticket_len = 0 + out.max_early_data = 0 + out.alert = ALERT_DECODE_ERROR + if n < 9 { return "new session ticket: truncated" } + out.lifetime = (get16(body, 0) << 16) | get16(body, 2) + out.age_add = (get16(body, 4) << 16) | get16(body, 6) + nl = get8(body, 8) + p = 9 + if p + nl + 2 > n { return "new session ticket: truncated nonce" } + out.nonce_off = p + out.nonce_len = nl + p = p + nl + tl = get16(body, p) + p = p + 2 + if tl == 0 { return "new session ticket: an empty ticket" } + if p + tl + 2 > n { return "new session ticket: truncated ticket" } + out.ticket_off = p + out.ticket_len = tl + p = p + tl + el = get16(body, p) + p = p + 2 + if p + el != n { return "new session ticket: the extensions do not fill the message" } + a, err = check_extensions(MSG_NEW_SESSION_TICKET, body[p..n], 0) + if a != 0 { + out.alert = a + return "new session ticket: ${err}" + } + q = p + while q + 4 <= n { + if get16(body, q) == 42 { + out.max_early_data = (get16(body, q + 4) << 16) | get16(body, q + 6) + } + q = q + 4 + get16(body, q + 2) + } + out.alert = 0 + return "" +} + +// ---- HelloRetryRequest (RFC 8446 4.1.4) ---- + +// A HelloRetryRequest (#3325): a ServerHello whose random is the +// HelloRetryRequest sentinel, echoing `session_id`, naming `cipher_suite`, +// and asking in key_share for a share of `group`. Fresh bytes (caller frees). +hello_retry_request(session_id: byte[], cipher_suite: int, group: int) -> ptr { + if session_id.len < 0 { panic("tls13_hs.hello_retry_request: the `session_id` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } + int sid_len = session_id.len + n = 4 + 2 + 32 + 1 + sid_len + 2 + 1 + 2 + 6 + 6 + m = bytes.new(n) + bytes.set(m, n - 1, 0) + put8(m, 0, HS_SERVER_HELLO) + put24(m, 1, n - 4) + off = put16(m, 4, 0x0303) + hrr = hrr_random() + off = put_bytes(m, off, bytes.view(hrr)[0..32]) + bytes.wipe_free(hrr) + off = put8(m, off, sid_len) + off = put_bytes(m, off, session_id) + off = put16(m, off, cipher_suite) + off = put8(m, off, 0) + off = put16(m, off, 12) + off = put16(m, off, 43) + off = put16(m, off, 2) + off = put16(m, off, 0x0304) + off = put16(m, off, 51) + off = put16(m, off, 2) + off = put16(m, off, group) + return m +} + +// client_hello_retry(ch1, group, share, cookie, out): the second +// ClientHello after a HelloRetryRequest, which RFC 8446 4.1.2 wants to be +// the first one, `ch1`, changed only as the HelloRetryRequest asks: when +// `group` is not 0, key_share holds the one KeyShareEntry for it with +// `share`; a non-empty `cookie` goes in a cookie extension; early_data is +// gone; and a pre_shared_key, still the last extension, has its binders +// zeroed for the caller to compute again over the new transcript (#3325). +// Fills `out` as client_hello_psk does: binder_off and truncated_len are 0 +// without a pre_shared_key. Caller frees out.buf. +client_hello_retry(ch1: byte[], group: int, share: byte[], cookie: byte[], out: *PskCh) { + if ch1.len < 0 { panic("tls13_hs.client_hello_retry: the `ch1` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } + if share.len < 0 { panic("tls13_hs.client_hello_retry: the `share` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } + if cookie.len < 0 { panic("tls13_hs.client_hello_retry: the `cookie` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } + int n = ch1.len + int share_len = share.len + int cookie_len = cookie.len + out.buf = null + out.buf_len = 0 + out.binder_off = 0 + out.truncated_len = 0 + // Fixed fields: version, random, session id, cipher suites, compression. + p = 4 + 2 + 32 + p = p + 1 + get8(ch1, p) + p = p + 2 + get16(ch1, p) + p = p + 1 + get8(ch1, p) + ext_end = p + 2 + get16(ch1, p) + buf = bytes.new(n + share_len + cookie_len + 64) + bytes.set(buf, n + share_len + cookie_len + 63, 0) + off = put_bytes(buf, 0, ch1[0..p]) + ext_len_pos = off + off = off + 2 + q = p + 2 + cookie_done = 0 + while q + 4 <= ext_end { + t = get16(ch1, q) + el = get16(ch1, q + 2) + if t == 41 && cookie_len > 0 && cookie_done == 0 { + off = put_cookie(buf, off, cookie) + cookie_done = 1 + } + if t == 51 && group != 0 { + off = put16(buf, off, 51) + off = put16(buf, off, 2 + 4 + share_len) + off = put16(buf, off, 4 + share_len) + off = put16(buf, off, group) + off = put16(buf, off, share_len) + off = put_bytes(buf, off, share) + } else if t == 42 { + // No early data after a HelloRetryRequest (4.1.2). + } else if t == 41 { + // identities<2>, then binders<2> of binder<1> entries, zeroed. + off = put16(buf, off, 41) + off = put16(buf, off, el) + il = get16(ch1, q + 4) + off = put_bytes(buf, off, ch1[q + 4..q + 6 + il]) + out.truncated_len = off + b = q + 6 + il + bl = get16(ch1, b) + off = put16(buf, off, bl) + r = b + 2 + first = 1 + while r < b + 2 + bl { + one = get8(ch1, r) + off = put8(buf, off, one) + if first == 1 { out.binder_off = off } + first = 0 + k = 0 + while k < one { + off = put8(buf, off, 0) + k = k + 1 + } + r = r + 1 + one + } + } else { + off = put_bytes(buf, off, ch1[q..q + 4 + el]) + } + q = q + 4 + el + } + if cookie_len > 0 && cookie_done == 0 { off = put_cookie(buf, off, cookie) } + put16(buf, ext_len_pos, off - ext_len_pos - 2) + put24(buf, 1, off - 4) + m = bytes.new(off) + bytes.copy_from_bytes(m, 0, buf, 0, off) + bytes.wipe_free(buf) + out.buf = m + out.buf_len = off +} + +// The cookie extension (44) holding `cookie`; the offset past it. +fn put_cookie(buf: ptr, at: int, cookie: byte[]) -> int { + int cl = cookie.len + off = put16(buf, at, 44) + off = put16(buf, off, 2 + cl) + off = put16(buf, off, cl) + return put_bytes(buf, off, cookie) +} diff --git a/std/cryptography/tls13_kdf/module.ae b/std/cryptography/tls13_kdf/module.ae index 08d12dd2c..15011cf5d 100644 --- a/std/cryptography/tls13_kdf/module.ae +++ b/std/cryptography/tls13_kdf/module.ae @@ -96,7 +96,7 @@ fn build_hkdf_label(label: string, context: byte[], out_len: int) -> (ptr, int) expand_label(algo: string, secret: byte[], label: string, context: byte[], out_len: int) -> ptr { hl, hl_len = build_hkdf_label(label, context, out_len) okm = hkdf.expand(algo, secret, bytes.view(hl)[0..hl_len], out_len) - bytes.free(hl) + bytes.wipe_free(hl) return okm } diff --git a/std/cryptography/tls13_ks/module.ae b/std/cryptography/tls13_ks/module.ae index b946d4ae4..c73655f1e 100644 --- a/std/cryptography/tls13_ks/module.ae +++ b/std/cryptography/tls13_ks/module.ae @@ -37,12 +37,15 @@ // traffic_secret(algo, secret, label, th) -> ptr (Derive-Secret) // write_key(algo, traffic_secret, key_len) -> ptr // write_iv(algo, traffic_secret) -> ptr (12 bytes) +// next_traffic_secret(algo, secret) -> ptr (KeyUpdate, 7.2) +// key_update(algo, ctx, secret) -> ptr (rekeys ctx too) // // All results are freshly allocated std.bytes (caller frees). import std.bytes import std.cryptography.hkdf import std.cryptography.tls13_kdf +import std.cryptography.tls13_record extern malloc(size: int) -> ptr extern free(p: ptr) @@ -50,7 +53,7 @@ extern free(p: ptr) exports( early_secret, early_secret_psk, psk_from_resumption, binder_key, early_traffic_secret, handshake_secret, master_secret, - traffic_secret, write_key, write_iv, + traffic_secret, next_traffic_secret, key_update, free_secret, write_key, write_iv, IV_LEN ) @@ -80,8 +83,8 @@ early_secret(algo: string) -> ptr { salt = zeros(hl) ikm = zeros(hl) prk = hkdf.extract(algo, bytes.view(salt)[0..hl], bytes.view(ikm)[0..hl]) - bytes.free(salt) - bytes.free(ikm) + bytes.wipe_free(salt) + bytes.wipe_free(ikm) return prk } @@ -91,7 +94,7 @@ early_secret_psk(algo: string, psk: byte[]) -> ptr { hl = hlen(algo) salt = zeros(hl) prk = hkdf.extract(algo, bytes.view(salt)[0..hl], psk) - bytes.free(salt) + bytes.wipe_free(salt) return prk } @@ -110,7 +113,7 @@ binder_key(algo: string, early: ptr) -> ptr { hl = hlen(algo) empty_th = empty_transcript_hash(algo) bk = tls13_kdf.derive_secret(algo, bytes.view(early)[0..hl], "res binder", bytes.view(empty_th)[0..hl]) - bytes.free(empty_th) + bytes.wipe_free(empty_th) return bk } @@ -140,8 +143,8 @@ handshake_secret(algo: string, early: ptr, ecdhe: byte[]) -> ptr { // salt = Derive-Secret(Early, "derived", Hash("")) salt = tls13_kdf.derive_secret(algo, bytes.view(early)[0..hl], "derived", bytes.view(empty_th)[0..hl]) prk = hkdf.extract(algo, bytes.view(salt)[0..hl], ecdhe) - bytes.free(empty_th) - bytes.free(salt) + bytes.wipe_free(empty_th) + bytes.wipe_free(salt) return prk } @@ -153,9 +156,9 @@ master_secret(algo: string, handshake: ptr) -> ptr { salt = tls13_kdf.derive_secret(algo, bytes.view(handshake)[0..hl], "derived", bytes.view(empty_th)[0..hl]) ikm = zeros(hl) prk = hkdf.extract(algo, bytes.view(salt)[0..hl], bytes.view(ikm)[0..hl]) - bytes.free(empty_th) - bytes.free(salt) - bytes.free(ikm) + bytes.wipe_free(empty_th) + bytes.wipe_free(salt) + bytes.wipe_free(ikm) return prk } @@ -175,16 +178,53 @@ write_key(algo: string, secret: byte[], key_len: int) -> ptr { hl = hlen(algo) empty = zeros(0) k = tls13_kdf.expand_label(algo, secret[0..hl], "key", bytes.view(empty)[0..0], key_len) - bytes.free(empty) + bytes.wipe_free(empty) return k } +// The next application traffic secret after a KeyUpdate (RFC 8446 7.2): +// HKDF-Expand-Label(secret, "traffic upd", "", Hash.length). Fresh bytes; +// `secret` is consumed: zeroed and freed, as 7.2 asks once its successor +// exists. +next_traffic_secret(algo: string, secret: @frees ptr) -> ptr { + hl = hlen(algo) + empty = zeros(0) + nxt = tls13_kdf.expand_label(algo, bytes.view(secret)[0..hl], "traffic upd", bytes.view(empty)[0..0], hl) + bytes.wipe_free(empty) + free_secret(secret) + return nxt +} + +// Move one direction of a connection on at a KeyUpdate (RFC 8446 4.6.3, +// 7.2): the next traffic secret from `secret`, which is consumed (zeroed and +// freed), and the record context `ctx` keyed afresh from it, its AEAD and +// key length unchanged and its sequence number 0. Returns the new secret +// (caller owns; hand it to the next key_update or free_secret). +key_update(algo: string, ctx: ptr, secret: @frees ptr) -> ptr { + nxt = next_traffic_secret(algo, secret) + rc = ctx as *tls13_record.RecordCtx + kl = rc.key_len + k = write_key(algo, bytes.view(nxt)[0..hlen(algo)], kl) + iv = write_iv(algo, nxt) + tls13_record.rekey(ctx, bytes.view(k)[0..kl], iv) + free_secret(k) + free_secret(iv) + return nxt +} + +// Zero a secret, then free it (null is ignored): bytes.wipe_free, whose +// zeroing the compiler cannot drop, where a loop of bytes.set before the +// free was a dead store (#3414). +free_secret(secret: @frees ptr) { + bytes.wipe_free(secret) +} + // Record write iv = HKDF-Expand-Label(traffic_secret, "iv", "", 12). write_iv(algo: string, secret: ptr) -> ptr { hl = hlen(algo) empty = zeros(0) iv = tls13_kdf.expand_label(algo, bytes.view(secret)[0..hl], "iv", bytes.view(empty)[0..0], IV_LEN) - bytes.free(empty) + bytes.wipe_free(empty) return iv } diff --git a/std/cryptography/tls13_record/module.ae b/std/cryptography/tls13_record/module.ae index f69372eed..3c8eb9a88 100644 --- a/std/cryptography/tls13_record/module.ae +++ b/std/cryptography/tls13_record/module.ae @@ -35,6 +35,7 @@ // AEAD is ChaCha20-Poly1305 (16-byte tag). AES-GCM support is a later add. import std.bytes +import std.mem import std.string import std.bits import std.cryptography.chacha20poly1305 @@ -44,7 +45,8 @@ extern malloc(size: int) -> ptr @extern("free") libc_free(p: ptr) exports( - new, new_aead, free_ctx, seal_record, open_record, nonce_for, + new, new_aead, rekey, free_ctx, seal_record, seal_records, open_record, nonce_for, check_header, + ALERT_UNEXPECTED_MESSAGE, ALERT_BAD_RECORD_MAC, ALERT_RECORD_OVERFLOW, ALERT_DECODE_ERROR, RecordCtx, OpenedRecord, CONTENT_HANDSHAKE, CONTENT_APPLICATION_DATA, CONTENT_ALERT, TAG_LEN, RECORD_HEADER_LEN, OPAQUE_APPLICATION_DATA, @@ -100,31 +102,55 @@ new(key: byte[], iv12: ptr) -> ptr { // is built once here (GCM only runs the cipher forward, for seal AND open). new_aead(aead_id: int, key: byte[], iv12: ptr) -> ptr { if key.len < 0 { panic("tls13_record.new_aead: the `key` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } - int key_len = key.len ctx = malloc(sizeof(RecordCtx)) as *RecordCtx + ctx.key = null + ctx.key_len = 0 + ctx.iv = null + ctx.seq = 0 + ctx.aead = aead_id + ctx.aes_ctx = null + rekey(ctx as ptr, key, iv12) + return ctx as ptr +} + +// Key `ctx` afresh with `key` and `iv12`, its AEAD unchanged, and start its +// sequence number again at 0, as a KeyUpdate does (RFC 8446 4.6.3, 5.3). +// Both are copied (the ctx owns its copies; the old ones are freed). +rekey(ctx: ptr, key: byte[], iv12: ptr) { + if key.len < 0 { panic("tls13_record.rekey: the `key` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } + c = ctx as *RecordCtx + int key_len = key.len k = bytes.new(key_len) if key_len > 0 { bytes.set(k, key_len - 1, 0) } bytes.copy_from_slice(k, 0, key[0..key_len]) iv = bytes.new(NONCE_LEN) bytes.set(iv, NONCE_LEN - 1, 0) bytes.copy_from_bytes(iv, 0, iv12, 0, NONCE_LEN) - ctx.key = k - ctx.key_len = key_len - ctx.iv = iv - ctx.seq = 0 - ctx.aead = aead_id - ctx.aes_ctx = null - if aead_id == AEAD_AES_128_GCM || aead_id == AEAD_AES_256_GCM { - ctx.aes_ctx = aes.new_encryptor(bytes.view(k)[0..key_len]) + wipe_free(c.key) + wipe_free(c.iv) + if c.aes_ctx != null { aes.free(c.aes_ctx) } + c.key = k + c.key_len = key_len + c.iv = iv + c.seq = 0 + c.aes_ctx = null + if c.aead == AEAD_AES_128_GCM || c.aead == AEAD_AES_256_GCM { + c.aes_ctx = aes.new_encryptor(bytes.view(k)[0..key_len]) } - return ctx as ptr +} + +// Zero a key buffer, then free it: a record key must not outlive its +// context (RFC 8446 7.2 asks that keys replaced by a KeyUpdate be deleted). +fn wipe_free(b: @frees ptr) { + bytes.wipe_free(b) } free_ctx(ctx: ptr) { c = ctx as *RecordCtx - bytes.free(c.key) - bytes.free(c.iv) + wipe_free(c.key) + wipe_free(c.iv) if c.aes_ctx != null { aes.free(c.aes_ctx) } + mem.wipe(ctx, sizeof(RecordCtx)) libc_free(ctx) } @@ -209,19 +235,54 @@ seal_record(ctx: ptr, content_type: int, plaintext: byte[]) -> ptr { bytes.set(rec, 4, ct_len & 0xFF) bytes.copy_from_bytes(rec, RECORD_HEADER_LEN, enc, 0, ct_len) - bytes.free(inner) - bytes.free(aad) - bytes.free(nonce) - bytes.free(enc) + bytes.wipe_free(inner) + bytes.wipe_free(aad) + bytes.wipe_free(nonce) + bytes.wipe_free(enc) c.seq = c.seq + 1 return rec } +// `plaintext` of `content_type` as records of at most CONTENT_MAX_PLAIN_LEN +// bytes of content each, back to back in one fresh buffer (caller frees), +// or null when one cannot be sealed (seal_record's limits). RFC 8446 5.1: +// a handshake message larger than a record is split across records, and +// application data is cut the same way (#3380). Empty `plaintext` is one +// empty record. +seal_records(ctx: ptr, content_type: int, plaintext: byte[]) -> ptr { + if plaintext.len < 0 { panic("tls13_record.seal_records: the `plaintext` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } + int n = plaintext.len + if n <= CONTENT_MAX_PLAIN_LEN { return seal_record(ctx, content_type, plaintext) } + out = bytes.new(n + (n / CONTENT_MAX_PLAIN_LEN + 1) * (RECORD_HEADER_LEN + 1 + TAG_LEN)) + olen = 0 + off = 0 + while off < n { + m = n - off + if m > CONTENT_MAX_PLAIN_LEN { m = CONTENT_MAX_PLAIN_LEN } + rec = seal_record(ctx, content_type, plaintext[off..off + m]) + if rec == null { + bytes.wipe_free(out) + return null + } + rl = bytes.length(rec) + bytes.copy_from_bytes(out, olen, rec, 0, rl) + olen = olen + rl + bytes.wipe_free(rec) + off = off + m + } + res = bytes.new(olen) + bytes.set(res, olen - 1, 0) + bytes.copy_from_bytes(res, 0, out, 0, olen) + bytes.wipe_free(out) + return res +} + // Parsed result of opening a record. struct OpenedRecord { content_type: int plaintext: ptr // fresh bytes (caller frees) — the inner content plaintext_len: int + alert: int // the alert open_record says to send, when it refuses the record } // Dispatch AEAD-Open to the negotiated cipher. Returns (inner_plaintext, "") @@ -237,12 +298,45 @@ fn aead_open_dispatch(c: *RecordCtx, nonce: ptr, aad: ptr, ctbuf: byte[]) -> (pt // Verifies the AEAD tag, strips trailing zero padding, and reads the real // content type from the last non-zero inner byte. Advances ctx.seq. Returns // "" on success or an error string. On success out.plaintext is fresh bytes. +// The alerts the record layer refuses with (RFC 8446 5, 6.2). +const ALERT_UNEXPECTED_MESSAGE = 10 +const ALERT_BAD_RECORD_MAC = 20 +const ALERT_RECORD_OVERFLOW = 22 +const ALERT_DECODE_ERROR = 50 + +// check_header(header) -> (alert, error) for a record's 5-byte header (the +// first five bytes of `header`): (0, "") for a content type TLS 1.3 knows +// (change_cipher_spec, alert, handshake, application_data) and a length the +// type allows: 2^14 for a plaintext record, 2^14 + 256 for a protected one +// (RFC 8446 5.1, 5.2), and never zero for a handshake or alert. Else +// unexpected_message or record_overflow. Checked as soon as the header has +// arrived, so a bogus length never sizes a read. +check_header(header: byte[]) -> (int, string) { + if header.len < 0 { panic("tls13_record.check_header: the `header` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } + if header.len < RECORD_HEADER_LEN { return ALERT_DECODE_ERROR, "record: truncated header" } + t = header[0] & 0xFF + n = ((header[3] & 0xFF) << 8) | (header[4] & 0xFF) + if t != 20 && t != 21 && t != 22 && t != 23 { + return ALERT_UNEXPECTED_MESSAGE, "record: unknown content type ${t}" + } + if t == OPAQUE_APPLICATION_DATA { + if n > RECORD_MAX_CIPHERTEXT_LEN { return ALERT_RECORD_OVERFLOW, "record: a protected record over 2^14 + 256 bytes" } + } else if n > CONTENT_MAX_PLAIN_LEN { + return ALERT_RECORD_OVERFLOW, "record: a plaintext record over 2^14 bytes" + } + if (t == 21 || t == 22) && n == 0 { + return ALERT_UNEXPECTED_MESSAGE, "record: an empty handshake or alert record" + } + return 0, "" +} + open_record(ctx: ptr, record: byte[], out: *OpenedRecord) -> string { if record.len < 0 { panic("tls13_record.open_record: the `record` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } int rec_len = record.len out.content_type = 0 out.plaintext = null out.plaintext_len = 0 + out.alert = ALERT_DECODE_ERROR if ctx == null { return "record: null context" } c = ctx as *RecordCtx @@ -252,16 +346,21 @@ open_record(ctx: ptr, record: byte[], out: *OpenedRecord) -> string { return "record: sequence number overflow" } if rec_len < RECORD_HEADER_LEN + TAG_LEN { + // Shorter than its tag: it cannot be decrypted, which RFC 8446 5.2 + // answers with bad_record_mac (#3357). + out.alert = ALERT_BAD_RECORD_MAC return "record: too short" } // header: opaque_type must be 0x17 for a protected record if (record[0] & 0xFF) != OPAQUE_APPLICATION_DATA { + out.alert = ALERT_UNEXPECTED_MESSAGE return "record: unexpected opaque_type" } ct_len = ((record[3] & 0xFF) << 8) | (record[4] & 0xFF) // RFC 8446 §5.2: a TLSCiphertext over 2^14 + 256 bytes is a record_overflow. // Reject before allocating so a bogus length cannot drive the buffer size. if ct_len > RECORD_MAX_CIPHERTEXT_LEN { + out.alert = ALERT_RECORD_OVERFLOW return "record: length exceeds maximum record size" } if RECORD_HEADER_LEN + ct_len > rec_len { @@ -280,11 +379,12 @@ open_record(ctx: ptr, record: byte[], out: *OpenedRecord) -> string { nonce = nonce_for(c.iv, c.seq) inner, err = aead_open_dispatch(c, nonce, aad, bytes.view(ctbuf)[0..ct_len]) - bytes.free(aad) - bytes.free(ctbuf) - bytes.free(nonce) + bytes.wipe_free(aad) + bytes.wipe_free(ctbuf) + bytes.wipe_free(nonce) if string.equals(err, "") != 1 { + out.alert = ALERT_BAD_RECORD_MAC return err } @@ -304,11 +404,27 @@ open_record(ctx: ptr, record: byte[], out: *OpenedRecord) -> string { } if ct_idx < 0 { // all-zero inner plaintext: no content type -> illegal - bytes.free(inner) + bytes.wipe_free(inner) c.seq = c.seq + 1 + out.alert = ALERT_UNEXPECTED_MESSAGE return "record: no content type (all-zero inner plaintext)" } out.content_type = bytes.get(inner, ct_idx) & 0xFF + // RFC 8446 5.4: TLSInnerPlaintext is at most 2^14 + 1 bytes (content, + // type and padding), and 5: a record type TLS 1.3 does not know is + // unexpected_message (#3317). + if inner_len > CONTENT_MAX_PLAIN_LEN + 1 { + bytes.wipe_free(inner) + c.seq = c.seq + 1 + out.alert = ALERT_RECORD_OVERFLOW + return "record: inner plaintext over 2^14 + 1 bytes" + } + if out.content_type != 21 && out.content_type != 22 && out.content_type != 23 { + bytes.wipe_free(inner) + c.seq = c.seq + 1 + out.alert = ALERT_UNEXPECTED_MESSAGE + return "record: protected record of unknown type ${out.content_type}" + } content_len = ct_idx pt = bytes.new(content_len) if content_len > 0 { @@ -317,8 +433,9 @@ open_record(ctx: ptr, record: byte[], out: *OpenedRecord) -> string { } out.plaintext = pt out.plaintext_len = content_len + out.alert = 0 - bytes.free(inner) + bytes.wipe_free(inner) c.seq = c.seq + 1 return "" } diff --git a/std/cryptography/tls13_server/module.ae b/std/cryptography/tls13_server/module.ae index 1427eaf64..02d58b87a 100644 --- a/std/cryptography/tls13_server/module.ae +++ b/std/cryptography/tls13_server/module.ae @@ -6,11 +6,18 @@ // // CONSTRAINTS, so they are found here rather than in a failed handshake: // -// - The server key must be ECDSA P-256. CertificateVerify is signed with -// sign_server_cert_verify_ecdsa_p256 and there is no RSA path, so an RSA -// key fails at the signature step and the peer just sees the connection -// drop. Generate with: -// openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 +// - The server key (load_private_key: PKCS#8, SEC1 or PKCS#1 PEM) is +// ECDSA P-256, P-384 or P-521, Ed25519, or RSA of 2048 to 16384 bits +// (#3326). CertificateVerify is signed with the key's scheme (0x0403, +// 0x0503, 0x0603, 0x0807, or rsa_pss_rsae_sha256 0x0804) when the +// client offers it; a client that offers none gets handshake_failure. +// An encrypted key is refused. std.http's server_set_tls checks that +// the key and the certificate are a pair when the server is set up +// (#3328). Every signature is checked against the leaf's key before it +// is sent; one that does not verify ends the handshake with +// internal_error instead. The key is kept shielded +// (std.cryptography.shield), in the clear only for each signature, and +// a key whose shielded form changed in memory is not used (#3376). // - Key exchange: X25519MLKEM768 (post-quantum hybrid) and X25519. // - Buffer arguments are std.bytes HANDLES, never bytes.data(b) -- see the // header of tls13_client for why that distinction bites. @@ -19,14 +26,17 @@ import std.bytes import std.string import std.list import std.list(list_new, list_free, list_size) -import std.mem(copy) -import std.fs +import std.mem(copy, ptr_to_long, long_to_ptr) import std.tcp import std.cryptography.sha2 import std.cryptography.hmac import std.cryptography.x25519 import std.cryptography.mlkem import std.cryptography.p256 +import std.cryptography.p384 +import std.cryptography.p521 +import std.cryptography.rsa +import std.cryptography.shield import std.cryptography.tls13_kdf import std.cryptography.tls13_client import std.cryptography.tls13_ks @@ -44,10 +54,10 @@ extern string_length(s: string) -> int extern tcp_socket_from_fd_owned(fd: int) -> ptr exports( - accept, conn_send, conn_recv, close_conn, + accept, conn_send, conn_recv, close_conn, conn_key_update, ServerConn, - parse_cert_chain_pem, parse_ec_private_key_pem, - pure_tls_server_accept, pure_tls_server_send, pure_tls_server_recv, pure_tls_server_close + parse_cert_chain_pem, parse_cert_chain_pem_checked, parse_ec_private_key_pem, load_private_key, free_private_key, ServerKey, + pure_tls_server_check, pure_tls_server_accept, pure_tls_server_send, pure_tls_server_recv, pure_tls_server_pending, pure_tls_server_close ) struct ServerConn { @@ -55,6 +65,27 @@ struct ServerConn { rio: ptr // *RecordIO app_send: ptr // *tls13_record.RecordCtx app_recv: ptr // *tls13_record.RecordCtx + suite: int // the negotiated cipher suite + // The application traffic secrets the record contexts are keyed from; + // each KeyUpdate moves one on (RFC 8446 7.2, #3320). + send_secret: ptr + recv_secret: ptr + // Post-handshake handshake bytes of a message not yet whole, or null. + hs_pend: ptr + hs_pend_len: int + empties: int // empty application-data records in a row (#3317) + key_updates: int // KeyUpdates since the last application data (#3320) + warnings: int // user_canceled alerts since the last application data (#3323) + ku_pending: int // 1 when the client asked for a KeyUpdate not yet sent (#3320) + closed: int // 0 open, 1 after the client's close_notify, 2 after an error + // What pure_tls_server_recv has decrypted but not yet handed over: the + // tail of a record larger than the caller's buffer (#3381), or null. + recv_pend: ptr + recv_pend_len: int + recv_pend_off: int + // 1 while the pending check (#3405) runs conn_recv: it handles only the + // records already read, and returns instead of reading the socket. + buffered_only: int } // RecordIO framing over a raw TCP socket. @@ -63,6 +94,13 @@ struct RecordIO { buf: ptr // std.bytes accumulator len: int off: int + alert: int // the alert recio_next says to send, when it refuses a record + ccs: int // dummy change_cipher_spec records read so far + hs_seen: int // 1 once the ClientHello has been read + // 1 once the connection is established (#3405): a read that times out + // is then the socket's timeout, which the caller set (std.http's wait + // for a next request), so it is reported, not retried. + after_hs: int } fn recio_new(sock: @keep ptr) -> *RecordIO { @@ -72,15 +110,44 @@ fn recio_new(sock: @keep ptr) -> *RecordIO { bytes.set(r.buf, 0, 0) r.len = 0 r.off = 0 + r.alert = 0 + r.ccs = 0 + r.hs_seen = 0 + r.after_hs = 0 return r } fn recio_free(r: *RecordIO) { - bytes.free(r.buf) + bytes.wipe_free(r.buf) libc_free(r as ptr) } +// Drop what has been consumed before reading more (#3379): back to the +// start when everything was consumed, else, once the consumed part is +// past RECIO_COMPACT_AT bytes and at least half of what is held, the rest +// moved to a fresh buffer. A connection then holds about one record and +// one read however long it lives, not everything it ever received. +const RECIO_COMPACT_AT = 16384 + +fn recio_compact(r: *RecordIO) { + if r.off == 0 { return } + if r.off >= r.len { + r.off = 0 + r.len = 0 + return + } + if r.off < RECIO_COMPACT_AT || r.off * 2 < r.len { return } + rest = r.len - r.off + nb = bytes.new(rest + 4096) + bytes.copy_from_bytes(nb, 0, r.buf, r.off, rest) + bytes.wipe_free(r.buf) + r.buf = nb + r.off = 0 + r.len = rest +} + fn recio_fill(r: *RecordIO) -> int { + recio_compact(r) tries = 0 filling = 1 result = -1 @@ -94,7 +161,12 @@ fn recio_fill(r: *RecordIO) -> int { filling = 0 } } else { - if string.equals(err, "timeout") == 1 { + if string.equals(err, "timeout") == 1 && r.after_hs == 1 { + // The socket's own timeout, which its owner set: reported, + // so a wait for a next request can end (#3405). + result = -2 + filling = 0 + } else if string.equals(err, "timeout") == 1 { tries = tries + 1 if tries >= 500 { result = -1 @@ -116,6 +188,12 @@ fn recio_next(r: *RecordIO) -> (ptr, int, string) { while loop == 1 { avail = r.len - r.off if avail >= 5 { + // The header alone says whether to read on (#3317). + ha, herr = tls13_record.check_header(bytes.view(r.buf)[r.off..r.off + 5]) + if ha != 0 { + r.alert = ha + return null, 0, herr + } plen = ((bytes.get(r.buf, r.off + 3) & 0xFF) << 8) | (bytes.get(r.buf, r.off + 4) & 0xFF) total = 5 + plen if avail >= total { @@ -127,6 +205,8 @@ fn recio_next(r: *RecordIO) -> (ptr, int, string) { } } got = recio_fill(r) + // What was read of a record stays in the buffer for the next call. + if got == -2 { return null, 0, "timeout" } if got <= 0 { return null, 0, "record: connection closed before a full record" } @@ -134,6 +214,92 @@ fn recio_next(r: *RecordIO) -> (ptr, int, string) { return null, 0, "unreachable" } +// The ClientHello, whatever records it came in, as one record: a 5-byte +// header, then the message (RFC 8446 5.1: a handshake message may be split +// across records, #3321). A change_cipher_spec before it, any record type +// other than handshake, bytes after the message in its last record (only +// early data, which this server does not take, may follow it) and a +// message over 2^16 bytes are unexpected_message; an alert record is the +// client giving up. r.alert says what to send on an error. `second` is 1 +// for the ClientHello after a HelloRetryRequest, which the client's one +// compatibility change_cipher_spec may come before (D.4, #3325), and 2 for +// one after a first ClientHello that offered early data: the 0-RTT records +// before it are read past, up to MAX_EARLY_SKIP bytes (4.2.10, #3357); +// application data anywhere else, an empty record included, is +// unexpected_message (#3379). +fn recio_next_client_hello(r: *RecordIO, second: int) -> (ptr, int, string) { + skip = 0 + if second == 2 { skip = MAX_EARLY_SKIP } + acc = bytes.new(0) + alen = 0 + while 1 == 1 { + rec, rlen, err = recio_next(r) + if string.equals(err, "") != 1 { + bytes.wipe_free(acc) + return null, 0, err + } + ct = bytes.get(rec, 0) & 0xFF + if second == 2 && ct == 0x17 && alen == 0 && rlen - 5 >= MIN_PROTECTED && skip >= rlen - 5 { + // The 0-RTT data a first ClientHello sent, which a + // HelloRetryRequest refused: read past (RFC 8446 4.2.10, #3357). + // Only then, and only records a protected one can be: an empty + // one cost nothing against `skip`, so any number of them went by, + // before a first ClientHello too (#3379). + bytes.wipe_free(rec) + skip = skip - (rlen - 5) + } else if ct == 0x14 && second >= 1 && alen == 0 && r.ccs == 0 && rlen == 6 && (bytes.get(rec, 5) & 0xFF) == 1 { + bytes.wipe_free(rec) + r.ccs = 1 + } else if ct == 0x14 { + bytes.wipe_free(rec) + bytes.wipe_free(acc) + r.alert = tls13_record.ALERT_UNEXPECTED_MESSAGE + if second >= 1 { return null, 0, "record: a change_cipher_spec other than the one compatibility mode allows" } + return null, 0, "record: a change_cipher_spec before the ClientHello" + } else if ct == 0x15 { + // The client sent an alert instead of a ClientHello. + aerr = "the client sent an alert instead of a ClientHello" + bytes.wipe_free(rec) + bytes.wipe_free(acc) + return null, 0, aerr + } else if ct != 0x16 { + bytes.wipe_free(rec) + bytes.wipe_free(acc) + r.alert = tls13_record.ALERT_UNEXPECTED_MESSAGE + return null, 0, "record: a record of type ${ct} where the ClientHello belongs" + } else { + bytes.copy_from_bytes(acc, alen, rec, 5, rlen - 5) + alen = alen + rlen - 5 + bytes.wipe_free(rec) + if alen >= 4 { + ml = ((bytes.get(acc, 1) & 0xFF) << 16) | ((bytes.get(acc, 2) & 0xFF) << 8) | (bytes.get(acc, 3) & 0xFF) + if ml > 65536 { + bytes.wipe_free(acc) + r.alert = tls13_record.ALERT_UNEXPECTED_MESSAGE + return null, 0, "handshake: a ClientHello over 2^16 bytes" + } + if alen >= 4 + ml { + if alen != 4 + ml { + bytes.wipe_free(acc) + r.alert = tls13_record.ALERT_UNEXPECTED_MESSAGE + return null, 0, "handshake: data after the ClientHello in its record" + } + out = bytes.new(5 + alen) + bytes.set(out, 0, 0x16) + bytes.set(out, 1, 0x03) + bytes.set(out, 2, 0x03) + bytes.set(out, 3, (alen >> 8) & 0xFF) + bytes.set(out, 4, alen & 0xFF) + bytes.copy_from_bytes(out, 5, acc, 0, alen) + bytes.wipe_free(acc) + r.hs_seen = 1 + return out, 5 + alen, "" + } + } + } + } +} + fn recio_next_hs(r: *RecordIO) -> (ptr, int, string) { looking = 1 rec = null @@ -143,8 +309,19 @@ fn recio_next_hs(r: *RecordIO) -> (ptr, int, string) { rec, rlen, err = recio_next(r) if string.equals(err, "") != 1 { looking = 0 } else { if (bytes.get(rec, 0) & 0xFF) == 0x14 { - bytes.free(rec) // skip ChangeCipherSpec + // One dummy change_cipher_spec, the single byte 1, between + // the ClientHello and the client Finished (RFC 8446 5, + // #3317); any other is unexpected_message. + r.ccs = r.ccs + 1 + bad = 0 + if rlen != 6 || (bytes.get(rec, 5) & 0xFF) != 1 || r.ccs > 1 || r.hs_seen == 0 { bad = 1 } + bytes.wipe_free(rec) + if bad == 1 { + r.alert = tls13_record.ALERT_UNEXPECTED_MESSAGE + return null, 0, "record: a change_cipher_spec other than the one compatibility mode allows" + } } else { + r.hs_seen = 1 looking = 0 } } @@ -177,18 +354,30 @@ fn send_plaintext_handshake(sock: ptr, msg: byte[]) -> string { bytes.set(rec, 4, msg_len & 0xFF) bytes.copy_from_slice(rec, 5, msg[0..msg_len]) n = srv_send_view(sock, bytes.view(rec)[0..total]) - bytes.free(rec) + bytes.wipe_free(rec) if n != total { return "failed to send handshake record" } return "" } +// Send the record(s) `rec`; null (a record that could not be sealed) is an +// error like a failed send, never a slice of nothing (#3380). fn send_record_raw(sock: ptr, rec: ptr) -> string { + if rec == null { return "a record could not be sealed" } rlen = bytes.length(rec) n = srv_send_view(sock, bytes.view(rec)[0..rlen]) if n != rlen { return "failed to send record" } return "" } +// `msg` of `content_type` under `ctx`, in as many records as it takes +// (tls13_record.seal_records), sent: "" or why not (#3380). +fn send_sealed(sock: ptr, ctx: ptr, content_type: int, msg: byte[]) -> string { + recs = tls13_record.seal_records(ctx, content_type, msg) + err = send_record_raw(sock, recs) + if recs != null { bytes.wipe_free(recs) } + return err +} + // The alert for a ClientHello parse_client_hello refused: illegal_parameter // (47) for a malformed X25519MLKEM768 share (RFC 10024 §4.2), // handshake_failure (40) when nothing offered is something we do, and @@ -213,25 +402,34 @@ fn send_plaintext_alert(sock: ptr, level: int, desc: int) { bytes.set(b, 5, level & 0xFF) bytes.set(b, 6, desc & 0xFF) srv_send_view(sock, bytes.view(b)[0..7]) - bytes.free(b) + bytes.wipe_free(b) } // Fresh randomness, or null. Returning null on failure is deliberate and the // callers MUST check: every use here is security-critical -- the server -// random, the ECDSA-P256 signing nonce, and the X25519 private key. Silently -// handing back a zero-filled buffer when the CSPRNG fails would make the -// nonce predictable, and a predictable ECDSA nonce discloses the private key -// outright (RFC 6979 s1). Failing the handshake is the only safe answer. +// random and the X25519 private key. Silently handing back a zero-filled +// buffer when the CSPRNG fails would make the key predictable, and with it +// every session's secrets. Failing the handshake is the only safe answer. +// (The ECDSA signing nonce used to come from here too; it is RFC 6979's now, +// derived inside p256.ecdsa_sign, because a predictable or repeated nonce +// discloses the private key outright.) fn rand_into(n: int) -> ptr { - s, sn, err = cryptography.random_bytes(n) + b, err = cryptography.random_buffer(n) if string.equals(err, "") != 1 { return null } - if sn < n { return null } - b = bytes.new(n) - if n > 0 { bytes.set(b, n - 1, 0) } - bytes.copy_from_string(b, 0, s, n) return b } +// A fresh secp256r1 private scalar in [1, n-1], or null without randomness +// (32 random bytes fall outside it with probability about 2^-32: draw again). +fn fresh_p256_scalar() -> ptr { + while true { + k = rand_into(32) + if k == null { return null } + if p256.private_key_valid(k) == 1 { return k } + bytes.wipe_free(k) + } +} + fn bytes_equal(a: ptr, b: byte[]) -> int { if b.len < 0 { panic("tls13_server.bytes_equal: the `b` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } int n = b.len @@ -282,7 +480,7 @@ fn derive_handshake_keys_algo(ecdhe: byte[], th: byte[], key_len: int, hash_algo k.server_iv = siv k.handshake_secret = hs - bytes.free(early) + bytes.wipe_free(early) return k as ptr } @@ -303,6 +501,83 @@ fn transcript_hash_algo(tr: ptr, algo: string) -> ptr { return tls13_client.transcript_hash_algo(tr, algo) } +// The dummy change_cipher_spec record of middlebox compatibility mode (RFC +// 8446 D.4): 14 03 03 00 01 01. +fn send_ccs(sock: ptr) { + ccs = bytes.new(6) + bytes.set(ccs, 0, 0x14) + bytes.set(ccs, 1, 0x03) + bytes.set(ccs, 2, 0x03) + bytes.set(ccs, 3, 0x00) + bytes.set(ccs, 4, 0x01) + bytes.set(ccs, 5, 0x01) + send_record_raw(sock, ccs) + bytes.wipe_free(ccs) +} + +// The HelloRetryRequest step (RFC 8446 4.1.4, 4.2.8, #3325) for the first +// ClientHello `ch1`, parsed in `p`, its transcript `tr`: ask for +// p.retry_group with the suite already chosen, send the compatibility +// change_cipher_spec after it, read the +// second ClientHello and hold it to the rules, each illegal_parameter: the +// same cipher suite, a share for the group asked for (no second +// HelloRetryRequest is ever sent), the same session id. (Early data after +// it is not skipped: it fails to open, bad_record_mac, as on BoringSSL.) +// Returns (alert, why, transcript, message): on success the transcript is +// message_hash(ch1), the HelloRetryRequest and the second ClientHello, the +// message is that ClientHello and `p` its parse. `tr` and `ch1` are taken +// either way; what comes back is the caller's to free. +fn hello_retry(sock: ptr, rio: *RecordIO, tr: @frees ptr, p: *ChParsed, ch1: @frees ptr) -> (int, string, ptr, ptr) { + suite = p.cipher_suite + group = p.retry_group + _a, _k, algo, _h, _e = suite_params(suite) + sid_len = p.session_id_len + sid = bytes.new(sid_len) + if sid_len > 0 { bytes.copy_from_bytes(sid, 0, p.session_id, 0, sid_len) } + hrr = tls13_hs.hello_retry_request(bytes.view(sid)[0..sid_len], suite, group) + hrr_len = bytes.length(hrr) + serr = send_plaintext_handshake(sock, bytes.view(hrr)[0..hrr_len]) + tr2 = tls13_client.transcript_retry(tr, bytes.view(ch1)[0..bytes.length(ch1)], bytes.view(hrr)[0..hrr_len], algo) + bytes.wipe_free(hrr) + bytes.wipe_free(ch1) + if serr != "" { + bytes.wipe_free(sid) + return 0, serr, tr2, bytes.new(0) + } + send_ccs(sock) + second = 1 + if p.has_early_data == 1 { second = 2 } + rec, rlen, rerr = recio_next_client_hello(rio, second) + if rerr != "" { + bytes.wipe_free(sid) + return rio.alert, rerr, tr2, bytes.new(0) + } + ml = rlen - 5 + ch2 = bytes.new(ml) + bytes.copy_from_bytes(ch2, 0, rec, 5, ml) + bytes.wipe_free(rec) + tls13_hs.free_ch(p) + perr = tls13_hs.parse_client_hello(bytes.view(ch2)[0..ml], p) + alert = 0 + if perr != "" { + alert = p.alert + if alert == 0 { alert = ch_error_alert(perr) } + } else if p.cipher_suite != suite { + alert = 47 + perr = "second client hello: a cipher suite other than the HelloRetryRequest's" + } else if p.group != group { + alert = 47 + perr = "second client hello: no key share for group ${group}, which the HelloRetryRequest asked for" + } else if p.session_id_len != sid_len || (sid_len > 0 && bytes_equal(sid, bytes.view(p.session_id)[0..sid_len]) != 1) { + alert = 47 + perr = "second client hello: a legacy_session_id other than the first's" + } + bytes.wipe_free(sid) + if perr != "" { return alert, perr, tr2, ch2 } + tls13_client.transcript_add(tr2, bytes.view(ch2)[0..ml]) + return 0, "", tr2, ch2 +} + // Perform the TLS 1.3 server handshake over `sock`. accept(sock: @keep ptr, cert_chain: ptr, cert_lens: ptr, priv_key: ptr, x25519_priv: ptr) -> (ptr, string) { if sock == null { return null, "socket is null" } @@ -313,29 +588,53 @@ accept(sock: @keep ptr, cert_chain: ptr, cert_lens: ptr, priv_key: ptr, x25519_p rio = recio_new(sock) // 1. Read ClientHello - chrec, chlen, cherr = recio_next_hs(rio) + chrec, chlen, cherr = recio_next_client_hello(rio, 0) if string.equals(cherr, "") != 1 { + if rio.alert != 0 { send_plaintext_alert(sock, 2, rio.alert) } recio_free(rio) return null, cherr } ch_msg_len = chlen - 5 ch_msg = bytes.new(ch_msg_len) bytes.copy_from_bytes(ch_msg, 0, chrec, 5, ch_msg_len) - bytes.free(chrec) + bytes.wipe_free(chrec) ch_parsed = tls13_hs.ChParsed { random: null, session_id: null, session_id_len: 0, cipher_suite: 0, group: 0, key_share: null, key_share_len: 0, has_sni: 0, saw_supported_versions: 0 } perr = tls13_hs.parse_client_hello(bytes.view(ch_msg)[0..ch_msg_len], &ch_parsed) if string.equals(perr, "") != 1 { - send_plaintext_alert(sock, 2, ch_error_alert(perr)) - tls13_hs.free_ch(&ch_parsed); bytes.free(ch_msg); recio_free(rio) + ca = ch_parsed.alert + if ca == 0 { ca = ch_error_alert(perr) } + send_plaintext_alert(sock, 2, ca) + tls13_hs.free_ch(&ch_parsed); bytes.wipe_free(ch_msg); recio_free(rio) return null, perr } tr = tls13_client.transcript_new() tls13_client.transcript_add(tr, bytes.view(ch_msg)[0..ch_msg_len]) + // No share for a group the server takes, but supported_groups names + // one: a HelloRetryRequest asks for it (RFC 8446 4.2.8, #3325), and the + // handshake goes on with the second ClientHello. + ccs_sent = 0 + // 0-RTT data this server never takes (it issues no tickets) is read + // past: before the client Finished, or before a second ClientHello. + skip_early = ch_parsed.has_early_data + if ch_parsed.group == 0 { + rt_alert, rt_err, tr2, ch2 = hello_retry(sock, rio, tr, &ch_parsed, ch_msg) + tr = tr2 + ch_msg = ch2 + if rt_err != "" { + if rt_alert != 0 { send_plaintext_alert(sock, 2, rt_alert) } + tls13_hs.free_ch(&ch_parsed); bytes.wipe_free(ch_msg); tls13_client.transcript_free(tr); recio_free(rio) + return null, rt_err + } + ch_msg_len = bytes.length(ch_msg) + ccs_sent = 1 + skip_early = 0 + } + // 2. Compute shared secret & server key share shared = null shared_len = 0 @@ -345,7 +644,7 @@ accept(sock: @keep ptr, cert_chain: ptr, cert_lens: ptr, priv_key: ptr, x25519_p if ch_parsed.group == tls13_hs.GROUP_X25519MLKEM768 { if ch_parsed.key_share_len != 1216 { send_plaintext_alert(sock, 2, 47) - tls13_hs.free_ch(&ch_parsed); bytes.free(ch_msg); tls13_client.transcript_free(tr); recio_free(rio) + tls13_hs.free_ch(&ch_parsed); bytes.wipe_free(ch_msg); tls13_client.transcript_free(tr); recio_free(rio) return null, "X25519MLKEM768 key share wrong length" } ek = bytes.new(1184) @@ -354,23 +653,23 @@ accept(sock: @keep ptr, cert_chain: ptr, cert_lens: ptr, priv_key: ptr, x25519_p bytes.copy_from_bytes(cxpub, 0, ch_parsed.key_share, 1184, 32) ct, mlk_ss, merr = mlkem.mlkem768_encaps(ek) - bytes.free(ek) + bytes.wipe_free(ek) if string.equals(merr, "") != 1 || ct == null || mlk_ss == null { - if ct != null { bytes.free(ct) } - if mlk_ss != null { bytes.free(mlk_ss) } - bytes.free(cxpub) + if ct != null { bytes.wipe_free(ct) } + if mlk_ss != null { bytes.wipe_free(mlk_ss) } + bytes.wipe_free(cxpub) send_plaintext_alert(sock, 2, 47) // illegal_parameter (RFC 10024 §4.2) - tls13_hs.free_ch(&ch_parsed); bytes.free(ch_msg); tls13_client.transcript_free(tr); recio_free(rio) + tls13_hs.free_ch(&ch_parsed); bytes.wipe_free(ch_msg); tls13_client.transcript_free(tr); recio_free(rio) return null, string.concat("X25519MLKEM768 encapsulation failed: ", merr) } x_ss, xerr = x25519.agree(x25519_priv, cxpub) - bytes.free(cxpub) + bytes.wipe_free(cxpub) if string.equals(xerr, "") != 1 || x_ss == null { - if x_ss != null { bytes.free(x_ss) } - bytes.free(ct); bytes.free(mlk_ss) + if x_ss != null { bytes.wipe_free(x_ss) } + bytes.wipe_free(ct); bytes.wipe_free(mlk_ss) send_plaintext_alert(sock, 2, 47) - tls13_hs.free_ch(&ch_parsed); bytes.free(ch_msg); tls13_client.transcript_free(tr); recio_free(rio) + tls13_hs.free_ch(&ch_parsed); bytes.wipe_free(ch_msg); tls13_client.transcript_free(tr); recio_free(rio) return null, xerr } @@ -379,19 +678,19 @@ accept(sock: @keep ptr, cert_chain: ptr, cert_lens: ptr, priv_key: ptr, x25519_p bytes.copy_from_bytes(server_ks, 0, ct, 0, 1088) bytes.copy_from_bytes(server_ks, 1088, sxpub, 0, 32) server_ks_len = 1120 - bytes.free(ct); bytes.free(sxpub) + bytes.wipe_free(ct); bytes.wipe_free(sxpub) shared = bytes.new(64) bytes.copy_from_bytes(shared, 0, mlk_ss, 0, 32) bytes.copy_from_bytes(shared, 32, x_ss, 0, 32) shared_len = 64 - bytes.free(mlk_ss); bytes.free(x_ss) + bytes.wipe_free(mlk_ss); bytes.wipe_free(x_ss) } else if ch_parsed.group == tls13_hs.GROUP_X25519 { x_ss, xerr = x25519.agree(x25519_priv, ch_parsed.key_share) if string.equals(xerr, "") != 1 || x_ss == null { - if x_ss != null { bytes.free(x_ss) } + if x_ss != null { bytes.wipe_free(x_ss) } send_plaintext_alert(sock, 2, 47) // illegal_parameter (RFC 8446 §7.4.2) - tls13_hs.free_ch(&ch_parsed); bytes.free(ch_msg); tls13_client.transcript_free(tr); recio_free(rio) + tls13_hs.free_ch(&ch_parsed); bytes.wipe_free(ch_msg); tls13_client.transcript_free(tr); recio_free(rio) return null, xerr } sxpub = x25519.base_point(x25519_priv) @@ -399,124 +698,170 @@ accept(sock: @keep ptr, cert_chain: ptr, cert_lens: ptr, priv_key: ptr, x25519_p server_ks_len = 32 shared = x_ss shared_len = 32 + } else if ch_parsed.group == tls13_hs.GROUP_SECP256R1 { + // secp256r1, which RFC 8446 9.1 says a TLS implementation MUST + // support (#3357): a fresh scalar, the client's point checked to be + // on the curve by p256.ecdh (illegal_parameter otherwise, 7.4.2). + pk = fresh_p256_scalar() + cx = bytes.new(32) + cy = bytes.new(32) + bytes.copy_from_bytes(cx, 0, ch_parsed.key_share, 1, 32) + bytes.copy_from_bytes(cy, 0, ch_parsed.key_share, 33, 32) + p_ss = null + if pk != null { p_ss = p256.ecdh(pk, cx, cy) } + bytes.wipe_free(cx); bytes.wipe_free(cy) + if p_ss == null { + if pk != null { wipe_free(pk) } + send_plaintext_alert(sock, 2, 47) + tls13_hs.free_ch(&ch_parsed); bytes.wipe_free(ch_msg); tls13_client.transcript_free(tr); recio_free(rio) + return null, "secp256r1 key share refused (not on the curve), or no randomness" + } + px, py = p256.scalar_mult_base(pk) + wipe_free(pk) + server_ks = bytes.new(65) + bytes.set(server_ks, 0, 4) + bytes.copy_from_bytes(server_ks, 1, px, 0, 32) + bytes.copy_from_bytes(server_ks, 33, py, 0, 32) + bytes.wipe_free(px); bytes.wipe_free(py) + server_ks_len = 65 + shared = p_ss + shared_len = 32 } else { send_plaintext_alert(sock, 2, 40) - tls13_hs.free_ch(&ch_parsed); bytes.free(ch_msg); tls13_client.transcript_free(tr); recio_free(rio) + tls13_hs.free_ch(&ch_parsed); bytes.wipe_free(ch_msg); tls13_client.transcript_free(tr); recio_free(rio) return null, "unsupported group in client hello" } // 3. Build and send ServerHello (plaintext) srnd = rand_into(32) if srnd == null { - tls13_hs.free_ch(&ch_parsed); bytes.free(ch_msg) + tls13_hs.free_ch(&ch_parsed); bytes.wipe_free(ch_msg) tls13_client.transcript_free(tr); recio_free(rio) - bytes.free(server_ks); bytes.free(shared) + bytes.wipe_free(server_ks); bytes.wipe_free(shared) return null, "no randomness available for the server random" } sh = tls13_hs.server_hello(srnd, bytes.view(ch_parsed.session_id)[0..ch_parsed.session_id_len], ch_parsed.cipher_suite, ch_parsed.group, bytes.view(server_ks)[0..server_ks_len]) - bytes.free(server_ks) + bytes.wipe_free(server_ks) sh_len = bytes.length(sh) sherr = send_plaintext_handshake(sock, bytes.view(sh)[0..sh_len]) if string.equals(sherr, "") != 1 { - bytes.free(srnd); bytes.free(shared); bytes.free(sh) - tls13_hs.free_ch(&ch_parsed); bytes.free(ch_msg); tls13_client.transcript_free(tr); recio_free(rio) + bytes.wipe_free(srnd); bytes.wipe_free(shared); bytes.wipe_free(sh) + tls13_hs.free_ch(&ch_parsed); bytes.wipe_free(ch_msg); tls13_client.transcript_free(tr); recio_free(rio) return null, sherr } tls13_client.transcript_add(tr, bytes.view(sh)[0..sh_len]) + // Middlebox compatibility (RFC 8446 D.4, #3322): a dummy + // change_cipher_spec right after the server's first handshake message, + // the ServerHello or the HelloRetryRequest (#3325), whatever the session + // id, as BoringSSL sends it (a client drops it at any point, 5; #3357). + if ccs_sent == 0 { send_ccs(sock) } // 4. Key Schedule rec_aead, rec_key_len, hash_algo, hash_len, suite_err = suite_params(ch_parsed.cipher_suite) if string.equals(suite_err, "") != 1 { - bytes.free(srnd); bytes.free(shared); bytes.free(sh) - tls13_hs.free_ch(&ch_parsed); bytes.free(ch_msg); tls13_client.transcript_free(tr); recio_free(rio) + bytes.wipe_free(srnd); bytes.wipe_free(shared); bytes.wipe_free(sh) + tls13_hs.free_ch(&ch_parsed); bytes.wipe_free(ch_msg); tls13_client.transcript_free(tr); recio_free(rio) return null, suite_err } th_chsh = transcript_hash_algo(tr, hash_algo) keys = derive_handshake_keys_algo(bytes.view(shared)[0..shared_len], bytes.view(th_chsh)[0..hash_len], rec_key_len, hash_algo) - bytes.free(shared) + bytes.wipe_free(shared) srv_hs_ctx = tls13_record.new_aead(rec_aead, bytes.view(hs_server_key(keys))[0..rec_key_len], hs_server_iv(keys)) cli_hs_ctx = tls13_record.new_aead(rec_aead, bytes.view(hs_client_key(keys))[0..rec_key_len], hs_client_iv(keys)) // 5. Send Encrypted Flight + // Each message in as many records as it takes (a chain over 16 KiB + // fills several, RFC 8446 5.1); the first one that cannot be sealed or + // sent ends the handshake (#3380). // a. EncryptedExtensions ee = tls13_hs.encrypted_extensions() - ee_rec = tls13_record.seal_record(srv_hs_ctx, tls13_record.CONTENT_HANDSHAKE, bytes.view(ee)) - send_record_raw(sock, ee_rec) + fl_err = send_sealed(sock, srv_hs_ctx, tls13_record.CONTENT_HANDSHAKE, bytes.view(ee)) tls13_client.transcript_add(tr, bytes.view(ee)) - bytes.free(ee); bytes.free(ee_rec) + bytes.wipe_free(ee) // b. Certificate cert_msg = tls13_hs.certificate_msg(cert_chain, cert_lens, 0) - cert_rec = tls13_record.seal_record(srv_hs_ctx, tls13_record.CONTENT_HANDSHAKE, bytes.view(cert_msg)) - send_record_raw(sock, cert_rec) + if fl_err == "" { fl_err = send_sealed(sock, srv_hs_ctx, tls13_record.CONTENT_HANDSHAKE, bytes.view(cert_msg)) } tls13_client.transcript_add(tr, bytes.view(cert_msg)) - bytes.free(cert_msg); bytes.free(cert_rec) - - // c. CertificateVerify + bytes.wipe_free(cert_msg) + + // c. CertificateVerify, with the scheme of the server's key when the + // client's signature_algorithms offers it (RFC 8446 4.4.3, #3326). An + // ECDSA nonce is RFC 6979's hedged with 32 fresh random bytes (section + // 3.6), and an Ed25519 r is hedged the same way, so the handshake needs + // randomness, as its key share does; an RSA-PSS signature is constant + // time and blinded. The signature is checked against the leaf's key + // before it is sent (#3376). + skey = priv_key as *ServerKey + scheme = key_scheme(skey, ch_parsed.sig_algs) th_cert = transcript_hash_algo(tr, hash_algo) - knonce = rand_into(32) - if knonce == null { - // Signing with a predictable nonce would disclose the private key; - // there is no safe fallback, so the handshake ends here. - bytes.free(th_cert) - return null, "no randomness available for the signing nonce" - } - sig = tls13_cert.sign_server_cert_verify_ecdsa_p256(priv_key, bytes.view(th_cert)[0..hash_len], knonce) - cv_msg = tls13_hs.certificate_verify_msg(0x0403, bytes.view(sig)) - cv_rec = tls13_record.seal_record(srv_hs_ctx, tls13_record.CONTENT_HANDSHAKE, bytes.view(cv_msg)) - send_record_raw(sock, cv_rec) + var sig: ptr = null + cv_alert = 0 + cv_err = "" + if scheme == 0 { + // RFC 8446 9.2: missing_extension when the client sent no + // signature_algorithms; handshake_failure when nothing it offers is + // the key's scheme. + cv_alert = 40 + if ch_parsed.saw_sig_algs == 0 { cv_alert = 109 } + cv_err = "the client offers no signature scheme the server's key signs with" + } else { + leaf = tls13_cert.LeafCert { tbs: null } + tls13_cert.init_leaf(&leaf) + lerr = "the chain has no certificate" + if list_size(cert_chain) > 0 { + leaf_der = list.at(cert_chain, 0) as ptr + lerr = tls13_cert.parse_certificate(bytes.view(leaf_der)[0..bytes.length(leaf_der)], &leaf) + } + if string.equals(lerr, "") != 1 { + cv_alert = 80 + cv_err = "the server's certificate does not parse: ${lerr}" + } else { + signed, why = sign_cert_verify(skey, &leaf, scheme, bytes.view(th_cert)[0..hash_len]) + sig = signed + if sig == null { + cv_alert = 80 + cv_err = why + } + } + tls13_cert.free_leaf(&leaf) + } + if cv_alert != 0 { + send_hs_alert(sock, srv_hs_ctx, cv_alert) + bytes.wipe_free(th_cert); bytes.wipe_free(srnd); bytes.wipe_free(sh) + tls13_hs.free_ch(&ch_parsed); bytes.wipe_free(ch_msg); tls13_client.transcript_free(tr); recio_free(rio) + tls13_record.free_ctx(srv_hs_ctx); tls13_record.free_ctx(cli_hs_ctx) + tls13_client.free_hs_keys(keys); bytes.wipe_free(th_chsh) + return null, cv_err + } + cv_msg = tls13_hs.certificate_verify_msg(scheme, bytes.view(sig)) + if fl_err == "" { fl_err = send_sealed(sock, srv_hs_ctx, tls13_record.CONTENT_HANDSHAKE, bytes.view(cv_msg)) } tls13_client.transcript_add(tr, bytes.view(cv_msg)) - bytes.free(th_cert); bytes.free(knonce); bytes.free(sig); bytes.free(cv_msg); bytes.free(cv_rec) + bytes.wipe_free(th_cert); bytes.wipe_free(sig); bytes.wipe_free(cv_msg) // d. Server Finished th_cv = transcript_hash_algo(tr, hash_algo) svd = finished_verify_data_algo(hs_server_secret(keys), bytes.view(th_cv)[0..hash_len], hash_algo, hash_len) fin_msg = tls13_hs.finished_msg(svd, hash_len) - fin_rec = tls13_record.seal_record(srv_hs_ctx, tls13_record.CONTENT_HANDSHAKE, bytes.view(fin_msg)) - send_record_raw(sock, fin_rec) + if fl_err == "" { fl_err = send_sealed(sock, srv_hs_ctx, tls13_record.CONTENT_HANDSHAKE, bytes.view(fin_msg)) } tls13_client.transcript_add(tr, bytes.view(fin_msg)) - bytes.free(th_cv); bytes.free(svd); bytes.free(fin_msg); bytes.free(fin_rec) - - // 6. Read and verify Client Finished - th_sf = transcript_hash_algo(tr, hash_algo) - - cf_rec, cf_rec_len, cf_err = recio_next_hs(rio) - if string.equals(cf_err, "") != 1 { - } - fin_ok = 0 - if string.equals(cf_err, "") == 1 { - opened = tls13_record.OpenedRecord { content_type: 0, plaintext: null, plaintext_len: 0 } - oerr = tls13_record.open_record(cli_hs_ctx, bytes.view(cf_rec)[0..cf_rec_len], &opened) - bytes.free(cf_rec) - if string.equals(oerr, "") == 1 { - if opened.content_type == 22 && opened.plaintext_len >= 4 { - if (bytes.get(opened.plaintext, 0) & 0xFF) == tls13_hs.HS_FINISHED { - cvd_len = opened.plaintext_len - 4 - cvd = bytes.new(cvd_len) - bytes.copy_from_bytes(cvd, 0, opened.plaintext, 4, cvd_len) - expected_cvd = finished_verify_data_algo(hs_client_secret(keys), bytes.view(th_sf)[0..hash_len], hash_algo, hash_len) - if bytes_equal(expected_cvd, bytes.view(cvd)[0..hash_len]) == 1 { - fin_ok = 1 - tls13_client.transcript_add(tr, bytes.view(opened.plaintext)[0..opened.plaintext_len]) - } - bytes.free(cvd); bytes.free(expected_cvd) - } - } - if opened.plaintext != null { bytes.free(opened.plaintext) } - } - } - - if fin_ok == 0 { - bytes.free(th_sf); bytes.free(srnd); bytes.free(sh) - tls13_hs.free_ch(&ch_parsed); bytes.free(ch_msg); tls13_client.transcript_free(tr); recio_free(rio) + bytes.wipe_free(th_cv); bytes.wipe_free(svd); bytes.wipe_free(fin_msg) + if fl_err != "" { + bytes.wipe_free(srnd); bytes.wipe_free(sh) + tls13_hs.free_ch(&ch_parsed); bytes.wipe_free(ch_msg); tls13_client.transcript_free(tr); recio_free(rio) tls13_record.free_ctx(srv_hs_ctx); tls13_record.free_ctx(cli_hs_ctx) - tls13_client.free_hs_keys(keys); bytes.free(th_chsh) - return null, "client Finished MAC verification failed" + tls13_client.free_hs_keys(keys); bytes.wipe_free(th_chsh) + return null, "handshake: ${fl_err}" } - // 7. Derive Application Traffic Keys + // 6. Derive Application Traffic Keys, over the transcript through the + // server Finished (RFC 8446 7.1). They come before the client Finished + // is read: the client reads the server's records under them from its + // Finished on, so an alert about the client Finished goes under them + // too (#3324). + th_sf = transcript_hash_algo(tr, hash_algo) ms = tls13_ks.master_secret(hash_algo, hs_handshake_secret(keys)) c_ap = tls13_ks.traffic_secret(hash_algo, ms, "c ap traffic", bytes.view(th_sf)[0..hash_len]) s_ap = tls13_ks.traffic_secret(hash_algo, ms, "s ap traffic", bytes.view(th_sf)[0..hash_len]) @@ -528,73 +873,377 @@ accept(sock: @keep ptr, cert_chain: ptr, cert_lens: ptr, priv_key: ptr, x25519_p app_send = tls13_record.new_aead(rec_aead, bytes.view(sk)[0..rec_key_len], siv) app_recv = tls13_record.new_aead(rec_aead, bytes.view(ck)[0..rec_key_len], civ) + tls13_ks.free_secret(ck); tls13_ks.free_secret(civ); tls13_ks.free_secret(sk); tls13_ks.free_secret(siv) + tls13_ks.free_secret(ms) + + // 7. Read and verify the client Finished. + cf_alert, cf_err = read_client_finished(rio, cli_hs_ctx, tr, hs_client_secret(keys), th_sf, hash_algo, hash_len, skip_early) + if string.equals(cf_err, "") != 1 { + if cf_alert != 0 { send_hs_alert(sock, app_send as ptr, cf_alert) } + tls13_record.free_ctx(app_send as ptr); tls13_record.free_ctx(app_recv as ptr) + tls13_ks.free_secret(c_ap); tls13_ks.free_secret(s_ap) + bytes.wipe_free(th_sf); bytes.wipe_free(srnd); bytes.wipe_free(sh) + tls13_hs.free_ch(&ch_parsed); bytes.wipe_free(ch_msg); tls13_client.transcript_free(tr); recio_free(rio) + tls13_record.free_ctx(srv_hs_ctx); tls13_record.free_ctx(cli_hs_ctx) + tls13_client.free_hs_keys(keys); bytes.wipe_free(th_chsh) + return null, cf_err + } // Cleanup handshake state - bytes.free(ck); bytes.free(civ); bytes.free(sk); bytes.free(siv) - bytes.free(ms); bytes.free(c_ap); bytes.free(s_ap); bytes.free(th_sf) - bytes.free(srnd); bytes.free(sh) - tls13_hs.free_ch(&ch_parsed); bytes.free(ch_msg); tls13_client.transcript_free(tr) + bytes.wipe_free(th_sf) + bytes.wipe_free(srnd); bytes.wipe_free(sh) + suite = ch_parsed.cipher_suite + tls13_hs.free_ch(&ch_parsed); bytes.wipe_free(ch_msg); tls13_client.transcript_free(tr) tls13_record.free_ctx(srv_hs_ctx); tls13_record.free_ctx(cli_hs_ctx) - tls13_client.free_hs_keys(keys); bytes.free(th_chsh) + tls13_client.free_hs_keys(keys); bytes.wipe_free(th_chsh) + // The connection keeps c_ap and s_ap for KeyUpdate (#3320). conn = malloc(sizeof(ServerConn)) as *ServerConn conn.sock = sock conn.rio = rio as ptr + rio.after_hs = 1 conn.app_send = app_send as ptr conn.app_recv = app_recv as ptr + conn.suite = suite + conn.send_secret = s_ap + conn.recv_secret = c_ap + conn.hs_pend = null + conn.hs_pend_len = 0 + conn.recv_pend = null + conn.recv_pend_len = 0 + conn.recv_pend_off = 0 + conn.buffered_only = 0 + conn.empties = 0 + conn.key_updates = 0 + conn.warnings = 0 + conn.ku_pending = 0 + conn.closed = 0 return conn as ptr, "" } -// Send application data as one encrypted record. +// Read the client's second flight, its Finished alone (this server asks for +// no certificate), under the client handshake keys `cli_hs_ctx`, and check +// it against `th_sf`, the transcript through the server Finished; on +// success it joins the transcript `tr`. (0, "") or the alert to send and +// why (#3324): bad_record_mac for a record that does not open, +// unexpected_message for a plaintext record, another record type or another +// message (a Finished may span records but not share its last one, RFC +// 8446 5.1), decrypt_error for verify_data that is not the hash's length +// or a MAC that does not match (4.4.4: the Finished is wrong, #3357). With +// `early` 1, records that do not open before the first that does are 0-RTT +// data the server did not take, skipped up to MAX_EARLY_SKIP bytes +// (4.2.10). An alert from the +// client ends the read with its name and no answer. +fn read_client_finished(rio: *RecordIO, cli_hs_ctx: ptr, tr: ptr, client_hs_secret: ptr, th_sf: ptr, hash_algo: string, hash_len: int, early: int) -> (int, string) { + // Early data offered and not taken is skipped (#3357). + skip_early = 0 + if early == 1 { skip_early = MAX_EARLY_SKIP } + opened_one = 0 + want = 4 + hash_len + msg = bytes.new(want) + mlen = 0 + while mlen < want { + rec, rlen, err = recio_next_hs(rio) + if string.equals(err, "") != 1 { + bytes.wipe_free(msg) + return rio.alert, err + } + ct = bytes.get(rec, 0) & 0xFF + if ct != 0x17 { + bytes.wipe_free(rec); bytes.wipe_free(msg) + return tls13_record.ALERT_UNEXPECTED_MESSAGE, "handshake: a plaintext record of type ${ct} where the client Finished belongs" + } + opened = tls13_record.OpenedRecord { content_type: 0, plaintext: null, plaintext_len: 0, alert: 0 } + oerr = tls13_record.open_record(cli_hs_ctx, bytes.view(rec)[0..rlen], &opened) + bytes.wipe_free(rec) + if string.equals(oerr, "") != 1 && opened_one == 0 && skip_early >= rlen - 5 { + // 0-RTT data this server did not take, under keys it does not + // have: skipped, up to MAX_EARLY_SKIP bytes, until the first + // record that opens (RFC 8446 4.2.10, #3357). + skip_early = skip_early - (rlen - 5) + } else if string.equals(oerr, "") != 1 { + bytes.wipe_free(msg) + return opened.alert, oerr + } else { + opened_one = 1 + t = opened.content_type + n = opened.plaintext_len + if t == tls13_record.CONTENT_ALERT && n >= 2 { + d = bytes.get(opened.plaintext, 1) & 0xFF + bytes.wipe_free(opened.plaintext); bytes.wipe_free(msg) + return 0, "the client sent alert ${tls13_client.alert_name(d)}" + } + if t != tls13_record.CONTENT_HANDSHAKE || n == 0 { + if opened.plaintext != null { bytes.wipe_free(opened.plaintext) } + bytes.wipe_free(msg) + return tls13_record.ALERT_UNEXPECTED_MESSAGE, "handshake: a record of type ${t} (or an empty one) where the client Finished belongs" + } + // The record joins what came before (#3321); the buffer grows to + // hold it, and is judged as soon as the header is in. + bytes.copy_from_bytes(msg, mlen, opened.plaintext, 0, n) + mlen = mlen + n + bytes.wipe_free(opened.plaintext) + if mlen >= 4 { + mt = bytes.get(msg, 0) & 0xFF + ml = ((bytes.get(msg, 1) & 0xFF) << 16) | ((bytes.get(msg, 2) & 0xFF) << 8) | (bytes.get(msg, 3) & 0xFF) + fa = 0 + fe = "" + if mt != tls13_hs.HS_FINISHED { + fa = tls13_record.ALERT_UNEXPECTED_MESSAGE + fe = "handshake: a message of type ${mt} where the client Finished belongs" + } else if ml != hash_len { + fa = ALERT_DECRYPT_ERROR + fe = "finished: verify_data is ${ml} bytes, not ${hash_len}" + } else if mlen > want { + fa = tls13_record.ALERT_UNEXPECTED_MESSAGE + fe = "handshake: data after the client Finished in its record" + } + if fa != 0 { + bytes.wipe_free(msg) + return fa, fe + } + } + } + } + expected = finished_verify_data_algo(client_hs_secret, bytes.view(th_sf)[0..hash_len], hash_algo, hash_len) + ok = bytes_equal(expected, bytes.view(msg)[4..want]) + bytes.wipe_free(expected) + if ok != 1 { + bytes.wipe_free(msg) + return ALERT_DECRYPT_ERROR, "client Finished MAC verification failed" + } + tls13_client.transcript_add(tr, bytes.view(msg)[0..want]) + bytes.wipe_free(msg) + return 0, "" +} + +// Send application data, in encrypted records of at most 2^14 bytes each +// (#3380). A KeyUpdate the client asked for goes first (RFC 8446 4.6.3, +// #3320). conn_send(conn: ptr, data: byte[]) -> string { if data.len < 0 { panic("tls13_server.conn_send: the `data` slice has no bound (.len -1); give it one, as `(p as byte[])[0..n]`") } c = conn as *ServerConn - rec = tls13_record.seal_record(c.app_send as ptr, tls13_record.CONTENT_APPLICATION_DATA, data) + if c.closed == 2 { return "send: the connection failed earlier" } + if c.ku_pending == 1 { + kerr = send_key_update(c, tls13_hs.KEY_UPDATE_NOT_REQUESTED) + if kerr != "" { return kerr } + } + // Data over 2^14 bytes goes in several records (#3380). + rec = tls13_record.seal_records(c.app_send as ptr, tls13_record.CONTENT_APPLICATION_DATA, data) + if rec == null { return "send: the record could not be sealed" } n = srv_send_view(c.sock, bytes.view(rec)) tot = bytes.length(rec) - bytes.free(rec) + bytes.wipe_free(rec) if n != tot { return "send failed" } return "" } -// Receive application data record. +// Send a KeyUpdate asking (`request` 1) or not (0) for the client's, under +// the current sending keys, then move them on (RFC 8446 4.6.3, 7.2). +fn send_key_update(c: *ServerConn, request: int) -> string { + m = tls13_hs.key_update_msg(request) + rec = tls13_record.seal_record(c.app_send as ptr, tls13_record.CONTENT_HANDSHAKE, bytes.view(m)[0..5]) + bytes.wipe_free(m) + if rec == null { return "key update: the record could not be sealed" } + n = srv_send_view(c.sock, bytes.view(rec)) + tot = bytes.length(rec) + bytes.wipe_free(rec) + if n != tot { return "key update: send failed" } + _a, _k, algo, _h, _e = suite_params(c.suite) + c.send_secret = tls13_ks.key_update(algo, c.app_send, c.send_secret) + c.ku_pending = 0 + return "" +} + +// conn_key_update(conn, request) -> "" or why: update the connection's +// sending keys now with a KeyUpdate (RFC 8446 4.6.3, #3320); `request` +// tls13_hs.KEY_UPDATE_REQUESTED (1) asks the client to update its own, +// which the next conn_recv takes in its stride, KEY_UPDATE_NOT_REQUESTED +// (0) does not. +conn_key_update(conn: ptr, request: int) -> string { + c = conn as *ServerConn + if request != tls13_hs.KEY_UPDATE_NOT_REQUESTED && request != tls13_hs.KEY_UPDATE_REQUESTED { + return "key update: request ${request} is neither KEY_UPDATE_NOT_REQUESTED nor KEY_UPDATE_REQUESTED" + } + if c.closed != 0 { return "key update: the connection is closed" } + return send_key_update(c, request) +} + +// Empty application-data records the server reads past in a row before it +// refuses the stream as a denial of service (#3317), as BoringSSL bounds them. +const MAX_EMPTY_RECORDS = 32 + +// The early data the server reads past when it does not take a ClientHello's +// 0-RTT data (RFC 8446 4.2.10 bounds it by max_early_data_size; this server +// issues no tickets, so this is the bound BoringSSL keeps), #3357. +const MAX_EARLY_SKIP = 16384 + +// The smallest protected record: its content type byte and the AEAD tag +// (RFC 8446 5.2). +const MIN_PROTECTED = 17 // 1 + tls13_record.TAG_LEN + +// KeyUpdates without application data between them the server reads past +// before it refuses the stream (#3320), as BoringSSL bounds them. +const MAX_KEY_UPDATES = 32 + +// decrypt_error (RFC 8446 6.2): a Finished whose MAC does not match. +const ALERT_DECRYPT_ERROR = 51 + +// A fatal alert under `ctx`: the server's handshake-traffic keys after +// ServerHello (#3326), its application-traffic keys after its Finished +// (#3324). Best effort, as send_plaintext_alert. +fn send_hs_alert(sock: ptr, ctx: ptr, desc: int) { + a = bytes.new(2) + bytes.set(a, 0, 2) + bytes.set(a, 1, desc) + rec = tls13_record.seal_record(ctx, tls13_record.CONTENT_ALERT, bytes.view(a)) + send_record_raw(sock, rec) + bytes.wipe_free(a) + if rec != null { bytes.wipe_free(rec) } +} + +// A fatal alert under the connection's application-traffic keys. +fn send_alert_app(c: *ServerConn, desc: int) { + body = bytes.new(2) + bytes.set(body, 0, 2) + bytes.set(body, 1, desc & 0xFF) + rec = tls13_record.seal_record(c.app_send as ptr, tls13_record.CONTENT_ALERT, bytes.view(body)[0..2]) + send_record_raw(c.sock, rec) + bytes.wipe_free(body) + if rec != null { bytes.wipe_free(rec) } +} + +// Send fatal alert `desc`, mark the connection failed, and return (null, +// 0, why) as conn_recv does. +fn conn_fail(c: *ServerConn, desc: int, why: string) -> (ptr, int, string) { + send_alert_app(c, desc) + c.closed = 2 + return null, 0, why +} + +// Receive the next application-data record's plaintext (fresh bytes; caller +// frees via bytes.free): (data, len, ""), (null, 0, "") at the client's +// close_notify, (null, 0, "timeout") when a receive timeout set on the socket +// fires before a record is whole (the connection is kept, and what was read +// of the record waits for the next call, #3405), or (null, 0, error). RFC +// 8446 5 (#3317): a change_cipher_spec +// or a plaintext record after the handshake, a record that does not open +// or is too long, an empty handshake record, and more than +// MAX_EMPTY_RECORDS empty application-data records in a row are each +// refused, with their alert sent before the error returns; so is a record +// of another type inside a handshake message that spans records (5.1). +// Alerts as tls13_client.alert_event says (#3323). The one handshake +// message a client sends after the handshake is a KeyUpdate (#3320): it +// moves the receiving keys on, has the next send update the sending keys +// first when it asks, must end its record, and comes at most +// MAX_KEY_UPDATES times without application data; any other is +// unexpected_message. conn_recv(conn: ptr) -> (ptr, int, string) { c = conn as *ServerConn rio = c.rio as *RecordIO - looking = 1 - out = null - outlen = 0 - rerr = "" - while looking == 1 { + if c.closed == 1 { return null, 0, "" } + if c.closed == 2 { return null, 0, "recv: the connection failed earlier" } + while 1 == 1 { + // The pending check reads nothing new: with no whole record held, + // it has its answer (#3405). + if c.buffered_only == 1 && recio_whole_record(rio) == 0 { return null, 0, "" } rec, rlen, e = recio_next(rio) - if string.equals(e, "") != 1 { rerr = e; looking = 0 } else { - ct = bytes.get(rec, 0) & 0xFF - if ct == 0x14 { bytes.free(rec) } else { - opened = tls13_record.OpenedRecord { content_type: 0, plaintext: null, plaintext_len: 0 } - oerr = tls13_record.open_record(c.app_recv as ptr, bytes.view(rec)[0..rlen], &opened) - bytes.free(rec) - if string.equals(oerr, "") != 1 { rerr = oerr; looking = 0 } else { - if opened.content_type == tls13_record.CONTENT_APPLICATION_DATA { - out = opened.plaintext - outlen = opened.plaintext_len - looking = 0 - } else { - if opened.content_type == tls13_record.CONTENT_ALERT { - desc = 0 - if opened.plaintext_len >= 2 { desc = bytes.get(opened.plaintext, 1) & 0xFF } - if desc != tls13_client.ALERT_CLOSE_NOTIFY { - rerr = string.concat("tls alert: ", tls13_client.alert_name(desc)) - } - looking = 0 - } - if opened.plaintext != null { bytes.free(opened.plaintext) } + // The socket's timeout: nothing more yet, and the connection stays + // as it was, so a later call goes on where this one stopped (#3405). + if string.equals(e, "timeout") == 1 { return null, 0, "timeout" } + if string.equals(e, "") != 1 { + if rio.alert != 0 { send_alert_app(c, rio.alert) } + c.closed = 2 + return null, 0, e + } + ct = bytes.get(rec, 0) & 0xFF + if ct != 0x17 { + bytes.wipe_free(rec) + return conn_fail(c, tls13_record.ALERT_UNEXPECTED_MESSAGE, "record: a plaintext record of type ${ct} after the handshake") + } + opened = tls13_record.OpenedRecord { content_type: 0, plaintext: null, plaintext_len: 0, alert: 0 } + oerr = tls13_record.open_record(c.app_recv as ptr, bytes.view(rec)[0..rlen], &opened) + bytes.wipe_free(rec) + if string.equals(oerr, "") != 1 { + return conn_fail(c, opened.alert, oerr) + } + t = opened.content_type + n = opened.plaintext_len + if t != tls13_record.CONTENT_HANDSHAKE && c.hs_pend_len > 0 { + if opened.plaintext != null { bytes.wipe_free(opened.plaintext) } + return conn_fail(c, tls13_record.ALERT_UNEXPECTED_MESSAGE, "record: a record of type ${t} inside a handshake message") + } + if t == tls13_record.CONTENT_APPLICATION_DATA { + if n > 0 { + c.empties = 0 + c.key_updates = 0 + c.warnings = 0 + return opened.plaintext, n, "" + } + if opened.plaintext != null { bytes.wipe_free(opened.plaintext) } + c.empties = c.empties + 1 + if c.empties > MAX_EMPTY_RECORDS { + return conn_fail(c, tls13_record.ALERT_UNEXPECTED_MESSAGE, "record: more than ${MAX_EMPTY_RECORDS} empty records in a row") + } + } else if t == tls13_record.CONTENT_ALERT { + level = -1 + desc = 0 + if n == 2 { + level = bytes.get(opened.plaintext, 0) & 0xFF + desc = bytes.get(opened.plaintext, 1) & 0xFF + } + if opened.plaintext != null { bytes.wipe_free(opened.plaintext) } + if level < 0 { return conn_fail(c, tls13_record.ALERT_DECODE_ERROR, "alert: a record that is not two bytes") } + ev, a, why = tls13_client.alert_event(level, desc, c.warnings) + if ev == tls13_client.EV_CLOSED { + c.closed = 1 + return null, 0, "" + } + if ev == tls13_client.EV_ERROR { + if a != 0 { send_alert_app(c, a) } + c.closed = 2 + return null, 0, why + } + c.warnings = c.warnings + 1 + } else { + // A handshake record: an empty one is no message at all. + if n == 0 { + if opened.plaintext != null { bytes.wipe_free(opened.plaintext) } + return conn_fail(c, tls13_record.ALERT_UNEXPECTED_MESSAGE, "record: an empty handshake record after the handshake") + } + if c.hs_pend == null { + c.hs_pend = bytes.new(n) + c.hs_pend_len = 0 + } + bytes.copy_from_bytes(c.hs_pend, c.hs_pend_len, opened.plaintext, 0, n) + c.hs_pend_len = c.hs_pend_len + n + bytes.wipe_free(opened.plaintext) + if c.hs_pend_len >= 4 { + mt = bytes.get(c.hs_pend, 0) & 0xFF + ml = ((bytes.get(c.hs_pend, 1) & 0xFF) << 16) | ((bytes.get(c.hs_pend, 2) & 0xFF) << 8) | (bytes.get(c.hs_pend, 3) & 0xFF) + if mt != tls13_hs.HS_KEY_UPDATE { + return conn_fail(c, tls13_record.ALERT_UNEXPECTED_MESSAGE, "handshake: a message of type ${mt} after the handshake") + } + if ml != 1 { return conn_fail(c, tls13_record.ALERT_DECODE_ERROR, "key update: the body is not one byte") } + if c.hs_pend_len >= 5 { + req, ka, kerr = tls13_hs.key_update_request(bytes.view(c.hs_pend)[4..5]) + if ka != 0 { return conn_fail(c, ka, kerr) } + if c.hs_pend_len != 5 { return conn_fail(c, tls13_record.ALERT_UNEXPECTED_MESSAGE, "handshake: data after a KeyUpdate in its record") } + bytes.wipe_free(c.hs_pend) + c.hs_pend = null + c.hs_pend_len = 0 + c.key_updates = c.key_updates + 1 + if c.key_updates > MAX_KEY_UPDATES { + return conn_fail(c, tls13_record.ALERT_UNEXPECTED_MESSAGE, "key update: more than ${MAX_KEY_UPDATES} without application data") } + _sa, _sk, algo, _sh, _se = suite_params(c.suite) + c.recv_secret = tls13_ks.key_update(algo, c.app_recv, c.recv_secret) + if req == tls13_hs.KEY_UPDATE_REQUESTED { c.ku_pending = 1 } } } } } - return out, outlen, rerr } close_conn(conn: ptr) { @@ -602,44 +1251,107 @@ close_conn(conn: ptr) { if c == null { return } tls13_record.free_ctx(c.app_send as ptr) tls13_record.free_ctx(c.app_recv as ptr) + tls13_ks.free_secret(c.send_secret) + tls13_ks.free_secret(c.recv_secret) + if c.hs_pend != null { bytes.wipe_free(c.hs_pend) } + if c.recv_pend != null { bytes.wipe_free(c.recv_pend) } recio_free(c.rio as *RecordIO) - tcp.close(c.sock) + // A connection that failed sent its alert, and may hold what the client + // sent after: drained first, so the alert is not lost to the reset a + // close with bytes unread sends (tcp.close_drained). + if c.closed == 2 { + tcp.close_drained(c.sock) + } else { + tcp.close(c.sock) + } libc_free(conn) } // ---- PEM helpers ---- +// The certificate chain in `pem_text`, the leaf first: the DER of every +// BEGIN CERTIFICATE block, in order (#3378). Every other block (the private +// key of a combined file, EC parameters) is skipped, as OpenSSL skips it, +// and never goes into the chain: the chain is what the server sends every +// client. (chain, lens) or (null, null); parse_cert_chain_pem_checked says +// why. parse_cert_chain_pem(pem_text: string) -> (ptr, ptr) { + chain, lens, _err = parse_cert_chain_pem_checked(pem_text) + return chain, lens +} + +// parse_cert_chain_pem, and why the text gives no chain: no certificate, a +// block without its END line, or a certificate block that does not decode +// or does not parse as a certificate, which refuses the whole text. Each +// entry is parsed here, once, when the chain is loaded. +parse_cert_chain_pem_checked(pem_text: string) -> (ptr, ptr, string) { chain = list_new() lens = list_new() - end_marker = "-----END CERTIFICATE-----" + begin = "-----BEGIN " + dashes = "-----" tlen = string_length(pem_text) - mlen = string_length(end_marker) pos = 0 - while pos < tlen { - e = string.index_of_from(pem_text, end_marker, pos) - if e < 0 { break } - block_end = e + mlen - block = string.substring_n(pem_text, tlen, pos, block_end) - der, _label, perr = pem.parse(block) - if string.equals(perr, "") == 1 && der != null { - list.add(chain, der) - list.add(lens, null) - } - pos = block_end - } - if list_size(chain) == 0 { - list_free(chain) + err = "" + going = 1 + while going == 1 && pos < tlen { + b = string.index_of_from(pem_text, begin, pos) + if b < 0 { + going = 0 + } else { + ls = b + string_length(begin) + le = string.index_of_from(pem_text, dashes, ls) + if le < 0 { + err = "a PEM BEGIN line has no closing dashes" + going = 0 + } else { + label = string.substring_n(pem_text, tlen, ls, le) + end_line = "-----END ${label}-----" + e = string.index_of_from(pem_text, end_line, le + string_length(dashes)) + if e < 0 { + err = "the PEM block ${label} has no END line" + going = 0 + } else { + block_end = e + string_length(end_line) + if string.equals(label, "CERTIFICATE") == 1 { + n = list_size(chain) + 1 + der, _label, perr = pem.parse_at(pem_text, b) + if string.equals(perr, "") != 1 || der == null { + err = "certificate ${n} of the chain does not decode: ${perr}" + going = 0 + } else { + leaf = tls13_cert.LeafCert { tbs: null } + tls13_cert.init_leaf(&leaf) + cerr = tls13_cert.parse_certificate(bytes.view(der)[0..bytes.length(der)], &leaf) + tls13_cert.free_leaf(&leaf) + if string.equals(cerr, "") != 1 { + bytes.wipe_free(der) + err = "certificate ${n} of the chain does not parse: ${cerr}" + going = 0 + } else { + list.add(chain, der) + list.add(lens, null) + } + } + } + pos = block_end + } + } + } + } + if string.equals(err, "") == 1 && list_size(chain) == 0 { err = "no certificate (no BEGIN CERTIFICATE block)" } + if string.equals(err, "") != 1 { + free_cert_chain(chain) list_free(lens) - return null, null + return null, null, err } - return chain, lens + return chain, lens, "" } +// The key's DER and its SEQUENCE copies are wiped as they are freed (#3382). parse_ec_private_key_pem(pem_text: string) -> ptr { der, label, err = pem.parse(pem_text) if string.equals(err, "") != 1 || der == null { - if der != null { bytes.free(der) } + if der != null { wipe_free(der) } return null } dlen = bytes.length(der) @@ -648,14 +1360,14 @@ parse_ec_private_key_pem(pem_text: string) -> ptr { r = asn1.reader_new(der) sub, body, serr = asn1.read_sequence(r) if string.equals(serr, "") != 1 { - asn1.reader_free(r); bytes.free(der) + asn1.reader_free(r); wipe_free(der) return null } // Check version ver_bn, verr = asn1.read_integer(sub) if string.equals(verr, "") != 1 { - asn1.reader_free(sub); asn1.reader_free(r); bytes.free(der); bytes.free(body) + asn1.reader_free(sub); asn1.reader_free(r); wipe_free(der); wipe_free(body) return null } bignum.free(ver_bn) @@ -669,7 +1381,7 @@ parse_ec_private_key_pem(pem_text: string) -> ptr { if string.equals(label, "PRIVATE KEY") == 1 { // PKCS#8 PrivateKeyInfo ::= SEQUENCE { version INTEGER, privateKeyAlgorithm AlgId, privateKey OCTET STRING } sa, saerr = asn1.read_tlv(sub) - if string.equals(saerr, "") == 1 { bytes.free(sa) } + if string.equals(saerr, "") == 1 { bytes.wipe_free(sa) } inner_oct, ioerr = asn1.read_octet_string(sub) if string.equals(ioerr, "") == 1 { // Parse inner ECPrivateKey @@ -680,24 +1392,372 @@ parse_ec_private_key_pem(pem_text: string) -> ptr { if string.equals(iverr, "") == 1 { bignum.free(iver_bn) } poct, poerr = asn1.read_octet_string(isub) if string.equals(poerr, "") == 1 { priv = poct } - asn1.reader_free(isub); bytes.free(ibody) + asn1.reader_free(isub); wipe_free(ibody) } - asn1.reader_free(ir); bytes.free(inner_oct) + asn1.reader_free(ir); wipe_free(inner_oct) } } } - asn1.reader_free(sub); asn1.reader_free(r); bytes.free(der); bytes.free(body) + asn1.reader_free(sub); asn1.reader_free(r); wipe_free(der); wipe_free(body) return priv } +// ---- the server's private key (#3326) ---- + +// A server private key, of any kind the server signs a CertificateVerify +// with: ECDSA P-256, P-384 or P-521 (0x0403, 0x0503, 0x0603), Ed25519 +// (0x0807) or RSA (rsa_pss_rsae_sha256, 0x0804). load_private_key makes +// one, free_private_key frees it (wiping the secret). +// +// The secret is kept shielded (std.cryptography.shield, #3376): sealed +// behind a 16 KiB random pre-key for as long as the server keeps the key, +// in the clear only for each signature, and refused if a bit of it changed +// in memory. +struct ServerKey { + kind: int // tls13_cert.KEY_P256, KEY_P384, KEY_P521, KEY_ED25519 or KEY_RSA + shielded: ptr // the shield handle of the EC scalar (its curve's size), the Ed25519 seed (32 bytes) or the RSA key's PKCS#1 RSAPrivateKey DER +} + +// A ServerKey of `kind` holding `secret` (consumed: shielded, and its +// plaintext wiped), or (null, why). +fn shielded_key(kind: int, secret: @frees ptr) -> (ptr, string) { + h, herr = shield.wrap(secret) + if h == null { return null, "the private key cannot be shielded: ${herr}" } + k = heap.new(ServerKey) + k.kind = kind + k.shielded = h + return k as ptr, "" +} + +// A copy of a secret buffer in one of its exact size, so no growth leaves +// a stray copy behind (#3382). +fn secret_copy(b: @keep ptr) -> ptr { + n = bytes.length(b) + out = bytes.new(n) + bytes.copy_from_bytes(out, 0, b, 0, n) + return out +} + +// The PEM labels a private key can have, in the order looked for. +const KEY_LABEL_PKCS8 = "-----BEGIN PRIVATE KEY-----" +const KEY_LABEL_SEC1 = "-----BEGIN EC PRIVATE KEY-----" +const KEY_LABEL_PKCS1 = "-----BEGIN RSA PRIVATE KEY-----" +const KEY_LABEL_ENCRYPTED = "-----BEGIN ENCRYPTED PRIVATE KEY-----" + +// Zero a secret before giving it back. +fn wipe_free(b: @frees ptr) { + bytes.wipe_free(b) +} + +// The KEY_* a namedCurve OID names; KEY_NONE for any other. +fn curve_kind(oid: string) -> int { + if string.equals(oid, "1.2.840.10045.3.1.7") == 1 { return tls13_cert.KEY_P256 } + if string.equals(oid, "1.3.132.0.34") == 1 { return tls13_cert.KEY_P384 } + if string.equals(oid, "1.3.132.0.35") == 1 { return tls13_cert.KEY_P521 } + return tls13_cert.KEY_NONE +} + +// The scalar size of a curve KEY_*, 0 for another kind. +fn curve_size(kind: int) -> int { + if kind == tls13_cert.KEY_P256 { return 32 } + if kind == tls13_cert.KEY_P384 { return 48 } + if kind == tls13_cert.KEY_P521 { return 66 } + return 0 +} + +// The curve an unnamed scalar's size implies, KEY_NONE for none. +fn kind_of_size(n: int) -> int { + if n == 32 { return tls13_cert.KEY_P256 } + if n == 48 { return tls13_cert.KEY_P384 } + if n == 66 { return tls13_cert.KEY_P521 } + return tls13_cert.KEY_NONE +} + +// An ECPrivateKey (RFC 5915), for the curve `want` names, or (KEY_NONE) +// the curve its own parameters name, else the one its scalar's size implies: +// (kind, scalar of the curve's size) or (KEY_NONE, null). +fn parse_sec1(der: @keep ptr, want: int) -> (int, ptr) { + r = asn1.reader_new(der) + sub, body, serr = asn1.read_sequence(r) + if string.equals(serr, "") != 1 { + asn1.reader_free(r) + return tls13_cert.KEY_NONE, null + } + ver, verr = asn1.read_integer(sub) + if string.equals(verr, "") == 1 { bignum.free(ver) } + oct, oerr = asn1.read_octet_string(sub) + kind = want + if string.equals(oerr, "") == 1 && kind == tls13_cert.KEY_NONE && asn1.reader_eof(sub) == 0 { + // parameters [0] EXPLICIT ECParameters: a namedCurve OID. + params, perr = asn1.read_tagged_ext(sub, 1, 0x80, 0) + if string.equals(perr, "") == 1 { + pr = asn1.reader_new(params) + curve, cerr = asn1.read_oid(pr) + if string.equals(cerr, "") == 1 { + kind = curve_kind(curve) + string.release(curve) + } + asn1.reader_free(pr) + bytes.wipe_free(params) + } + } + // body is the key's SEQUENCE: the scalar is in it (#3382). + asn1.reader_free(sub); wipe_free(body); asn1.reader_free(r) + if string.equals(oerr, "") != 1 || oct == null { return tls13_cert.KEY_NONE, null } + olen = bytes.length(oct) + if kind == tls13_cert.KEY_NONE { kind = kind_of_size(olen) } + size = curve_size(kind) + if size == 0 || olen > size || olen == 0 { + wipe_free(oct) + return tls13_cert.KEY_NONE, null + } + // RFC 5915 fixes the length; an encoder that dropped leading zeros is + // padded back. + scalar = bytes.new(size) + bytes.set(scalar, size - 1, 0) + bytes.copy_from_bytes(scalar, size - olen, oct, 0, olen) + wipe_free(oct) + return kind, scalar +} + +// 1 when `scalar` is a valid private key of curve `kind`. +fn scalar_ok(kind: int, scalar: ptr) -> int { + if kind == tls13_cert.KEY_P256 { return p256.private_key_valid(scalar) } + if kind == tls13_cert.KEY_P384 { return p384.private_key_valid(scalar) } + if kind == tls13_cert.KEY_P521 { return p521.private_key_valid(scalar) } + return 0 +} + +// Why an RSA key cannot be the server's, "" when it can: under 2048 bits +// (the OpenSSL backend refuses it too, #3312) or over 16384. +fn rsa_key_problem(k: ptr) -> string { + bits = rsa.modulus_bits(k) + if bits < 2048 || bits > 16384 { return "an RSA key needs 2048 bits or more (16384 at most)" } + return "" +} + +// Why an EC scalar of curve `kind` cannot be the server's key, "" when it +// can: it must be in [1, n-1] for its curve. +fn ec_key_problem(kind: int, scalar: ptr) -> string { + if scalar == null { return "not an EC private key of P-256, P-384 or P-521" } + if scalar_ok(kind, scalar) != 1 { return "the EC private key is not in [1, n-1]" } + return "" +} + +// A PKCS#8 PrivateKeyInfo (RFC 5208 / 5958): an EC, Ed25519 or RSA key. +fn parse_pkcs8(der: @keep ptr) -> (ptr, string) { + r = asn1.reader_new(der) + sub, body, serr = asn1.read_sequence(r) + if string.equals(serr, "") != 1 { + asn1.reader_free(r) + return null, "not a PKCS#8 private key" + } + ver, verr = asn1.read_integer(sub) + if string.equals(verr, "") == 1 { bignum.free(ver) } + alg, abody, aerr = asn1.read_sequence(sub) + oid = "" + curve = tls13_cert.KEY_NONE + if string.equals(aerr, "") == 1 { + o, oerr = asn1.read_oid(alg) + if string.equals(oerr, "") == 1 { + oid = string.concat(o, "") + string.release(o) + if string.equals(oid, "1.2.840.10045.2.1") == 1 { + c, cerr = asn1.read_oid(alg) + if string.equals(cerr, "") == 1 { + curve = curve_kind(c) + string.release(c) + } + } + } + asn1.reader_free(alg); wipe_free(abody) + } + inner, ierr = asn1.read_octet_string(sub) + // body is the PrivateKeyInfo's SEQUENCE, the key inside it (#3382). + asn1.reader_free(sub); wipe_free(body); asn1.reader_free(r) + if string.equals(ierr, "") != 1 || inner == null { return null, "not a PKCS#8 private key" } + + if string.equals(oid, "1.2.840.10045.2.1") == 1 { + if curve == tls13_cert.KEY_NONE { + wipe_free(inner) + return null, "an EC key on a curve other than P-256, P-384 or P-521" + } + kind, scalar = parse_sec1(inner, curve) + wipe_free(inner) + why = ec_key_problem(kind, scalar) + if string.equals(why, "") != 1 { + if scalar != null { wipe_free(scalar) } + return null, why + } + return shielded_key(kind, scalar) + } + if string.equals(oid, "1.3.101.112") == 1 { + // CurvePrivateKey ::= OCTET STRING, the 32-byte seed (RFC 8410). + ir = asn1.reader_new(inner) + seed, serr2 = asn1.read_octet_string(ir) + asn1.reader_free(ir) + wipe_free(inner) + if string.equals(serr2, "") != 1 || seed == null || bytes.length(seed) != 32 { + if seed != null { wipe_free(seed) } + return null, "not an Ed25519 private key" + } + return shielded_key(tls13_cert.KEY_ED25519, seed) + } + if string.equals(oid, "1.2.840.113549.1.1.1") == 1 { + rk, rwhy = rsa_shielded_key(inner) + wipe_free(inner) + return rk, rwhy + } + wipe_free(inner) + return null, "a private key of an algorithm the server cannot sign with (${oid})" +} + +// The server's private key from PEM text (#3326): a PKCS#8 "PRIVATE KEY" +// (EC P-256, P-384, P-521, Ed25519 or RSA), a SEC1 "EC PRIVATE KEY" (an +// `openssl ecparam -genkey` file's EC PARAMETERS block before it is +// skipped) or a PKCS#1 "RSA PRIVATE KEY". An encrypted key is refused with +// how to decrypt it. (key, "") or (null, why); free_private_key frees it. +load_private_key(pem_text: string) -> (ptr, string) { + if string.index_of(pem_text, KEY_LABEL_ENCRYPTED) >= 0 { + return null, "the private key is encrypted; decrypt it first (openssl pkey -in key.pem -out plain.pem)" + } + at = string.index_of(pem_text, KEY_LABEL_PKCS8) + if at < 0 { at = string.index_of(pem_text, KEY_LABEL_SEC1) } + if at < 0 { at = string.index_of(pem_text, KEY_LABEL_PKCS1) } + if at < 0 { return null, "no private key in the PEM text" } + // Decoded where it lies in the text, never copied out as a string: a + // string cannot be wiped (#3382). + der, label, err = pem.parse_at(pem_text, at) + if string.equals(err, "") != 1 || der == null { + if der != null { wipe_free(der) } + return null, "the private key's PEM does not decode: ${err}" + } + if string.equals(label, "PRIVATE KEY") == 1 { + k, kerr = parse_pkcs8(der) + wipe_free(der) + return k, kerr + } + if string.equals(label, "EC PRIVATE KEY") == 1 { + kind, scalar = parse_sec1(der, tls13_cert.KEY_NONE) + wipe_free(der) + why = ec_key_problem(kind, scalar) + if string.equals(why, "") != 1 { + if scalar != null { wipe_free(scalar) } + return null, why + } + return shielded_key(kind, scalar) + } + rk, rwhy = rsa_shielded_key(der) + wipe_free(der) + return rk, rwhy +} + +// An RSA ServerKey from a PKCS#1 RSAPrivateKey DER (borrowed): the key is +// built once to check it can sign, then freed, and its DER is what is +// shielded; each signature builds the key from it again (#3376). +fn rsa_shielded_key(der: @keep ptr) -> (ptr, string) { + k, kerr = rsa.key_from_pkcs1_der(der) + if string.equals(kerr, "") != 1 || k == null { return null, "not an RSA private key" } + rwhy = rsa_key_problem(k) + rsa.key_free(k) + if string.equals(rwhy, "") != 1 { return null, rwhy } + return shielded_key(tls13_cert.KEY_RSA, secret_copy(der)) +} + +// Free a key load_private_key made, its secret wiped first. +free_private_key(key: @frees ptr) { + if key == null { return } + k = key as *ServerKey + shield.free(k.shielded) + heap.free(k) +} + +// The SignatureScheme the server signs CertificateVerify with: its key's +// own, when the client offered it in signature_algorithms (`offered`, +// tls13_hs.SIGBIT_*); 0 when it did not. +fn key_scheme(k: *ServerKey, offered: int) -> int { + if k.kind == tls13_cert.KEY_P256 && (offered & tls13_hs.SIGBIT_ECDSA_P256) != 0 { return tls13_cert.SCHEME_ECDSA_P256_SHA256 } + if k.kind == tls13_cert.KEY_P384 && (offered & tls13_hs.SIGBIT_ECDSA_P384) != 0 { return tls13_cert.SCHEME_ECDSA_P384_SHA384 } + if k.kind == tls13_cert.KEY_P521 && (offered & tls13_hs.SIGBIT_ECDSA_P521) != 0 { return tls13_cert.SCHEME_ECDSA_P521_SHA512 } + if k.kind == tls13_cert.KEY_ED25519 && (offered & tls13_hs.SIGBIT_ED25519) != 0 { return tls13_cert.SCHEME_ED25519 } + if k.kind == tls13_cert.KEY_RSA && (offered & tls13_hs.SIGBIT_RSA_PSS_SHA256) != 0 { return tls13_cert.SCHEME_RSA_PSS_RSAE_SHA256 } + if k.kind == tls13_cert.KEY_RSA && (offered & tls13_hs.SIGBIT_RSA_PSS_SHA384) != 0 { return tls13_cert.SCHEME_RSA_PSS_RSAE_SHA384 } + if k.kind == tls13_cert.KEY_RSA && (offered & tls13_hs.SIGBIT_RSA_PSS_SHA512) != 0 { return tls13_cert.SCHEME_RSA_PSS_RSAE_SHA512 } + return 0 +} + +// The CertificateVerify signature with the key under `scheme` (key_scheme's) +// over `transcript_hash`, checked against the public key of the parsed +// certificate `leaf` before it is given back: a signature computed wrongly +// (a fault in the arithmetic, a bit that flipped in memory) can disclose the +// key, one faulty RSA-CRT signature or two of one message under a +// deterministic nonce being enough, so one that does not verify is wiped +// and never sent (#3376). OpenSSL checks its RSA-CRT signatures the same +// way. The signature and "", or null and why. +fn sign_cert_verify(k: *ServerKey, leaf: *tls13_cert.LeafCert, scheme: int, transcript_hash: byte[]) -> (ptr, string) { + // The secret in the clear for this one signature only (#3376). + secret, uerr = shield.unwrap(k.shielded) + if secret == null { return null, "the server's private key cannot be used: ${uerr}" } + var sig: ptr = null + if k.kind == tls13_cert.KEY_ED25519 { + sig = tls13_cert.sign_server_cert_verify_ed25519(secret, transcript_hash) + } else if k.kind == tls13_cert.KEY_RSA { + rk, _rerr = rsa.key_from_pkcs1_der(secret) + if rk != null { + sig = tls13_cert.sign_server_cert_verify_rsa_pss_scheme(rk, scheme, transcript_hash) + rsa.key_free(rk) + } + } else { + sig = tls13_cert.sign_server_cert_verify_ecdsa(k.kind, secret, transcript_hash) + } + bytes.wipe_free(secret) + if sig == null { return null, "the server's private key could not sign (a scalar out of range, or no randomness)" } + if tls13_cert.leaf_signature_holds(leaf, scheme, transcript_hash, bytes.view(sig)) != 1 { + bytes.wipe_free(sig) + return null, "the server's signature does not verify with its certificate's key, so it was not sent (a fault while signing, or a key that is not the certificate's)" + } + return sig, "" +} + +// "" when `key` and the certificate `leaf_der` are a pair, else why not: +// the key signs a test CertificateVerify that the certificate's public key +// verifies, with the functions a handshake uses (OpenSSL's +// SSL_CTX_check_private_key does the same check another way). +fn key_matches_cert(k: *ServerKey, leaf_der: ptr) -> string { + leaf = tls13_cert.LeafCert { tbs: null } + tls13_cert.init_leaf(&leaf) + perr = tls13_cert.parse_certificate(bytes.view(leaf_der)[0..bytes.length(leaf_der)], &leaf) + if string.equals(perr, "") != 1 { + tls13_cert.free_leaf(&leaf) + return "the certificate does not parse: ${perr}" + } + if leaf.key_kind != k.kind { + tls13_cert.free_leaf(&leaf) + return "the private key is not of the certificate's key type" + } + th = bytes.new(32) + i = 0 + while i < 32 { + bytes.set(th, i, 0x5a) + i = i + 1 + } + // Any scheme the key signs with: an RSA key's pair check is rsa_pss_rsae_sha256. + sig, _why = sign_cert_verify(k, &leaf, key_scheme(k, 0x7FFFFFFF), bytes.view(th)[0..32]) + bytes.wipe_free(th) + tls13_cert.free_leaf(&leaf) + if sig == null { return "the private key does not match the certificate" } + bytes.wipe_free(sig) + return "" +} + fn free_cert_chain(chain: ptr) { if chain == null { return } n = list_size(chain) i = 0 while i < n { c = list.at(chain, i) as ptr - if c != null { bytes.free(c) } + if c != null { bytes.wipe_free(c) } i = i + 1 } list_free(chain) @@ -705,57 +1765,108 @@ fn free_cert_chain(chain: ptr) { // ---- @extern C entry points ---- -// Consumes fd on every path; a successful connection owns it until close_conn. -@c_callback("aether_pure_tls_server_accept") -pure_tls_server_accept(fd: int, cert_path: string, key_path: string) -> ptr { - sock = tcp_socket_from_fd_owned(fd) - if sock == null { return null } +// Linked in: std.http's server_set_tls_backend(server, "aether") asks +// this before choosing the pure server, so a program that forgot the import +// gets an error then instead of a failed handshake per connection (#1298). +@c_callback("aether_pure_tls_server_available") +pure_tls_server_available() -> int { + return 1 +} - cert_text, cerr = fs.read(cert_path) - if string.equals(cerr, "") != 1 { tcp.close(sock); return null } - key_text, kerr = fs.read(key_path) - if string.equals(kerr, "") != 1 { tcp.close(sock); return null } +// Why a load is refused, for the C that asked (std.http): the callback +// registry's reason (#3426), so this module links nothing of the HTTP server. +extern aether_callback_set_reason(reason: string) + +// A server's certificate chain and private key, parsed and checked once when +// the server is configured (#3362) and shared by every connection after: +// accept and its signing only read them (the EC scalar or Ed25519 seed, the +// RSA key's numbers; RSA blinding draws fresh values for each signature). +struct ServerCreds { + chain: ptr // std.list of certificate DER bytes, the leaf first + lens: ptr // their lengths + key: ptr // *ServerKey +} - chain_list, lens_list = parse_cert_chain_pem(cert_text) +// What std.http's server_set_tls and server_set_tls_pem give the pure server +// (#3328, #3362): the chain and the key as PEM text. They must load and be a +// pair, so a server that cannot complete a handshake is refused when it is +// configured, as the OpenSSL backend refuses it, instead of dropping every +// connection. The creds, or null with why given to aether_callback_set_reason. +// The text is the caller's; nothing here keeps it. +@c_callback("aether_pure_tls_server_load") +pure_tls_server_load(cert_pem: string, key_pem: string) -> ptr { + chain_list, lens_list, cerr = parse_cert_chain_pem_checked(cert_pem) if chain_list == null { - tcp.close(sock) + aether_callback_set_reason("the TLS certificate (PEM): ${cerr}") return null } - - // EC P-256 keys only. The handshake signs CertificateVerify with - // sign_server_cert_verify_ecdsa_p256, so an RSA key cannot be used even - // though the certificate would otherwise be valid -- the handshake fails - // at the signature step and the peer sees a dropped connection. Generate - // the pair with: - // openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 - priv_key = parse_ec_private_key_pem(key_text) - if priv_key == null { + key, perr = load_private_key(key_pem) + if key == null { + free_cert_chain(chain_list) + list_free(lens_list) + aether_callback_set_reason("the TLS private key cannot be used: ${perr}") + return null + } + merr = key_matches_cert(key as *ServerKey, list.at(chain_list, 0) as ptr) + if string.equals(merr, "") != 1 { + free_private_key(key) free_cert_chain(chain_list) list_free(lens_list) + aether_callback_set_reason(merr) + return null + } + c = heap.new(ServerCreds) + c.chain = chain_list + c.lens = lens_list + c.key = key + return c as ptr +} + +// Gives back what pure_tls_server_load made, the key's secret wiped. +@c_callback("aether_pure_tls_server_free_creds") +pure_tls_server_free_creds(creds: @frees ptr) { + if creds == null { return } + c = creds as *ServerCreds + free_private_key(c.key) + free_cert_chain(c.chain) + list_free(c.lens) + heap.free(c) +} + +// Consumes fd on every path; a successful connection owns it until +// close_conn. `creds` are the server's (pure_tls_server_load): borrowed, and +// still the server's after. +@c_callback("aether_pure_tls_server_accept_creds") +pure_tls_server_accept_creds(fd: int, creds: ptr) -> ptr { + sock = tcp_socket_from_fd_owned(fd) + if sock == null { return null } + if creds == null { tcp.close(sock) return null } + c = creds as *ServerCreds x25519_priv = rand_into(32) if x25519_priv == null { // An all-zeros X25519 private key would make the shared secret // trivially recoverable, so refuse rather than fall back. - bytes.free(priv_key) - free_cert_chain(chain_list) - list_free(lens_list) tcp.close(sock) return null } - sconn, err = accept(sock, chain_list, lens_list, priv_key, x25519_priv) - - bytes.free(priv_key) - bytes.free(x25519_priv) - free_cert_chain(chain_list) - list_free(lens_list) + sconn, err = accept(sock, c.chain, c.lens, c.key, x25519_priv) + bytes.wipe_free(x25519_priv) if err != "" { - if sconn != null { close_conn(sconn) } else { tcp.close(sock) } + // A handshake that failed sent its alert: the socket is drained + // before it closes, so the client reads the alert, not a reset. + if sconn != null { + failed = sconn as *ServerConn + failed.closed = 2 + close_conn(sconn) + } else { + tcp.close_drained(sock) + } return null } return sconn @@ -768,7 +1879,7 @@ pure_tls_server_send(sconn: ptr, buf: ptr, len: int) -> int { bytes.set(b, len - 1, 0) mem.copy(bytes.data(b), buf, len as long) err = conn_send(sconn, bytes.view(b)[0..len]) - bytes.free(b) + bytes.wipe_free(b) if err != "" { return -1 } return len } @@ -776,16 +1887,95 @@ pure_tls_server_send(sconn: ptr, buf: ptr, len: int) -> int { @c_callback("aether_pure_tls_server_recv") pure_tls_server_recv(sconn: ptr, buf: ptr, len: int) -> int { if sconn == null || buf == null || len <= 0 { return -1 } + c = sconn as *ServerConn + // The tail of the last record first: a record larger than the caller's + // buffer keeps what did not fit for the next call, as the client + // bridge does (#3381). The HTTP server reads with less than a whole + // record's room (16383 bytes), and its Content-Length loop asks for just + // what the body still lacks, so a dropped tail was a body byte or a + // pipelined request. + if c.recv_pend != null { + avail = c.recv_pend_len - c.recv_pend_off + take = avail + if take > len { take = len } + src = long_to_ptr(ptr_to_long(bytes.data(c.recv_pend)) + (c.recv_pend_off as long)) + copy(buf, src, take as long) + c.recv_pend_off = c.recv_pend_off + take + if c.recv_pend_off >= c.recv_pend_len { + bytes.wipe_free(c.recv_pend) + c.recv_pend = null + c.recv_pend_len = 0 + c.recv_pend_off = 0 + } + return take + } p, n, err = conn_recv(sconn) + // -2: the socket's timeout fired with no data yet, and the connection + // is kept; std.http takes it as its wait for a next request ending + // (#3405). -1: the end or an error. + if err == "timeout" { return -2 } if err != "" || n <= 0 { - if p != null { bytes.free(p) } + if p != null { bytes.wipe_free(p) } return -1 } - copy_n = n - if copy_n > len { copy_n = len } - mem.copy(buf, bytes.data(p), copy_n as long) - bytes.free(p) - return copy_n + take = n + if take > len { take = len } + copy(buf, bytes.data(p), take as long) + if take < n { + c.recv_pend = p + c.recv_pend_len = n + c.recv_pend_off = take + } else { + bytes.wipe_free(p) + } + return take +} + +// Is there application data to read without waiting on the socket (#3384)? +// std.http asks before it parks a connection or waits on its descriptor: +// what the record reader already holds does not show on poll(2), as +// OpenSSL's buffered plaintext does not, which SSL_pending answers there. +// The bytes not yet handed over, after handling the whole records the reader +// holds as conn_recv does: a KeyUpdate, an empty record or a warning is +// handled and is not data (#3405), so a connection with only those left is +// parked, not read from. A record that ends the connection or fails it +// answers 1, so the next read reports that at once. Else 0. +@c_callback("aether_pure_tls_server_pending") +pure_tls_server_pending(sconn: ptr) -> int { + if sconn == null { return 0 } + c = sconn as *ServerConn + if c.recv_pend != null { return c.recv_pend_len - c.recv_pend_off } + if c.closed != 0 { return 1 } + r = c.rio as *RecordIO + // What the socket already holds, taken without waiting: each read takes + // no more than FIONREAD counts, and they go on until a record is whole + // or the socket has nothing left (#3405). + while recio_whole_record(r) == 0 && tcp.available(r.sock) > 0 { + if recio_fill(r) <= 0 { return 1 } + } + if recio_whole_record(r) == 0 { return 0 } + c.buffered_only = 1 + p, n, _err = conn_recv(sconn) + c.buffered_only = 0 + if p != null && n > 0 { + c.recv_pend = p + c.recv_pend_len = n + c.recv_pend_off = 0 + return n + } + if p != null { bytes.wipe_free(p) } + if c.closed != 0 { return 1 } + return 0 +} + +// 1 when the record reader holds a whole record it has already read from +// the socket: its 5-byte header and the length the header gives. +fn recio_whole_record(r: *RecordIO) -> int { + avail = r.len - r.off + if avail < 5 { return 0 } + plen = ((bytes.get(r.buf, r.off + 3) & 0xFF) << 8) | (bytes.get(r.buf, r.off + 4) & 0xFF) + if avail >= 5 + plen { return 1 } + return 0 } @c_callback("aether_pure_tls_server_close") diff --git a/std/cryptography/whirlpool/module.ae b/std/cryptography/whirlpool/module.ae index c8ee6e3d1..262f48c5b 100644 --- a/std/cryptography/whirlpool/module.ae +++ b/std/cryptography/whirlpool/module.ae @@ -33,6 +33,7 @@ import std.bits import std.bytes +import std.mem extern malloc(size: int) -> ptr extern free(p: ptr) @@ -120,7 +121,7 @@ build_tables(c: *WpCtx) { c.c0[i] = word i = i + 1 } - bytes.free(scratch) + bytes.wipe_free(scratch) // rc[0] = 0; rc[r] gathers byte 8*(r-1) from each C table, masked. c.rc[0] = 0 @@ -332,7 +333,7 @@ finalize_to_bytes(c: *WpCtx) -> ptr { bytes.set(c.buf, 32 + j, bytes.get(bitlen, j)) j = j + 1 } - bytes.free(bitlen) + bytes.wipe_free(bitlen) process_filled_buffer(c) out = bytes.new(64) @@ -346,11 +347,11 @@ finalize_to_bytes(c: *WpCtx) -> ptr { free_internals(c: *WpCtx) { if c.buf != null { - bytes.free(c.buf) + bytes.wipe_free(c.buf) c.buf = null } if c.bitcount != null { - bytes.free(c.bitcount) + bytes.wipe_free(c.bitcount) c.bitcount = null } } @@ -362,6 +363,7 @@ final_bytes(ctx: ptr) -> ptr { out = finalize_to_bytes(c) s = bytes.finish(out, 64) free_internals(c) + mem.wipe(ctx, sizeof(WpCtx)) free(ctx) res = bytes.new(64) bytes.copy_from_string(res, 0, s, 64) @@ -391,9 +393,10 @@ final_hex(ctx: ptr) -> string { bytes.set(hexbuf, i * 2 + 1, c_lo) i = i + 1 } - bytes.free(out) + bytes.wipe_free(out) s = bytes.finish(hexbuf, 128) free_internals(c) + mem.wipe(ctx, sizeof(WpCtx)) free(ctx) return s } @@ -407,6 +410,7 @@ free_ctx(ctx: ptr) { } c = ctx as *WpCtx free_internals(c) + mem.wipe(ctx, sizeof(WpCtx)) free(ctx) } diff --git a/std/cryptography/x25519/module.ae b/std/cryptography/x25519/module.ae index fb32360ea..8d2afb5e8 100644 --- a/std/cryptography/x25519/module.ae +++ b/std/cryptography/x25519/module.ae @@ -37,6 +37,7 @@ // Aether has no fixed-size inline array fields. import std.bytes +import std.cryptography.ct extern malloc(size: int) -> ptr extern free(p: ptr) @@ -847,7 +848,7 @@ fn ladder(scalar: ptr, u: ptr) -> ptr { r = bytes.new(POINT_BYTES) fe_encode(&x2, r) - bytes.free(k) + bytes.wipe_free(k) return r } @@ -877,7 +878,7 @@ base_point(scalar: ptr) -> ptr { bytes.set(u, POINT_BYTES - 1, 0) bytes.set(u, 0, 9) r = ladder(scalar, u) - bytes.free(u) + bytes.wipe_free(u) return r } @@ -892,8 +893,10 @@ agree(scalar: ptr, u: ptr) -> (ptr, string) { acc = acc | (bytes.get(r, i) & 255) i = i + 1 } - if acc == 0 { - bytes.free(r) + // Whether the result is zero is public (the handshake ends on it): the + // one value the constant-time harness lets decide a branch here. + if ct.declassify(ct.eq(acc, 0)) == 1 { + bytes.wipe_free(r) return null, "x25519: all-zero shared secret (small-order point)" } return r, "" diff --git a/std/cryptography/x448/module.ae b/std/cryptography/x448/module.ae index 6e7423b84..c5e5cf1bd 100644 --- a/std/cryptography/x448/module.ae +++ b/std/cryptography/x448/module.ae @@ -2,217 +2,154 @@ // Portions copyright (c) 2000-2026 The Legion of the Bouncy Castle Inc. (https://www.bouncycastle.org) // Portions copyright (c) 2026 Aether Developers. // -// std.cryptography.x448 — X448 (RFC 7748) in pure Aether, on top of -// std.bignum. NO externs to OpenSSL. X448 is the Diffie-Hellman function on -// Curve448 (Montgomery form, y^2 = x^3 + 156326 x^2 + x over the field -// GF(2^448 - 2^224 - 1)) computed with a single x-coordinate Montgomery -// ladder. a24 = (156326 - 2) / 4 = 39081. +// std.cryptography.x448: X448 (RFC 7748) in pure Aether, constant time, on +// std.cryptography.ecc's fixed-width field. NO externs to OpenSSL. X448 is +// the Diffie-Hellman function on Curve448 (Montgomery form, +// y^2 = x^3 + 156326 x^2 + x over GF(2^448 - 2^224 - 1)) computed with the +// x-coordinate Montgomery ladder, a24 = (156326 - 2) / 4 = 39081. // -// x448(scalar56, u56) -> shared56 (the core RFC 7748 function) -// base_mult(scalar56) -> public56 (scalar * base point, u=5) +// x448(scalar56, u56) -> shared56 (the core RFC 7748 function) +// agree(scalar56, u56) -> (shared56, err) (refuses an all-zero result) +// base_mult(scalar56) -> public56 (scalar * base point, u = 5) // // All inputs/outputs are 56-byte little-endian std.bytes buffers (the wire -// format). `scalar` is clamped per RFC 7748 §5 (clear low 2 bits of byte 0, -// set the high bit of byte 55). The field is exactly 448 bits so there is no -// high-bit masking of `u`. +// format); a buffer of another length gives null. `scalar` is clamped per +// RFC 7748 section 5 (clear the low 2 bits of byte 0, set the high bit of +// byte 55); `u` is taken modulo p, as the RFC asks for non-canonical values. // -// NOTE: correctness-first port (field math goes through the variable-time -// std.bignum), not a constant-time/side-channel-hardened implementation. -// Validated against the RFC 7748 §5.2 scalar/u vector and the §6.2 -// Diffie-Hellman shared-secret vector. +// The ladder runs all 448 steps whatever the scalar, swaps with masks, and +// does its arithmetic on fixed-width Montgomery limbs: constant time in the +// scalar (tests/integration/crypto_ct_valgrind). agree is the key-agreement +// entry point, as x25519.agree is: an all-zero shared secret means the peer +// sent a small-order point, and RFC 7748 section 6.2 lets (and TLS 1.3 +// requires) the caller refuse it. +// +// About 1 ms per agreement with the default `ae build` on an x86-64 Windows +// desktop (6.6 ms on std.bignum). import std.bytes -import std.bignum +import std.cryptography.ct +import std.cryptography.ecc exports( - x448, base_mult + x448, agree, base_mult ) -// ---- field prime p = 2^448 - 2^224 - 1 ---- -// As 56-byte big-endian: -// ffffffffffffffffffffffffffffffffffffffffffffffffffffffff (28 x ff) -// fffffffffffffffffffffffffffffffffffffffffffffffffffffffe (bytes for -// the 2^224-1 borrow): the low 28 bytes are fe followed by 27 x ff. -// i.e. byte[27] = 0xFE, all others = 0xFF. -field_p() -> ptr { - b = bytes.new(56) - i = 0 - while i < 56 { bytes.set(b, i, 0xFF); i = i + 1 } - bytes.set(b, 27, 0xFE) // big-endian index 27 = the 2^224 bit position - p = bignum.from_bytes_unsigned(b, 56) - bytes.free(b) - return p -} -// p - 2 for the Fermat inverse exponent. -field_p_minus_2() -> ptr { - p = field_p() - two = bignum.from_int(2) - e = bignum.subtract(p, two) - bignum.free(p); bignum.free(two) - return e -} - -// ---- field ops (operands and result are *BN; p is passed in) ---- -fadd(a: ptr, b: ptr, p: ptr) -> ptr { s = bignum.add(a, b); r = bignum.mod(s, p); bignum.free(s); return r } -fsub(a: ptr, b: ptr, p: ptr) -> ptr { d = bignum.subtract(a, b); r = bignum.mod(d, p); bignum.free(d); return r } -fmul(a: ptr, b: ptr, p: ptr) -> ptr { m = bignum.multiply(a, b); r = bignum.mod(m, p); bignum.free(m); return r } -finv(a: ptr, p: ptr) -> ptr { - e = field_p_minus_2() - r = bignum.mod_pow(a, e, p) - bignum.free(e) - return r +fn field(F: *ecc.Field) { + ecc.field_init(F, "fffffffffffffffffffffffffffffffffffffffffffffffffffffffeffffffffffffffffffffffffffffffffffffffffffffffffffffffff") } -// Decode a 56-byte little-endian buffer to a *BN. -from_le56(b: ptr) -> ptr { - be = bytes.new(56) - i = 0 - while i < 56 { - bytes.set(be, i, bytes.get(b, 55 - i)) - i = i + 1 +// X448(scalar, u): the RFC 7748 section 5 function. Returns a fresh 56-byte +// little-endian result (all zero for a small-order u), or null for inputs +// that are not 56 bytes. +x448(scalar: ptr, u: ptr) -> ptr { + if scalar == null || u == null || bytes.length(scalar) != 56 || bytes.length(u) != 56 { + return null } - n = bignum.from_bytes_unsigned(be, 56) - bytes.free(be) - return n -} - -// Encode a *BN (0 <= n < 2^448) as 56-byte little-endian into a fresh buffer. -to_le56(n: ptr) -> ptr { - be = bignum.to_bytes_unsigned(n) - blen = bytes.length(be) + F = ecc.Field {} + field(&F) + // Clamp: clear the low 2 bits of byte 0, set the high bit of byte 55. + k = bytes.new(56) + bytes.copy_from_bytes(k, 0, scalar, 0, 56) + bytes.set(k, 0, bytes.get(k, 0) & 252) + bytes.set(k, 55, bytes.get(k, 55) | 128) + + x1 = ecc.Fe {} + ecc.fe_reduce_le(&F, &x1, u, 0, 56) + ecc.fe_to_mont(&F, &x1, &x1) + x2 = ecc.Fe {} + ecc.fe_one(&F, &x2) + z2 = ecc.Fe {} + ecc.fe_zero(&z2) + x3 = ecc.Fe {} + ecc.fe_copy(&x3, &x1) + z3 = ecc.Fe {} + ecc.fe_one(&F, &z3) + a24 = ecc.Fe {} + ecc.fe_from_int(&F, &a24, 39081) + a = ecc.Fe {} + aa = ecc.Fe {} + b = ecc.Fe {} + bb = ecc.Fe {} + e = ecc.Fe {} + c = ecc.Fe {} + d = ecc.Fe {} + da = ecc.Fe {} + cb = ecc.Fe {} + int swap = 0 + int t = 447 + while t >= 0 { + int kt = (bytes.get(k, t / 8) >> (t % 8)) & 1 + swap = swap ^ kt + ecc.fe_cswap(&F, &x2, &x3, swap) + ecc.fe_cswap(&F, &z2, &z3, swap) + swap = kt + ecc.fe_add(&F, &a, &x2, &z2) + ecc.fe_sqr(&F, &aa, &a) + ecc.fe_sub(&F, &b, &x2, &z2) + ecc.fe_sqr(&F, &bb, &b) + ecc.fe_sub(&F, &e, &aa, &bb) + ecc.fe_add(&F, &c, &x3, &z3) + ecc.fe_sub(&F, &d, &x3, &z3) + ecc.fe_mul(&F, &da, &d, &a) + ecc.fe_mul(&F, &cb, &c, &b) + ecc.fe_add(&F, &x3, &da, &cb) + ecc.fe_sqr(&F, &x3, &x3) + ecc.fe_sub(&F, &z3, &da, &cb) + ecc.fe_sqr(&F, &z3, &z3) + ecc.fe_mul(&F, &z3, &z3, &x1) + ecc.fe_mul(&F, &x2, &aa, &bb) + ecc.fe_mul(&F, &z2, &a24, &e) + ecc.fe_add(&F, &z2, &z2, &aa) + ecc.fe_mul(&F, &z2, &z2, &e) + t = t - 1 + } + ecc.fe_cswap(&F, &x2, &x3, swap) + ecc.fe_cswap(&F, &z2, &z3, swap) + ecc.fe_inv(&F, &z2, &z2) + ecc.fe_mul(&F, &x2, &x2, &z2) + ecc.fe_from_mont(&F, &x2, &x2) out = bytes.new(56) - i = 0 - while i < 56 { bytes.set(out, i, 0); i = i + 1 } - i = 0 - while i < blen { - if i < 56 { - bytes.set(out, i, bytes.get(be, blen - 1 - i)) - } + ecc.fe_to_le(&x2, out, 0, 56) + int i = 0 + while i < 56 { + bytes.set(k, i, 0) i = i + 1 } - bytes.free(be) + bytes.wipe_free(k) return out } -// ---- the Montgomery ladder ---- -// `k` is the already-clamped scalar (*BN), `u` is the field element (*BN). -ladder(k: ptr, u: ptr, p: ptr) -> ptr { - a24 = bignum.from_int(39081) - - one = bignum.from_int(1) - zero = bignum.from_int(0) - - x1 = bignum.clone(u) - x2 = bignum.clone(one) - z2 = bignum.clone(zero) - x3 = bignum.clone(u) - z3 = bignum.clone(one) - swap = 0 - - t = 447 - while t >= 0 { - kt = bignum.shift_right(k, t) - bit = bit0(kt) - bignum.free(kt) - - swap = swap ^ bit - if swap == 1 { - tmp = x2; x2 = x3; x3 = tmp - tmp = z2; z2 = z3; z3 = tmp - } - swap = bit - - A = fadd(x2, z2, p) - AA = fmul(A, A, p) - B = fsub(x2, z2, p) - BB = fmul(B, B, p) - E = fsub(AA, BB, p) - C = fadd(x3, z3, p) - D = fsub(x3, z3, p) - DA = fmul(D, A, p) - CB = fmul(C, B, p) - s1 = fadd(DA, CB, p) - nx3 = fmul(s1, s1, p) - s2 = fsub(DA, CB, p) - s2s = fmul(s2, s2, p) - nz3 = fmul(x1, s2s, p) - nx2 = fmul(AA, BB, p) - a24E = fmul(a24, E, p) - sum = fadd(AA, a24E, p) - nz2 = fmul(E, sum, p) - - bignum.free(x2); bignum.free(z2); bignum.free(x3); bignum.free(z3) - bignum.free(A); bignum.free(AA); bignum.free(B); bignum.free(BB) - bignum.free(E); bignum.free(C); bignum.free(D); bignum.free(DA); bignum.free(CB) - bignum.free(s1); bignum.free(s2); bignum.free(s2s); bignum.free(a24E); bignum.free(sum) - x2 = nx2; z2 = nz2; x3 = nx3; z3 = nz3 - - t = t - 1 +// Diffie-Hellman with RFC 7748's check: (shared, "") or, when the shared +// secret is all zero (a small-order u), (null, error) with nothing kept. +agree(scalar: ptr, u: ptr) -> (ptr, string) { + r = x448(scalar, u) + if r == null { + return null, "x448: scalar and u must be 56 bytes" } - - if swap == 1 { - tmp = x2; x2 = x3; x3 = tmp - tmp = z2; z2 = z3; z3 = tmp + int acc = 0 + int i = 0 + while i < 56 { + acc = acc | (bytes.get(r, i) & 255) + i = i + 1 } - - zinv = finv(z2, p) - res = fmul(x2, zinv, p) - - bignum.free(a24); bignum.free(one); bignum.free(zero) - bignum.free(x1); bignum.free(x2); bignum.free(z2); bignum.free(x3); bignum.free(z3) - bignum.free(zinv) - return res -} - -// Low bit of a *BN (0 or 1). -bit0(n: ptr) -> int { - if bignum.is_zero(n) == 1 { - return 0 + if ct.declassify(ct.eq(acc, 0)) == 1 { + bytes.wipe_free(r) + return null, "x448: all-zero shared secret (small-order point)" } - two = bignum.from_int(2) - r = bignum.mod(n, two) - bit = 0 - if bignum.is_zero(r) != 1 { bit = 1 } - bignum.free(two) - bignum.free(r) - return bit -} - -// ---- public API ---- - -// X448(scalar, u): the RFC 7748 §5 function. `scalar` and `u` are 56-byte -// little-endian buffers; returns a fresh 56-byte little-endian shared secret. -x448(scalar: ptr, u: ptr) -> ptr { - // Clamp the scalar (RFC 7748 §5 for X448): clear the low 2 bits of byte 0, - // set the high bit of byte 55. - s = bytes.new(56) - bytes.copy_from_bytes(s, 0, scalar, 0, 56) - bytes.set(s, 0, bytes.get(s, 0) & 252) - bytes.set(s, 55, bytes.get(s, 55) | 128) - - // No high-bit masking of u: the field is exactly 448 bits. - um = bytes.new(56) - bytes.copy_from_bytes(um, 0, u, 0, 56) - - p = field_p() - k = from_le56(s) - ucoord = from_le56(um) - - r = ladder(k, ucoord, p) - out = to_le56(r) - - bignum.free(p); bignum.free(k); bignum.free(ucoord); bignum.free(r) - bytes.free(s); bytes.free(um) - return out + return r, "" } // scalar * base point (u = 5): the public key for a private scalar. base_mult(scalar: ptr) -> ptr { base = bytes.new(56) - i = 0 - while i < 56 { bytes.set(base, i, 0); i = i + 1 } + int i = 0 + while i < 56 { + bytes.set(base, i, 0) + i = i + 1 + } bytes.set(base, 0, 5) r = x448(scalar, base) - bytes.free(base) + bytes.wipe_free(base) return r } diff --git a/std/cryptography/xoodyak/module.ae b/std/cryptography/xoodyak/module.ae index 9383dc8aa..edaadccf7 100644 --- a/std/cryptography/xoodyak/module.ae +++ b/std/cryptography/xoodyak/module.ae @@ -12,6 +12,7 @@ import std.bits import std.bytes +import std.mem extern malloc(size: int) -> ptr extern free(p: ptr) @@ -238,11 +239,11 @@ finalize_to_bytes(c: *XoodyakCtx) -> ptr { free_internals(c: *XoodyakCtx) { if c.m_state != null { - bytes.free(c.m_state) + bytes.wipe_free(c.m_state) c.m_state = null } if c.m_buf != null { - bytes.free(c.m_buf) + bytes.wipe_free(c.m_buf) c.m_buf = null } } @@ -252,6 +253,7 @@ final_bytes(ctx: ptr) -> ptr { out = finalize_to_bytes(c) s = bytes.finish(out, 32) free_internals(c) + mem.wipe(ctx, sizeof(XoodyakCtx)) free(ctx) res = bytes.new(32) bytes.copy_from_string(res, 0, s, 32) @@ -279,9 +281,10 @@ final_hex(ctx: ptr) -> string { bytes.set(hexbuf, i * 2 + 1, c_lo) i = i + 1 } - bytes.free(out) + bytes.wipe_free(out) s = bytes.finish(hexbuf, 64) free_internals(c) + mem.wipe(ctx, sizeof(XoodyakCtx)) free(ctx) return s } @@ -292,6 +295,7 @@ free_ctx(ctx: ptr) { } c = ctx as *XoodyakCtx free_internals(c) + mem.wipe(ctx, sizeof(XoodyakCtx)) free(ctx) } diff --git a/std/dl/aether_dl.c b/std/dl/aether_dl.c index 7271935b3..4ac359495 100644 --- a/std/dl/aether_dl.c +++ b/std/dl/aether_dl.c @@ -20,6 +20,21 @@ #define AETHER_DL_ERR_CAP 512 +/* What every Aether library binds to in the runtime it runs on (#3439): its + * stubs check the runtime ABI and read the outcome. A library built to run + * on its host's runtime (a script the HTTP gateway loads, linked with its + * runtime calls left to the host) binds them at load, and a host exports + * only the archive members it linked; kept here, a program that loads + * libraries links them, and with them the rest of the host API + * (aether_exec.c keeps it). */ +extern const char* aether_runtime_abi(void); +extern int aether_last_outcome(void); +#if defined(__GNUC__) || defined(__clang__) +__attribute__((used)) static void (*const dl_library_api[])(void) = { + (void (*)(void))aether_runtime_abi, (void (*)(void))aether_last_outcome, +}; +#endif + static AETHER_TLS char g_dl_last_err[AETHER_DL_ERR_CAP]; static void dl_clear_error(void) { diff --git a/std/host/README.md b/std/host/README.md index 468a2e46f..4e4c86333 100644 --- a/std/host/README.md +++ b/std/host/README.md @@ -39,6 +39,14 @@ calling and how long they will wait: the context a shared script needs to make a policy decision, and the reason it is here rather than passed as an argument to every function. +`last_outcome` and `last_outcome_text` say how the last call into a library +export on this thread ended (`OK`, `PANICKED`, `DEADLINE`, `CANCELLED`, +`MEMORY_CAP`, `OUT_OF_MEMORY`) and why. Every export runs in an execution +frame, so a panic ends the call rather than the process; a program that +imports a library in binary form gets a failed call raised again as a +panic, and these read the same outcome without a `try` (#2956, +docs/emit-lib.md). + The `java`, `python`, `ruby` and `go` helpers are the language-specific entry points; `contrib/host//` carries the bridge for each, and `docs/aether-embedded-in-host-applications.md` is the full account. @@ -47,4 +55,5 @@ points; `contrib/host//` carries the bridge for each, and `notify`, `describe`, `input`, `event`, `bindings`, `java`, `python`, `ruby`, `go`, `manifest_get`, `manifest_clear`, `caller_identity`, -`caller_attribute`, `caller_deadline_ms`. +`caller_attribute`, `caller_deadline_ms`, `last_outcome`, `last_outcome_text`, +`OK`, `PANICKED`, `DEADLINE`, `CANCELLED`, `MEMORY_CAP`, `OUT_OF_MEMORY`. diff --git a/std/host/module.ae b/std/host/module.ae index c4ab7a11a..3e7856bc0 100644 --- a/std/host/module.ae +++ b/std/host/module.ae @@ -14,7 +14,11 @@ exports( notify, describe, input, event, bindings, java, python, ruby, go, manifest_get, manifest_clear, - caller_identity, caller_attribute, caller_deadline_ms + caller_identity, caller_attribute, caller_deadline_ms, + last_outcome, last_outcome_text, + account_new, account_free, account_use, account_in_use, account_used, account_tripped, + DEADLINE_ELAPSED, DEADLINE_ACTIVE, TRIPPED_DEADLINE, TRIPPED_MEMORY, + OK, PANICKED, DEADLINE, CANCELLED, MEMORY_CAP, OUT_OF_MEMORY, INCOMPATIBLE ) // Emit an event to the host process. Returns 1 if a handler was found @@ -110,3 +114,96 @@ caller_attribute(key: string) -> string { caller_deadline_ms() -> long { return aether_caller_deadline_ms() } + +// --------------------------------------------------------------------------- +// Outcomes (#2956): how the last call into a library export on this thread +// ended. Every export runs in an execution frame, so a panic in it ends +// the call, not the process. A program that imports a library in binary +// form gets a failed call raised again as a panic, with the same reason, +// which a `try` around the call catches; these read the same outcome +// without one, as a C host reads aether_last_outcome() (libaether.h). +// --------------------------------------------------------------------------- + +const OK = 0 +const PANICKED = 1 +const DEADLINE = 2 +const CANCELLED = 3 +const MEMORY_CAP = 4 +const OUT_OF_MEMORY = 5 +const INCOMPATIBLE = 6 // made for another runtime (#2957); any other is a failure too + +extern aether_last_outcome() -> int +extern aether_last_outcome_text() -> string + +// host.OK, or what ended the last call: host.PANICKED, host.DEADLINE, ... +last_outcome() -> int { + return aether_last_outcome() +} + +// "" for host.OK, else the reason (a panic's message, cut at 1023 bytes). +last_outcome_text() -> string { + return "${aether_last_outcome_text()}" +} + +// --------------------------------------------------------------------------- +// Execution accounts (#2954): a memory allowance and a deadline that belong +// to one logical execution, a script that yields to its host and resumes +// later while others run on the same thread, as libaether.h describes them +// for a C host. The account in use on this thread is charged every +// cap-aware allocation (strings, collections, JSON) and gives every library +// call its deadline; a free credits the account that was charged, wherever +// it happens. An account is a raw `ptr` the program owns from account_new to +// account_free (nothing frees it for you, and the runtime keeps no reference +// to it after account_free); the calls below only borrow it. +// --------------------------------------------------------------------------- + +const DEADLINE_ELAPSED = 0 // wall time since the account was made +const DEADLINE_ACTIVE = 1 // time spent in calls under it +const TRIPPED_DEADLINE = 1 // account_tripped bits +const TRIPPED_MEMORY = 2 + +extern aether_exec_account_make(memory_bytes: long, deadline_ms: long, counts: int, parent: ptr) -> ptr +extern aether_exec_account_free(account: ptr) +extern aether_exec_use(account: ptr, previous: ptr) -> int +extern aether_account_here() -> ptr +extern aether_exec_account_used(account: ptr) -> long +extern aether_exec_account_tripped(account: ptr) -> int + +// A new account: at most `memory_bytes` live (0: no limit of its own), a +// deadline of `deadline_ms` (0: none) counted as host.DEADLINE_ELAPSED or +// host.DEADLINE_ACTIVE, and `parent` (null, or an account it narrows: what +// it holds counts against the parent too). null when out of memory, or when +// `parent` is no live account (freed, or another runtime copy's). +account_new(memory_bytes: long, deadline_ms: long, counts: int, parent: ptr) -> ptr { + return aether_exec_account_make(memory_bytes, deadline_ms, counts, parent) +} + +// Frees an account. The blocks charged to it are detached (its parent chain +// credited for them), and its children go under its own parent. Freed while +// in use, it is in use no more: account_in_use() is null after, +// here and, from its next allocation or call, on any other thread. +account_free(account: ptr) { + aether_exec_account_free(account) +} + +// Puts `account` (null: none) in use on this thread: 0, or -1 when it is in +// use on another thread. +account_use(account: ptr) -> int { + return aether_exec_use(account, null) +} + +// The account in use on this thread, or null. +account_in_use() -> ptr { + return aether_account_here() +} + +// The live bytes charged to `account` (and its children). +account_used(account: ptr) -> long { + return aether_exec_account_used(account) +} + +// host.TRIPPED_DEADLINE and host.TRIPPED_MEMORY bits: what the account ran +// out of. 0 when neither. +account_tripped(account: ptr) -> int { + return aether_exec_account_tripped(account) +} diff --git a/std/http/README.md b/std/http/README.md index 479b33f04..f12335335 100644 --- a/std/http/README.md +++ b/std/http/README.md @@ -59,33 +59,79 @@ if err != "" { } ``` +A program that holds its certificate and key in memory, and should not write +the key to disk to hand it over, gives them as PEM text instead: +`server_set_tls_pem(server, cert_pem, key_pem)` makes the same checks with +the same results (#3362). Either way the first certificate is the server's +own and the rest are the chain it sends with it, so a CA's bundle (leaf, then +intermediates) serves as issued on both backends (#3370). Only the +`BEGIN CERTIFICATE` blocks form the chain: any other block in the text, such +as the key in a file that holds both, is skipped, and a certificate block +that does not parse refuses the whole text (#3378). The server keeps +its own copy of the text, for a later `server_set_tls_backend`, and wipes it +when it is replaced or the server is freed. + **Two backends.** OpenSSL is the default wherever it is compiled in. The other is a pure-Aether TLS 1.3 implementation with no OpenSSL dependency at all, -selected with `AETHER_PURE_TLS=1`, and used automatically when the build has -no OpenSSL, which is what lets a cross-compiled binary (`ae build --target=…`, -which links none) serve HTTPS at all. +used automatically when the build has no OpenSSL, which is what lets a +cross-compiled binary (`ae build --target=…`, which links none) serve HTTPS at +all. A program chooses explicitly with `http.server_set_tls_backend(server, +"aether")` (or `"openssl"`, or `""` for the default); `AETHER_PURE_TLS=1` +moves a whole process onto the pure stack, and an explicit choice wins over +it. `server_set_tls_backend` returns an error string when the backend is not +built in or not linked (#1298): -The pure backend is **opt-in at the application**, because it reads the -certificate and key itself and so needs filesystem capability that a plain -`std.http` user should not inherit: +```aether,fragment +err = http.server_set_tls_backend(server, "aether") +if err != "" { println("TLS backend: ${err}"); return } +``` + +The pure backend is **opt-in at the application**: a program that uses it +imports it, and a plain `std.http` user does not carry it. The server reads +the certificate and key files for both backends, so the pure server reads +no files itself (and needs no `--with=fs` under `--emit=lib`); it parses +them once, when the server is set up, and every connection shares that +(#3362): ```aether,fragment import std.http -import std.cryptography.tls13_server // pure TLS server; needs --with=fs +import std.cryptography.tls13_server // pure TLS server ``` -The pure backend has constraints the OpenSSL one does not, and most fail as a -**dropped connection rather than a message**, so they are worth knowing before -debugging one: - -- the server key must be **ECDSA P-256**: there is no RSA signing path, so an - RSA certificate produces a server that fails at the signature step - (`openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1`) +The pure backend has constraints the OpenSSL one does not; they are worth +knowing before debugging a handshake: + +- the server key is **ECDSA P-256, P-384 or P-521, Ed25519, or RSA** of 2048 + to 16384 bits (PKCS#8, SEC1 or PKCS#1 PEM, not encrypted), and + CertificateVerify is signed with the key's own scheme (RSA as + rsa_pss_rsae_sha256, or rsa_pss_rsae_sha384 or rsa_pss_rsae_sha512 for a + client that offers only those, #3357): a client that offers none of the + key's schemes gets `handshake_failure` (#3326). `server_set_tls` checks the + certificate and key when the server is set up and says why it cannot serve + them, as the OpenSSL backend does (#3328) +- every CertificateVerify signature is checked against the certificate's + key before it is sent: a signature computed wrongly (a fault while + signing, a bit flipped in memory) can disclose the key, so one that does + not verify is never sent, and the handshake ends with `internal_error` + (#3376). The check is a verification per handshake, about as costly as + the signature for ECDSA and Ed25519, about a tenth of it for RSA +- the private key is kept encrypted in memory (`std.cryptography.shield`, + as OpenSSH keeps its keys), in the clear only for each signature, so a + memory-reading attack must read a 16 KiB random pre-key without an error + to get it; a key whose encrypted form changed in memory is not used + (#3376) - key exchange is **X25519MLKEM768** (the post-quantum hybrid, preferred - when the client offers it) **or X25519** -- **HelloRetryRequest is not implemented**, so a client offering only other - groups (a P-256-only client, say) is refused with a `handshake_failure` - alert rather than renegotiated + when the client offers it), **X25519 or secp256r1** (P-256, which RFC 8446 + 9.1 requires, #3357); a client that names one of them in supported_groups + but sends no key share for it gets a **HelloRetryRequest** asking for that + share (#3325), and a client offering none of them (a P-384-only client, + say) is refused with a `handshake_failure` alert +- a handshake message or a write over one TLS record (2^14 bytes), such as + a certificate chain over 16 KiB or a large response, goes out in as many + records as it takes (#3380), and the pure client reads one split across + records (#3321) up to 256 KiB for the server's Certificate and 64 KiB for + its other handshake messages (#3366); the pure server takes a ClientHello + split across records, up to 64 KiB, and asks for no client certificate The client side is the mirror, and is now wired the same way (#1849): `std.http.client` does HTTPS through OpenSSL where it is compiled in, and @@ -98,10 +144,12 @@ import std.cryptography.tls13_client to the program and `https://` works in a cross-built binary that links no TLS library at all. The pure client offers X25519MLKEM768 by default, alongside -X25519 and P-256 key shares, so it gets post-quantum key exchange from any -server that supports it (#2483). `set_insecure` and `set_cafile` are honoured on the pure path -too, and an https request through a forward proxy works because the CONNECT -tunnel is established before the handshake either way. +X25519 and P-256 key shares (and names P-384 without one, for a server that +asks for it in a HelloRetryRequest, #3369), so it gets post-quantum key +exchange from any server that supports it (#2483). `set_insecure` and +`set_cafile` are honoured on the pure path too, and an https request through +a forward proxy works because the CONNECT tunnel is established before the +handshake either way. Without that import the pure client is not linked, and an https request in a no-OpenSSL build fails with an error naming the missing import rather than @@ -110,7 +158,22 @@ returning an empty body. (`std.http.client` does not import it for you: `--emit=lib --with=net` reject every `std.http.client` user, the same reason the server side is opt-in.) -In a build that HAS OpenSSL, `AETHER_PURE_TLS=1` (the variable that already +**Choosing the client backend per request.** `client.set_tls_backend(req, +"aether")` sends this request through the pure client, `"openssl"` through +OpenSSL, `""` back to the default (OpenSSL where it is built in, unless +`AETHER_PURE_TLS=1`); the choice wins over the environment variable, and +pooled connections are kept apart per backend. It returns an error string +for a backend that cannot be used: `"openssl"` in a build without OpenSSL, +`"aether"` without `import std.cryptography.tls13_client`, an unknown name +(#1298). + +```aether,fragment +req = client.request("GET", "https://example.com/") +err = client.set_tls_backend(req, "aether") +if err != "" { println("TLS backend: ${err}"); return } +``` + +In a build that HAS OpenSSL, `AETHER_PURE_TLS=1` (the variable that also selects the pure server) moves client requests onto the pure client as well, provided the program imports it (otherwise the request fails, naming the import). That is how the pure path is tested on every platform, not only in @@ -130,20 +193,51 @@ certificate directory that loads) Android's `/apex/com.android.conscrypt/cacerts file with no bundle at all, then `/etc/ssl/certs`. If nothing loads, the handshake fails closed. +**What it validates.** The server's certificate must name the host +(subjectAltName only, on the OpenSSL backend too since #3313: DNS names, with +a whole-label leading wildcard over at least two labels, and IP +addresses), and there must be a path from it +through the certificates the server sent to one of those anchors, which +must be a self-signed root, on this backend as on OpenSSL's (a trusted +intermediate ends no path; pinning one is #3311) +(`tls13_cert.validate_chain`, RFC 5280 section 6 as the Web PKI profiles it) +on which every signature verifies (RSA PKCS#1 v1.5 with SHA-256, SHA-384 or +SHA-512; ECDSA on P-256, P-384 or P-521; Ed25519), every key is one of those +(RSA of at least 2048 bits, named curves only; the OpenSSL backend refuses +the same RSA keys, at security level 2 or above, #3312), every certificate +is inside its validity period, every issuer below the anchor is a version 3 CA +(basicConstraints cA) allowed to sign certificates (keyUsage keyCertSign +when keyUsage is present) within its pathLenConstraint, the leaf's keyUsage +(when present) allows digitalSignature, which TLS 1.3 signs with, the leaf +and every issuer that lists extendedKeyUsage allow serverAuth (a leaf without +extendedKeyUsage is valid for any purpose, as RFC 5280 says and as OpenSSL +and Go's crypto/x509 read it: the backend a program runs on must not change +which chains it accepts), every issuer's name +constraints hold (DNS names, IP addresses, mailboxes, URI hosts, directory +names; a wildcard is refused under an excluded name it could match), and no +certificate has a critical extension the client does not process (nor a +critical key identifier, which OpenSSL refuses too). Every candidate path is +tried, within a bound, so an extra or cross-signed certificate in the +server's list does not hide a good one. Before #3303 only the names and the signatures were +checked, and a certificate a CA issued to any site could sign one for any +other. The rules are measured against the x509-limbo and bettertls corpora +in CI; `tests/conformance/x509_limbo/expected.txt` lists the cases that +differ and why, each one the OpenSSL backend accepts too. A stapled OCSP response saying revoked fails the handshake; +there is no CRL or online revocation check. + **Scope and errors.** The pure client speaks TLS 1.3 only. A server that does not is refused with an error that says so (`server does not support TLS 1.3`), not a hang. Handshake failures carry the real cause (an untrusted chain, a certificate not valid for the requested host, no trust store) prefixed with `TLS handshake failed (pure-Aether TLS 1.3):`. -**Performance.** A pure handshake against a real CDN (a P-256 leaf over -secp384r1 intermediates) completes in about 1.3 seconds, and a full cross-built -HTTPS request in under a second. That is slower than OpenSSL but well inside any -server's handshake deadline. An earlier version of this note measured 12-22 -seconds and warned that the default request timeout could expire mid-handshake; -that cost was `std.bignum`'s bit-serial division inside every P-256 and P-384 -field reduction, and it is gone: the division is Knuth's Algorithm D (#2595), -and a P-256 signature check takes about 15 ms. No timeout adjustment is needed. +**Performance.** A whole pure-client GET of a real site (github.com, +www.cloudflare.com, www.bing.com: DNS, trust store, handshake with full path +validation, response) takes 0.4 to 0.5 seconds on a desktop, network +included. A P-256 signature check takes about 0.5 ms (`std.cryptography.ecc`, +#2600); earlier versions of this note measured 12 to 22 seconds, then 1.3, +when every field operation went through `std.bignum`. No timeout adjustment is +needed. See also `std.http.server.lb` for load balancing, `std.tcp` for raw sockets, and `docs/http-server.md` for routing, middleware, TLS and the HTTP/2 path. diff --git a/std/http/client/module.ae b/std/http/client/module.ae index 41c3a3a89..1639930fc 100644 --- a/std/http/client/module.ae +++ b/std/http/client/module.ae @@ -55,7 +55,7 @@ import std.string exports( request, set_header, set_body, set_timeout, set_follow_redirects, set_insecure, - set_cafile, set_stream, use_env_proxy, use_http_proxy, ignore_http_proxy, request_free, + set_cafile, set_partial_chain, set_tls_backend, set_stream, use_env_proxy, use_http_proxy, ignore_http_proxy, request_free, send_request, send_stream, response_status, response_body, response_body_bytes, response_error, response_redirect_error, response_is_stream, response_read, @@ -78,6 +78,8 @@ extern http_request_set_timeout_ns_raw(req: ptr, timeout: Duration) -> int extern http_request_set_follow_redirects_raw(req: ptr, max_hops: int) -> int extern http_request_set_insecure_raw(req: ptr, on: int) -> int extern http_request_set_cafile_raw(req: ptr, path: string) -> int +extern http_request_set_partial_chain_raw(req: ptr, on: int) -> int +extern http_request_set_tls_backend_raw(req: ptr, name: string) -> int extern http_request_set_stream_raw(req: ptr, on: int) -> int extern http_request_use_env_proxy_raw(req: ptr, on: int) -> int extern http_request_use_http_proxy_raw(req: ptr, proxy_url: string) -> int @@ -197,6 +199,38 @@ set_cafile(req: ptr, path: string) -> string { return "" } +// Trust partial chains for this request (#3311): any certificate in the trust +// store (the system one, or set_cafile's) ends a certification path, not only +// a self-signed root. For a CA file holding an intermediate CA alone: a +// private PKI that hands out its issuing CA, or pinning one CA below a public +// root. Peer and hostname verification stay on, and a trusted certificate +// that is not a root must still be a CA (v3, cA, keyCertSign). Off by +// default, as on curl and OpenSSL; the same on both TLS backends (OpenSSL's +// X509_V_FLAG_PARTIAL_CHAIN). Pooled connections are kept apart by it. +set_partial_chain(req: ptr, on: int) -> string { + rc = http_request_set_partial_chain_raw(req, on) + if rc != 0 { return "set_partial_chain failed (null req)" } + return "" +} + +// Choose the TLS backend for this request (#1298): "openssl", or "aether", +// the pure-Aether TLS 1.3 client (std.cryptography.tls13_client); "" returns +// to the default. The default is OpenSSL wherever it is built in, and the +// pure client where it is not (every `ae build --target=` cross-build) or +// when AETHER_PURE_TLS=1; a choice made here wins over both. The pure client +// must be linked: `import std.cryptography.tls13_client` in the program. +// Returns "" or why the backend cannot be used: an unknown name, "openssl" in +// a build without OpenSSL, or "aether" without the import. Pooled +// connections are kept apart per backend. +set_tls_backend(req: ptr, backend: string) -> string { + rc = http_request_set_tls_backend_raw(req, backend) + if rc == 0 { return "" } + if rc == -1 { return "set_tls_backend failed (null req)" } + if rc == -3 { return "set_tls_backend: this build has no OpenSSL, so the \"openssl\" TLS backend is not available" } + if rc == -4 { return "set_tls_backend: the pure-Aether TLS client is not linked; add `import std.cryptography.tls13_client` to the program" } + return "set_tls_backend: unknown TLS backend \"${backend}\" (use \"aether\", \"openssl\" or \"\")" +} + // Enable streaming of the response body for this request (#1004). When on, // send_request returns a response whose body is NOT buffered: pull it window- // by-window with response_read until an empty chunk. Peak memory is one window diff --git a/std/http/module.ae b/std/http/module.ae index 46fa26721..858f86db1 100644 --- a/std/http/module.ae +++ b/std/http/module.ae @@ -39,7 +39,8 @@ exports( server_set_host, http_server_start_background_raw, http_server_stop, http_server_free, - http_server_set_tls_raw, server_set_tls, + http_server_set_tls_raw, server_set_tls, http_server_set_tls_pem_raw, server_set_tls_pem, + http_server_set_tls_backend_raw, server_set_tls_backend, http_server_set_keepalive_raw, server_set_keepalive, http_server_set_h2_raw, server_set_h2, http_server_set_multi_accept_raw, server_set_multi_accept, @@ -301,6 +302,39 @@ server_set_tls(server: ptr, cert_path: string, key_path: string) -> { return http_server_set_tls_raw(server, cert_path, key_path) } +extern http_server_set_tls_pem_raw(server: ptr, cert_pem: string, key_pem: string) -> string + +// server_set_tls with the certificate chain and private key as PEM text, for +// a program that holds them in memory and should not write the key to disk +// (#3362). The first certificate is the leaf, the rest its chain; the same +// checks as server_set_tls, the same results, on both backends. The server +// keeps its own copy of the text, wiped when it is replaced or the server is +// freed. Returns "" or why not. +// +// Example: +// err = http.server_set_tls_pem(server, cert_pem, key_pem) +// if err != "" { println("TLS setup failed: ${err}"); exit(1) } +server_set_tls_pem(server: ptr, cert_pem: string, key_pem: string) -> { + return http_server_set_tls_pem_raw(server, cert_pem, key_pem) +} + +// Choose the server's TLS backend (#1298): "openssl", or "aether", the +// pure-Aether TLS 1.3 server (std.cryptography.tls13_server); "" returns to +// the default, which is OpenSSL wherever it is built in and the pure server +// where it is not or when AETHER_PURE_TLS=1. A choice made here wins over +// both. Call it before server_start, before or after server_set_tls (it +// re-applies the certificate and key). The pure server must be linked: +// `import std.cryptography.tls13_server` in the program. Returns "" or why +// the backend cannot be used. +// +// err = http.server_set_tls_backend(server, "aether") +// if err != "" { println("TLS backend: ${err}"); exit(1) } +extern http_server_set_tls_backend_raw(server: ptr, name: string) -> string + +server_set_tls_backend(server: ptr, backend: string) -> string { + return http_server_set_tls_backend_raw(server, backend) +} + // HTTP/2 (#260 Tier 2). When enabled: // - the TLS context advertises "h2" via ALPN with "http/1.1" as // the fallback, so HTTP/2-capable clients (curl --http2, diff --git a/std/json/aether_json.c b/std/json/aether_json.c index 59a8d0c52..5224cfceb 100644 --- a/std/json/aether_json.c +++ b/std/json/aether_json.c @@ -579,6 +579,7 @@ typedef struct { int line; // 1-indexed int col; // 1-indexed Arena* arena; // where we allocate values/strings/arrays + unsigned values; // parsed so far, for the stop check below } Parser; static inline int p_eof(const Parser* s) { @@ -1278,7 +1279,18 @@ static JsonValue* parse_object(Parser* s, int depth) { } } +/* A call into a library that runs long on its input (#2956): every 4096 + * values the parse asks whether the running execution's deadline passed or + * it was cancelled, and stops with an error (the document's arena freed by + * the caller), so the call ends at its next safe point. Outside an execution + * this is one load and a branch per 4096 values. */ +extern int aether_exec_should_stop(void); + static JsonValue* parse_value_depth(Parser* s, int depth) { + if ((++s->values & 4095u) == 0 && aether_exec_should_stop()) { + err_set(s->line, s->col, "stopped: the call's deadline passed or it was cancelled"); + return NULL; + } if (depth > JSON_MAX_DEPTH) { err_set(s->line, s->col, "nesting depth exceeds limit of %d", JSON_MAX_DEPTH); return NULL; @@ -1328,6 +1340,7 @@ JsonValue* json_parse_raw_n(const char* data, size_t n) { s.line = 1; s.col = 1; s.arena = a; + s.values = 0; JsonValue* root = parse_value_depth(&s, 0); if (!root) { diff --git a/std/log/aether_log.c b/std/log/aether_log.c index b924a7c7e..7858edb15 100644 --- a/std/log/aether_log.c +++ b/std/log/aether_log.c @@ -1,4 +1,5 @@ #include "aether_log.h" +#include "../../runtime/utils/aether_fatal.h" /* aether_fatal_flush (#3435) */ #include #include #include @@ -172,6 +173,7 @@ void log_writef(LogLevel level, const char* fmt, ...) { // If fatal, abort if (level == LOG_LEVEL_FATAL) { + aether_fatal_flush(); /* the program's lines first (#3435, aether_fatal.h) */ abort(); } } @@ -230,6 +232,7 @@ void log_with_location(LogLevel level, const char* file, int line, // If fatal, abort if (level == LOG_LEVEL_FATAL) { + aether_fatal_flush(); /* the program's lines first (#3435, aether_fatal.h) */ abort(); } } diff --git a/std/mem/README.md b/std/mem/README.md index 530b340d2..d899d5eea 100644 --- a/std/mem/README.md +++ b/std/mem/README.md @@ -122,6 +122,19 @@ because none exists in the offset-less forms. within its own buffer is an overlapping interior copy; `copy_at` is `memcpy` and is undefined there, exactly as in C. +## Wiping a secret before a free + +`mem.wipe(dst, n)` zeroes `n` bytes in a way the C compiler cannot drop. +`mem.set(dst, 0, n)` right before the memory is freed is a dead store, which +an optimizing compiler removes, leaving the secret in freed memory. It is +the raw-memory form of `bytes.wipe`, for a struct a module allocates and +frees itself, such as a hash context that held a key's digest (#3402): + +```aether,fragment +mem.wipe(ctx, sizeof(Sha2Ctx)) +free(ctx) +``` + ## How much the heap holds `mem.heap_in_use()` is the bytes the C allocator has handed out and not taken diff --git a/std/mem/aether_mem.c b/std/mem/aether_mem.c index 13cbacb52..42542f28e 100644 --- a/std/mem/aether_mem.c +++ b/std/mem/aether_mem.c @@ -35,6 +35,7 @@ #include #include +#include "../../runtime/utils/aether_wipe.h" /* Read unsigned byte at offset i. Returns 0..255. Returns -1 if * `p` is NULL — sole defensive check, since a NULL deref would @@ -446,6 +447,14 @@ void* aether_mem_set_bulk(void* dst, int value, int64_t n) { return __builtin_memset(dst, value, (size_t)n); } +/* Zero n bytes at dst so the writes happen even right before a free: the + * raw-memory counterpart of bytes.wipe, for a struct the program frees + * itself (a hash context, say, #3402). */ +void aether_mem_wipe(void* dst, int64_t n) { + if (!dst || n <= 0) return; + aether_wipe(dst, (size_t)n); +} + /* Offset forms of the three bulk writers (#1733 ask 2). * * The offset-less versions above can only start at byte 0 of each buffer, diff --git a/std/mem/module.ae b/std/mem/module.ae index 778b9896b..585cee0c0 100644 --- a/std/mem/module.ae +++ b/std/mem/module.ae @@ -47,7 +47,7 @@ exports( udiv64_32, call_fn3_int, call_fn3_void, call_fn2_void, ptr_to_long, long_to_ptr, - copy, move, compare, set, + copy, move, compare, set, wipe, copy_at, move_at, fill_at, compare_at, get_u16_le, get_u16_be, set_u16_le, set_u16_be, get_u32_le, get_u32_be, set_u32_le, set_u32_be, @@ -248,6 +248,7 @@ extern aether_mem_copy(dst: ptr, src: ptr, n: long) -> ptr extern aether_mem_move(dst: ptr, src: ptr, n: long) -> ptr extern aether_mem_compare(a: ptr, b: ptr, n: long) -> int extern aether_mem_set_bulk(dst: ptr, value: int, n: long) -> ptr +extern aether_mem_wipe(dst: ptr, n: long) extern aether_mem_copy_at(dst: ptr, dst_off: long, src: ptr, src_off: long, n: long) -> ptr extern aether_mem_move_at(dst: ptr, dst_off: long, src: ptr, src_off: long, n: long) -> ptr extern aether_mem_fill_at(dst: ptr, off: long, value: int, n: long) -> ptr @@ -321,6 +322,12 @@ move(dst: ptr, src: ptr, n: long) -> ptr { return aether_mem_move(dst, src, n) } compare(a: ptr, b: ptr, n: long) -> int { return aether_mem_compare(a, b, n) } set(dst: ptr, value: int, n: long) -> ptr { return aether_mem_set_bulk(dst, value, n) } +// Zero `n` bytes at `dst` so the writes happen even right before a free, +// where a plain set is a dead store the C compiler may drop. The raw-memory +// counterpart of bytes.wipe, for a struct a module frees itself, such as a +// hash context holding secret state (#3402). +wipe(dst: ptr, n: long) { aether_mem_wipe(dst, n) } + // Offset forms of the bulk operations (#1733). // // The four above can only start at byte 0 of each buffer, and Aether has no diff --git a/std/net/aether_actor_bridge.c b/std/net/aether_actor_bridge.c index 59ace47b1..5ad290cf9 100644 --- a/std/net/aether_actor_bridge.c +++ b/std/net/aether_actor_bridge.c @@ -17,6 +17,9 @@ #include #include #include +#if !defined(_WIN32) && AETHER_HAS_NETWORKING +#include /* send, MSG_NOSIGNAL: ae_fd_write (#3457) */ +#endif // TLS variables owned by the scheduler + send path. We reach into them // here to identify the "current" actor and core at the point of call. @@ -112,10 +115,23 @@ int ae_pipe_write_fd(void) { // Write a NUL-terminated string to an fd. Returns bytes written // on success, -1 on failure. +// +// A socket whose peer closed fails with EPIPE rather than ending the process +// with SIGPIPE (#3457), as std.tcp's sends do: Linux asks it of send(), so a +// socket is written with send(MSG_NOSIGNAL) and anything else (a pipe) with +// write(); macOS and the BSDs carry it on the socket (SO_NOSIGPIPE, which +// std.tcp sets on every socket it makes). send() says an fd is no socket +// with ENOTSOCK, and lwIP's (ESP-IDF) with EBADF, which write() also gives +// an fd that is not open. int ae_fd_write(int fd, const char* data) { if (fd < 0 || !data) return -1; size_t len = strlen(data); +#if !defined(_WIN32) && AETHER_HAS_NETWORKING && defined(MSG_NOSIGNAL) + ssize_t n = send(fd, data, len, MSG_NOSIGNAL); + if (n < 0 && (errno == ENOTSOCK || errno == EBADF)) n = write(fd, data, len); +#else ssize_t n = write(fd, data, len); +#endif return (int)n; } diff --git a/std/net/aether_http.c b/std/net/aether_http.c index ed62de2d0..1d6ef5e1e 100644 --- a/std/net/aether_http.c +++ b/std/net/aether_http.c @@ -119,6 +119,8 @@ int http_request_set_timeout_ns_raw(HttpClientRequest* r, int64_t ns) { (void)r; int http_request_set_follow_redirects_raw(HttpClientRequest* r, int n) { (void)r; (void)n; return -1; } int http_request_set_insecure_raw(HttpClientRequest* r, int on) { (void)r; (void)on; return -1; } int http_request_set_cafile_raw(HttpClientRequest* r, const char* p) { (void)r; (void)p; return -1; } +int http_request_set_partial_chain_raw(HttpClientRequest* r, int on) { (void)r; (void)on; return -1; } +int http_request_set_tls_backend_raw(HttpClientRequest* r, const char* n) { (void)r; (void)n; return -1; } int http_request_use_env_proxy_raw(HttpClientRequest* r, int on) { (void)r; (void)on; return -1; } int http_request_use_http_proxy_raw(HttpClientRequest* r, const char* u) { (void)r; (void)u; return -1; } int http_request_ignore_http_proxy_raw(HttpClientRequest* r) { (void)r; return -1; } @@ -151,10 +153,6 @@ void http_client_pool_size_for_proxy(void) {} #ifdef _WIN32 #include #include - /* The ROOT certificate store (#2675). Before the OpenSSL headers, which - * undefine the names wincrypt.h shares with them (X509_NAME and others); - * aether_thread.h's WIN32_LEAN_AND_MEAN keeps windows.h from bringing it. */ - #include #ifdef _MSC_VER #pragma comment(lib, "ws2_32.lib") #endif @@ -204,7 +202,9 @@ void http_client_pool_size_for_proxy(void) {} * connection or a forward-proxy CONNECT tunnel -- http_dial completes the * tunnel before calling this, so proxying needs no separate path. * - * verify_mode: 0 = full verification, 1 = skip it (set_insecure). + * verify_mode: a bit set. 0 = full verification; 1 = skip it (set_insecure); + * 2 = any certificate in the trust store ends a path, not only a self-signed + * root (set_partial_chain, #3311). * cafile: a pinned CA bundle path (set_cafile), or NULL for the default store. * Returns an opaque connection, or NULL on failure. */ AETHER_WEAK void* aether_pure_tls_client_connect(int fd, const char* host, @@ -268,43 +268,44 @@ static int pt_client_available(void) { * The connect callback hands back only a pointer, so before this every pure * failure -- an untrusted chain, a wrong hostname, a TLS 1.2-only server, no * trust store at all -- reached the caller as one generic sentence that - * guessed at the cause. tls13_client knows the real reason; it stores it here + * guessed at the cause. tls13_client knows the real reason; it gives it as + * the callback's reason (aether_callback_set_reason, thread-local, #3426) * just before returning NULL, and http_dial copies it into the request's - * error. Thread-local because the handshake runs synchronously on the - * dialling thread, so two requests on two threads cannot see each other's - * reason, and no lock is needed. Cleared before each connect so a stale - * reason is never reported for a later failure that did not set one. */ -static _Thread_local char pt_client_last_err[512]; - -void aether_pure_tls_client_set_error(const char* msg) { - if (!msg) { pt_client_last_err[0] = '\0'; return; } - size_t n = strlen(msg); - if (n >= sizeof(pt_client_last_err)) n = sizeof(pt_client_last_err) - 1; - memcpy(pt_client_last_err, msg, n); - pt_client_last_err[n] = '\0'; -} - -/* Which TLS client backend an https request uses. + * error. The handshake runs synchronously on the dialling thread, so two + * requests on two threads cannot see each other's reason, and no lock is + * needed. Cleared before each connect so a stale reason is never reported + * for a later failure that did not set one. It went through a function of + * this file, which linked the whole HTTP client into every program that + * imported the pure client. */ + +/* Which TLS client backend an https request uses (#1298). * - * A build without OpenSSL has only the pure-Aether client. A build WITH it - * keeps OpenSSL as the default and switches to the pure client when - * AETHER_PURE_TLS is 1 or true -- the same variable that already selects the - * pure TLS server (aether_http_server.c), so one setting moves a process onto - * the pure stack in both directions. Read per request rather than cached, so - * the pool key and the dial always agree on the answer for a given request. */ -static int http_client_use_pure_tls(void) { -#ifndef AETHER_HAS_OPENSSL - return 1; -#else - const char* v = getenv("AETHER_PURE_TLS"); - return v && (strcmp(v, "1") == 0 || strcasecmp(v, "true") == 0); -#endif -} + * A request names one with http_request_set_tls_backend_raw ("aether" or + * "openssl"). Otherwise: a build without OpenSSL has only the pure-Aether + * client; a build WITH it keeps OpenSSL as the default and switches to the + * pure client when AETHER_PURE_TLS is 1 or true -- the same variable that + * selects the pure TLS server (aether_http_server.c), so one setting moves a + * process onto the pure stack in both directions. Read per request rather + * than cached, so the pool key and the dial always agree on the answer for a + * given request. Defined after struct HttpClientRequest. */ +#define HTTP_TLS_BACKEND_DEFAULT 0 +#define HTTP_TLS_BACKEND_AETHER 1 +#define HTTP_TLS_BACKEND_OPENSSL 2 +static int http_client_use_pure_tls(const HttpClientRequest* req); #ifdef AETHER_HAS_OPENSSL #include #include #include +/* The hostname check: no partial-label wildcards ("f*.example.com"), and + * never the subject CN (#3313), only the SANs, as the pure-Aether backend + * reads them (RFC 9525), so both backends accept the same servers. Every + * OpenSSL from 1.1.0 has the flag. */ +#ifdef X509_CHECK_FLAG_NEVER_CHECK_SUBJECT +#define AETHER_HOSTFLAGS (X509_CHECK_FLAG_NO_PARTIAL_WILDCARDS | X509_CHECK_FLAG_NEVER_CHECK_SUBJECT) +#else +#define AETHER_HOSTFLAGS X509_CHECK_FLAG_NO_PARTIAL_WILDCARDS +#endif #endif static int http_initialized = 0; @@ -396,156 +397,9 @@ int parse_url_alloc(const char* url, char* host, size_t host_size, // Windows trust anchors, the same for both HTTPS clients (#2675) // ----------------------------------------------------------------- // -// Neither client read the Windows certificate store, so a program copied to a -// machine without MSYS2 or Git for Windows could make no HTTPS request at all, -// and the two looked for different bundle files (OpenSSL's list here, the -// pure-Aether client's in std.cryptography.tls13_client), so one could work on -// a machine where the other failed. Both now trust, unless SSL_CERT_FILE or -// SSL_CERT_DIR names the store, every certificate in the system ROOT store -// (what Python's ssl module loads on Windows) together with the first bundle -// file of the one list below. The bundle joins the store rather than standing -// in for it: Windows adds a root to the store on demand, the first time -// CryptoAPI needs it, so a root a server chains to can be missing from the -// store of a machine that has never met that server. -// -// The tls13_client module reaches these through extern declarations, which is -// why they live here, beside the OpenSSL code that uses them too, and why -// they exist (returning nothing) on every platform. - -#ifdef _WIN32 -/* wincrypt.h is included with winsock2.h at the top, before the OpenSSL - * headers, which undefine the names it shares with them (X509_NAME...). */ - -/* The first file of the bundle list that exists, in `out`; "" when none - * does. In order: the bundle the release archive ships beside the program - * (\bin\prog.exe -> \share\ssl\ca-bundle.crt, or the program's own - * directory when it is not in a bin\), MSYS2's ca-certificates for MINGW64 - * and UCRT64 under both names it has used, and Git for Windows'. */ -static const char* windows_ca_bundle(char* out, size_t cap) { - static const char* fixed[] = { - "C:\\msys64\\mingw64\\etc\\ssl\\certs\\ca-bundle.crt", - "C:\\msys64\\ucrt64\\etc\\ssl\\certs\\ca-bundle.crt", - "C:\\msys64\\mingw64\\etc\\ssl\\cert.pem", - "C:\\msys64\\ucrt64\\etc\\ssl\\cert.pem", - "C:\\Program Files\\Git\\mingw64\\etc\\ssl\\certs\\ca-bundle.crt", - NULL - }; - char exe_path[MAX_PATH]; - DWORD n = GetModuleFileNameA(NULL, exe_path, sizeof(exe_path)); - if (n > 0 && n < sizeof(exe_path)) { - char* p = strrchr(exe_path, '\\'); // ...\bin\prog.exe -> ...\bin - if (p) { - *p = '\0'; - char* q = strrchr(exe_path, '\\'); // ...\bin -> ... - if (q && _stricmp(q, "\\bin") == 0) *q = '\0'; - } - snprintf(out, cap, "%s\\share\\ssl\\ca-bundle.crt", exe_path); - DWORD a = GetFileAttributesA(out); - if (a != INVALID_FILE_ATTRIBUTES && !(a & FILE_ATTRIBUTE_DIRECTORY)) return out; - } - for (int i = 0; fixed[i]; i++) { - DWORD a = GetFileAttributesA(fixed[i]); - if (a != INVALID_FILE_ATTRIBUTES && !(a & FILE_ATTRIBUTE_DIRECTORY)) { - snprintf(out, cap, "%s", fixed[i]); - return out; - } - } - if (cap) out[0] = '\0'; - return out; -} - -/* Calls `each` with the DER of every certificate in the ROOT system store of - * the current user, which includes the machine's. Returns how many. */ -static int windows_root_store_each(void (*each)(const unsigned char* der, size_t len, void* arg), - void* arg) { - HCERTSTORE store = CertOpenSystemStoreW(0, L"ROOT"); - if (!store) return 0; - int n = 0; - PCCERT_CONTEXT c = NULL; - while ((c = CertEnumCertificatesInStore(store, c)) != NULL) { - if ((c->dwCertEncodingType & X509_ASN_ENCODING) && c->cbCertEncoded > 0) { - each(c->pbCertEncoded, (size_t)c->cbCertEncoded, arg); - n++; - } - } - CertCloseStore(store, 0); - return n; -} - -typedef struct { char* buf; size_t len, cap; int oom; } PemOut; - -static void pem_out_append(PemOut* o, const char* s, size_t n) { - if (o->oom) return; - if (o->len + n + 1 > o->cap) { - size_t want = o->cap ? o->cap * 2 : 65536; - while (want < o->len + n + 1) want *= 2; - char* grown = (char*)aether_caps_realloc(o->buf, o->cap, want); - if (!grown) { o->oom = 1; return; } - o->buf = grown; - o->cap = want; - } - memcpy(o->buf + o->len, s, n); - o->len += n; - o->buf[o->len] = '\0'; -} - -/* One certificate as a PEM block, base64 in lines of 64. */ -static void pem_out_cert(const unsigned char* der, size_t len, void* arg) { - static const char b64[] = - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - PemOut* o = (PemOut*)arg; - pem_out_append(o, "-----BEGIN CERTIFICATE-----\n", 28); - char line[65]; - size_t col = 0; - for (size_t i = 0; i < len; i += 3) { - unsigned v = (unsigned)der[i] << 16; - if (i + 1 < len) v |= (unsigned)der[i + 1] << 8; - if (i + 2 < len) v |= der[i + 2]; - line[col++] = b64[(v >> 18) & 63]; - line[col++] = b64[(v >> 12) & 63]; - line[col++] = i + 1 < len ? b64[(v >> 6) & 63] : '='; - line[col++] = i + 2 < len ? b64[v & 63] : '='; - if (col == 64) { - line[col++] = '\n'; - pem_out_append(o, line, col); - col = 0; - } - } - if (col) { - line[col++] = '\n'; - pem_out_append(o, line, col); - } - pem_out_append(o, "-----END CERTIFICATE-----\n", 26); -} -#endif // _WIN32 - -/* Every certificate of the Windows ROOT system store as PEM text, a fresh - * counted string the caller releases; "" when there is none, and on any other - * platform. NULL only when out of memory. */ -char* aether_tls_windows_store_pem(void) { -#ifdef _WIN32 - PemOut o = { NULL, 0, 0, 0 }; - windows_root_store_each(pem_out_cert, &o); - if (o.oom) { aether_caps_free(o.buf, o.cap); return NULL; } - if (o.buf) return (char*)string_adopt_caps_buffer(o.buf, o.len, o.cap); -#endif - return (char*)string_empty(); -} - -/* The first bundle file of the list above that exists, a fresh counted string - * the caller releases; "" when none does, and on any other platform. NULL only - * when out of memory. */ -char* aether_tls_windows_bundle(void) { -#ifdef _WIN32 - char path[MAX_PATH + 64]; - windows_ca_bundle(path, sizeof(path)); - size_t n = strlen(path); -#else - const char* path = ""; - size_t n = 0; -#endif - return (char*)string_new_with_length(path, n); -} +// In aether_tls_trust.c (#3426), which the pure-Aether client reads too, +// without linking this file. +#include "aether_tls_trust.h" // ----------------------------------------------------------------- // OpenSSL context: lazy init, shared across all HTTPS calls @@ -584,9 +438,9 @@ static int load_windows_trust(SSL_CTX* ctx) { if (file && SSL_CTX_load_verify_locations(ctx, file, NULL) == 1) loaded = 1; if (dir && SSL_CTX_load_verify_locations(ctx, NULL, dir) == 1) loaded = 1; } else { - loaded = windows_root_store_each(x509_store_add_der, SSL_CTX_get_cert_store(ctx)) > 0; + loaded = aether_tls_windows_root_store_each(x509_store_add_der, SSL_CTX_get_cert_store(ctx)) > 0; char bundle[MAX_PATH + 64]; - if (windows_ca_bundle(bundle, sizeof(bundle))[0] && + if (aether_tls_windows_ca_bundle(bundle, sizeof(bundle))[0] && SSL_CTX_load_verify_locations(ctx, bundle, NULL) == 1) loaded = 1; } /* What failed to load (a duplicate, a missing file) is no error of any @@ -596,6 +450,21 @@ static int load_windows_trust(SSL_CTX* ctx) { } #endif // _WIN32 +/* Security level 2 at least (#3312): RSA, DSA and DH keys of 2048 bits or + * more and elliptic curves of 224 bits or more, in the certificate chain and + * the handshake, whatever level the distribution configures (OpenSSL's own + * default, level 1, accepts RSA-1024; Debian and Ubuntu raise it to 2). A + * system set higher stays higher. The pure-Aether TLS backend refuses the + * same keys, so which backend a program runs on does not change which peers + * it accepts. */ +static void tls_min_security_level(SSL_CTX* ctx) { +#if !defined(LIBRESSL_VERSION_NUMBER) || LIBRESSL_VERSION_NUMBER >= 0x3060000fL + if (SSL_CTX_get_security_level(ctx) < 2) SSL_CTX_set_security_level(ctx, 2); +#else + (void)ctx; +#endif +} + // Get (or lazily create) the shared SSL_CTX. Benign race on first call — // compare-exchange ensures at most one SSL_CTX is installed even if two // threads reach here simultaneously. Returns NULL on OpenSSL error. @@ -615,6 +484,7 @@ static SSL_CTX* get_ssl_ctx(void) { ctx = SSL_CTX_new(TLS_client_method()); if (!ctx) return NULL; + tls_min_security_level(ctx); SSL_CTX_set_min_proto_version(ctx, TLS1_2_VERSION); SSL_CTX_set_verify(ctx, SSL_VERIFY_PEER, NULL); @@ -829,11 +699,12 @@ static int64_t http_now_ms(void) { return (int64_t)http_clock_ms(); } /* Everything that makes two connections non-interchangeable: the origin, the * endpoint actually dialled (proxy or origin), TLS and which backend carries * it (`use_tls` is 0 plain, 1 OpenSSL, 2 pure-Aether), and the verification the - * caller asked for. A connection opened with a pinned CA or with verification - * off must never be handed to a request that did not ask for that. */ + * caller asked for (`verify`: http_req_verify_mode's bits). A connection + * opened with a pinned CA, with verification off or with partial chains + * trusted must never be handed to a request that did not ask for that. */ static void http_pool_key(char* out, size_t n, const char* host, int port, int use_tls, const char* dial_host, int dial_port, - int insecure, const char* cafile) { + int verify, const char* cafile) { const char* h = host ? host : ""; const char* dh = dial_host ? dial_host : ""; const char* ca = cafile ? cafile : ""; @@ -846,7 +717,7 @@ static void http_pool_key(char* out, size_t n, const char* host, int port, if (port < 0 || dial_port < 0 || hl + dhl + cal + 48 > n) { snprintf(out, n, "%s:%d|%s:%d|%d|%d|%s", h, port, dh, dial_port, - use_tls, insecure ? 1 : 0, ca); + use_tls, verify & 3, ca); return; } @@ -861,7 +732,7 @@ static void http_pool_key(char* out, size_t n, const char* host, int port, *p++ = '|'; *p++ = (char)('0' + (use_tls >= 0 && use_tls <= 9 ? use_tls : 1)); *p++ = '|'; - *p++ = insecure ? '1' : '0'; + *p++ = (char)('0' + (verify & 3)); *p++ = '|'; memcpy(p, ca, cal); p += cal; *p = '\0'; @@ -1333,6 +1204,11 @@ struct HttpClientRequest { * verification on. Applied per-connection via a * per-SSL X509_STORE, never on the shared SSL_CTX * (#1107). */ + int partial_chain; /* 1: any certificate in the trust store (the system + * one, or set_cafile's) ends a path, not only a + * self-signed root: OpenSSL's X509_V_FLAG_PARTIAL_CHAIN + * and the pure client's TRUST_PARTIAL_CHAIN (#3311). + * Off by default; per-SSL, never on a shared CTX. */ int stream; /* 0 = buffer the whole body (default); 1 = streaming: * read only the header block and hand the open * transport to an HttpStream for incremental reads (#1004). */ @@ -1345,8 +1221,22 @@ struct HttpClientRequest { * PROXY_MODE_DIRECT (0) default: no proxy */ int proxy_mode; char* proxy_url; /* owned; set only for PROXY_MODE_EXPLICIT */ + int tls_backend; /* HTTP_TLS_BACKEND_*: DEFAULT follows AETHER_PURE_TLS + * and the build; AETHER or OPENSSL is the program's + * choice for this request (set_tls_backend, #1298). */ }; +static int http_client_use_pure_tls(const HttpClientRequest* req) { + if (req && req->tls_backend == HTTP_TLS_BACKEND_AETHER) return 1; + if (req && req->tls_backend == HTTP_TLS_BACKEND_OPENSSL) return 0; +#ifndef AETHER_HAS_OPENSSL + return 1; +#else + const char* v = getenv("AETHER_PURE_TLS"); + return v && (strcmp(v, "1") == 0 || strcasecmp(v, "true") == 0); +#endif +} + /* Proxy-mode ladder (see struct HttpClientRequest.proxy_mode). */ #define PROXY_MODE_DIRECT 0 #define PROXY_MODE_ENV 1 @@ -1547,6 +1437,28 @@ int http_request_set_insecure_raw(HttpClientRequest* req, int on) { return 0; } +/* Trust partial chains for THIS request (#3311): any certificate in the trust + * store, the system one or set_cafile's, ends a path, not only a self-signed + * root. For a deployment that distributes an intermediate CA alone (a private + * PKI that hands out the issuing CA, or pinning one CA below a public root). + * Peer and hostname verification stay on, and a trusted certificate that is + * not a root must still be a CA. Both backends: OpenSSL's + * X509_V_FLAG_PARTIAL_CHAIN, set per SSL, and the pure client's + * TRUST_PARTIAL_CHAIN. `on` non-zero enables it; 0 (the default) does not. */ +int http_request_set_partial_chain_raw(HttpClientRequest* req, int on) { + if (!req) return -1; + req->partial_chain = on ? 1 : 0; + return 0; +} + +/* The verification a request asked for, as the bridge's verify_mode bit set + * (1 = skip, 2 = partial chains). It is part of a pooled connection's + * identity as well. */ +static int http_req_verify_mode(const HttpClientRequest* req) { + if (!req) return 0; + return (req->insecure ? 1 : 0) | (req->partial_chain ? 2 : 0); +} + /* Pin a custom CA for THIS request: verify the peer certificate against the PEM * bundle at `path` instead of the system trust store, while keeping peer and * hostname verification ON (#1107). This is the "verify, but against THIS cert" @@ -1562,6 +1474,34 @@ int http_request_set_cafile_raw(HttpClientRequest* req, const char* path) { return 0; } +/* Choose THIS request's TLS client backend (#1298): "aether" (the pure-Aether + * TLS 1.3 client, std.cryptography.tls13_client) or "openssl"; "" goes back to + * the default (OpenSSL where it is built in, unless AETHER_PURE_TLS selects + * the pure client). The choice is part of the pooled connection's identity. + * Returns 0; -1 on a null request; -2 for any other name; -3 when the build + * has no OpenSSL; -4 when the pure client is not linked into the program. */ +int http_request_set_tls_backend_raw(HttpClientRequest* req, const char* name) { + if (!req) return -1; + if (!name || !*name) { + req->tls_backend = HTTP_TLS_BACKEND_DEFAULT; + return 0; + } + if (strcmp(name, "aether") == 0) { + if (!pt_client_available()) return -4; + req->tls_backend = HTTP_TLS_BACKEND_AETHER; + return 0; + } + if (strcmp(name, "openssl") == 0) { +#ifdef AETHER_HAS_OPENSSL + req->tls_backend = HTTP_TLS_BACKEND_OPENSSL; + return 0; +#else + return -3; +#endif + } + return -2; +} + /* #1004: enable streaming response bodies for THIS request. When on, the * response returned by http_send_raw carries an open HttpStream instead of a * buffered body; the caller pulls the body via http_response_read_chunk_raw @@ -2330,11 +2270,17 @@ static void ae_set_err(char** out_err, const char* msg) { * Split out of http_request_internal so a pooled connection that turns out to * be dead can be redialled without duplicating any of this (#1653). */ /* Why HTTPS cannot run in this program, or NULL when it can: the - * pure-Aether client is selected (AETHER_PURE_TLS=1, or a build without - * OpenSSL) but not linked. The program's to fix, and known before any - * network is touched. */ -static const char* http_tls_backend_missing(void) { - if (!http_client_use_pure_tls() || pt_client_available()) return NULL; + * pure-Aether client is selected (set_tls_backend "aether", AETHER_PURE_TLS=1, + * or a build without OpenSSL) but not linked. The program's to fix, and known + * before any network is touched. (set_tls_backend itself refuses "aether" + * when the client is not linked; this catches the other two routes.) */ +static const char* http_tls_backend_missing(const HttpClientRequest* req) { + if (!http_client_use_pure_tls(req) || pt_client_available()) return NULL; + if (req && req->tls_backend == HTTP_TLS_BACKEND_AETHER) { + return "set_tls_backend(\"aether\") selects the pure-Aether TLS client, " + "but it is not linked into this program. Add " + "`import std.cryptography.tls13_client` to the program."; + } #ifdef AETHER_HAS_OPENSSL return "AETHER_PURE_TLS selects the pure-Aether TLS client, but it is " "not linked into this program. Add " @@ -2358,7 +2304,7 @@ static int http_dial(HttpClientRequest* req, AetherAddrList* addrs, * next time. */ const char* why = NULL; int sockfd = aether_net_dial_addrs(dial_host, dial_port, addrs, - req->timeout_ns > 0 ? req->timeout_ns : 0, &why); + req->timeout_ns > 0 ? req->timeout_ns : 0, 0, &why); if (sockfd < 0) { ae_set_err(out_err, why); return -1; @@ -2454,18 +2400,18 @@ static int http_dial(HttpClientRequest* req, AetherAddrList* addrs, * Verification is the pure client's own: certificate chain to a trusted * anchor, validity window, and hostname/SAN pinned to `host`. set_insecure * skips it; set_cafile pins a couriered bundle. */ - if (use_tls && http_client_use_pure_tls()) { + if (use_tls && http_client_use_pure_tls(req)) { /* http_request_internal has said so before dialling; a dial from * anywhere else must not reach the handshake without a client. */ - const char* missing = http_tls_backend_missing(); + const char* missing = http_tls_backend_missing(req); if (missing) { close(sockfd); ae_set_err(out_err, missing); return -1; } - aether_pure_tls_client_set_error(NULL); + aether_callback_set_reason(NULL); void* pc = pt_client_connect(sockfd, host, - req && req->insecure ? 1 : 0, + http_req_verify_mode(req), req ? req->cafile : NULL); if (!pc) { /* Not close(sockfd): the descriptor was handed over, and the pure @@ -2474,13 +2420,14 @@ static int http_dial(HttpClientRequest* req, AetherAddrList* addrs, * NUMBER, which another thread may already have been given for a * fresh socket of its own. * - * tls13_client leaves the reason in pt_client_last_err. Only a + * tls13_client leaves the reason as the callback's. Only a * failure before it got that far (out of memory, an unusable * socket) arrives without one. */ char emsg[640]; - if (pt_client_last_err[0]) { + const char* reason = aether_callback_reason(); + if (reason[0]) { snprintf(emsg, sizeof(emsg), "TLS handshake failed (pure-Aether TLS 1.3): %s", - pt_client_last_err); + reason); } else { snprintf(emsg, sizeof(emsg), "TLS handshake failed (pure-Aether TLS 1.3): the connection " @@ -2519,6 +2466,7 @@ static int http_dial(HttpClientRequest* req, AetherAddrList* addrs, if (req->cafile) { ctx = SSL_CTX_new(TLS_client_method()); if (ctx) { + tls_min_security_level(ctx); SSL_CTX_set_min_proto_version(ctx, TLS1_2_VERSION); SSL_CTX_set_verify(ctx, SSL_VERIFY_PEER, NULL); if (SSL_CTX_load_verify_locations(ctx, req->cafile, NULL) != 1) { @@ -2563,10 +2511,13 @@ static int http_dial(HttpClientRequest* req, AetherAddrList* addrs, // still verifies. Mirrors curl -k / wget --no-check-certificate. SSL_set_verify(ssl, SSL_VERIFY_NONE, NULL); } else { - // Verify the cert's CN/SAN matches the host we connected to. For an - // IP literal (e.g. a Proxmox API at https://192.168.0.204:8006), + // Verify the cert's SAN matches the host we connected to (never the + // subject CN, #3313: the pure-Aether backend reads SANs only, as + // RFC 9525 and browsers do, and the backends must accept the same + // servers). For an IP literal (e.g. a Proxmox API at + // https://192.168.0.204:8006), // set1_ip_asc pins the IP SAN; for a DNS name, set1_host pins the - // DNS SAN/CN. Using the IP-specific call for IP literals is correct + // DNS SAN. Using the IP-specific call for IP literals is correct // across OpenSSL versions (older set1_host did not auto-detect IPs). // The trust anchor for THIS connection is either the shared system // store or, when set_cafile was called, the couriered CA loaded onto @@ -2575,7 +2526,12 @@ static int http_dial(HttpClientRequest* req, AetherAddrList* addrs, // stronger than set_insecure and fails closed if the CA doesn't // cover the presented cert. X509_VERIFY_PARAM* vpm = SSL_get0_param(ssl); - X509_VERIFY_PARAM_set_hostflags(vpm, X509_CHECK_FLAG_NO_PARTIAL_WILDCARDS); + X509_VERIFY_PARAM_set_hostflags(vpm, AETHER_HOSTFLAGS); + // set_partial_chain (#3311): any trusted certificate ends a + // path, as on the pure backend; this SSL's param only. + if (req->partial_chain) { + X509_VERIFY_PARAM_set_flags(vpm, X509_V_FLAG_PARTIAL_CHAIN); + } struct in_addr in4; struct in6_addr in6; if (inet_pton(AF_INET, host, &in4) == 1 || @@ -3482,7 +3438,7 @@ static HttpResponse* http_request_internal(HttpClientRequest* req) { * connect failed first (a server not up yet, a loaded machine) the * request reported "connection failed" instead. */ if (use_tls) { - const char* missing = http_tls_backend_missing(); + const char* missing = http_tls_backend_missing(req); if (missing) { free(path); response->error = string_new(missing); @@ -3555,9 +3511,9 @@ static HttpResponse* http_request_internal(HttpClientRequest* req) { /* The TLS backend is part of a connection's identity: 1 = OpenSSL, * 2 = pure-Aether. A pooled OpenSSL connection must never serve a request * that asked for the pure client, or the reverse. */ - int tls_kind = use_tls ? (http_client_use_pure_tls() ? 2 : 1) : 0; + int tls_kind = use_tls ? (http_client_use_pure_tls(req) ? 2 : 1) : 0; http_pool_key(pool_key, sizeof(pool_key), host, port, tls_kind, - dial_host, dial_port, req->insecure, req->cafile); + dial_host, dial_port, http_req_verify_mode(req), req->cafile); /* A streaming response hands the transport to the caller, who may abandon * it mid-body, so those connections are never pooled in either direction. * A caller who set their own Connection header gets exactly that, and a diff --git a/std/net/aether_http.h b/std/net/aether_http.h index 1bbb6318c..be14573be 100644 --- a/std/net/aether_http.h +++ b/std/net/aether_http.h @@ -132,19 +132,10 @@ const char* http_client_pool_configure_raw(int max_idle, int max_per_host, void http_client_pool_clear_raw(void); int http_client_pool_idle_count_raw(void); -/* std.cryptography.tls13_client records why a pure-TLS handshake failed, so - * the https request's error names the real cause (untrusted chain, wrong host, - * a server that does not speak TLS 1.3) rather than a guess. Thread-local. */ -void aether_pure_tls_client_set_error(const char* msg); - -/* The Windows trust anchors both HTTPS clients use (#2675), for - * std.cryptography.tls13_client: every certificate of the ROOT system store as - * PEM text, and the first bundle file of the list std.http.client's OpenSSL - * path looks for. Each a fresh counted AetherString (cast to char*) the - * caller releases with string_release, "" when there is nothing (always, off - * Windows), NULL only when out of memory. */ -char* aether_tls_windows_store_pem(void); -char* aether_tls_windows_bundle(void); +/* Why a pure-TLS handshake failed reaches the request as the callback's + * reason (runtime/aether_callbacks.h), and the Windows trust anchors both + * HTTPS clients use (#2675) are in aether_tls_trust.h: neither through this + * file, which std.cryptography.tls13_client would link for them (#3426). */ long long http_client_rx_buffer_allocs_raw(void); int http_response_status(HttpResponse* response); @@ -283,6 +274,15 @@ int http_request_set_insecure_raw(HttpClientRequest* req, int on); // verification ON. Strictly stronger than set_insecure. Per-connection (never // touches the shared SSL_CTX); NULL/empty clears the pin. `path` is copied. int http_request_set_cafile_raw(HttpClientRequest* req, const char* path); +// Trust partial chains for THIS request (#3311): any certificate in the trust +// store ends a path, not only a self-signed root (an intermediate CA pinned +// alone). Verification stays on; a trusted non-root must still be a CA. Both +// backends. 0 on success, -1 null request. +int http_request_set_partial_chain_raw(HttpClientRequest* req, int on); +/* "aether" / "openssl" / "" (default) for this request (#1298). 0 on success; + * -1 null request, -2 unknown name, -3 no OpenSSL in this build, -4 the + * pure-Aether client is not linked. */ +int http_request_set_tls_backend_raw(HttpClientRequest* req, const char* name); // Enable streaming response bodies for THIS request (#1004). When on (non-zero), // http_send_raw returns a response whose body is NOT buffered: it carries an diff --git a/std/net/aether_http_evloop.c b/std/net/aether_http_evloop.c index 8e5881bb5..50434157f 100644 --- a/std/net/aether_http_evloop.c +++ b/std/net/aether_http_evloop.c @@ -1956,9 +1956,12 @@ static void ev_take_submissions(EvDriver* d) { * without waiting, like everything else here: a connection mid * handshake costs a slot, not a thread. */ if (d->loop->server && d->loop->server->tls_enabled) { - SSL_CTX* ctx = (SSL_CTX*)d->loop->server->tls_ctx; + /* Taken with a reference, so a re-load of the server's TLS + * cannot free it before SSL_new holds its own (#3371). */ + SSL_CTX* ctx = (SSL_CTX*)http_server_tls_ctx_ref(d->loop->server); if (!ctx) { close(fd); free(c); continue; } c->ssl = SSL_new(ctx); + SSL_CTX_free(ctx); if (!c->ssl || SSL_set_fd(c->ssl, fd) != 1) { if (c->ssl) SSL_free(c->ssl); close(fd); diff --git a/std/net/aether_http_server.c b/std/net/aether_http_server.c index 468551f43..26b493fe4 100644 --- a/std/net/aether_http_server.c +++ b/std/net/aether_http_server.c @@ -1,4 +1,5 @@ #include "aether_http_server.h" +#include "../../runtime/utils/aether_fatal.h" /* aether_fatal_flush (#3435) */ #include "aether_http.h" #include "../http/proxy/aether_proxy.h" #include "aether_net.h" @@ -19,6 +20,7 @@ #endif #include "../../runtime/config/aether_optimization_config.h" #include "../../runtime/aether_resource_caps.h" +#include "../../runtime/utils/aether_wipe.h" #include "../../runtime/utils/aether_spawn.h" /* HTTP/2 (#260 Tier 2). Pull in the wrapper header up front so @@ -123,9 +125,17 @@ int http_server_start_raw(HttpServer* s) { (void)s; return -1; } int http_server_start_background_raw(HttpServer* s) { (void)s; return -1; } void http_server_stop(HttpServer* s) { (void)s; } void http_server_free(HttpServer* s) { (void)s; } +const char* http_server_set_tls_backend_raw(HttpServer* s, const char* n) { + (void)s; (void)n; + return "networking is not available in this build"; +} const char* http_server_set_tls_raw(HttpServer* s, const char* c, const char* k) { (void)s; (void)c; (void)k; return "TLS unavailable: networking not built in"; } +void* http_server_tls_ctx_ref(HttpServer* s) { (void)s; return NULL; } +const char* http_server_set_tls_pem_raw(HttpServer* s, const char* c, const char* k) { + (void)s; (void)c; (void)k; return "TLS unavailable: networking not built in"; +} const char* http_server_set_h2_raw(HttpServer* s, int m) { (void)s; (void)m; return "HTTP/2 unavailable: networking not built in"; } @@ -291,6 +301,7 @@ const char* http_request_header_value(HttpRequest* r, int i) { (void)r; (void)i; #include #include #include + #include /* FIONREAD: conn_fd_unread (#3405) */ #include /* Linux + macOS sendfile(2) live in different headers with * different signatures. Only one is pulled in per platform. */ @@ -340,9 +351,19 @@ const char* http_request_header_value(HttpRequest* r, int i) { (void)r; (void)i; #ifdef AETHER_HAS_OPENSSL #include #include +#include /* x509v3.h for X509_VERIFY_PARAM_set1_host / the hostflags used when the * WebSocket client pins a wss:// certificate to its host. */ #include +/* The hostname check: no partial-label wildcards ("f*.example.com"), and + * never the subject CN (#3313), only the SANs, as the pure-Aether backend + * reads them (RFC 9525), so both backends accept the same servers. Every + * OpenSSL from 1.1.0 has the flag. */ +#ifdef X509_CHECK_FLAG_NEVER_CHECK_SUBJECT +#define AETHER_HOSTFLAGS (X509_CHECK_FLAG_NO_PARTIAL_WILDCARDS | X509_CHECK_FLAG_NEVER_CHECK_SUBJECT) +#else +#define AETHER_HOSTFLAGS X509_CHECK_FLAG_NO_PARTIAL_WILDCARDS +#endif /* The HTTP client's shared SSL_CTX (aether_http.c). wss:// reuses it so the * trust store and TLS floor are decided in exactly one place. Do not free. */ @@ -356,8 +377,8 @@ SSL_CTX* aether_http_client_ssl_ctx(void); #define AETHER_WEAK #endif -AETHER_WEAK void* aether_pure_tls_server_accept(int fd, const char* cert_path, const char* key_path) { - (void)cert_path; (void)key_path; +AETHER_WEAK void* aether_pure_tls_server_accept_creds(int fd, void* creds) { + (void)creds; /* The bridge consumes fd even when no implementation is linked. */ close(fd); return NULL; @@ -370,33 +391,133 @@ AETHER_WEAK int aether_pure_tls_server_recv(void* pure_conn, void* buf, int len) (void)pure_conn; (void)buf; (void)len; return -1; } +/* What the pure server holds that a read returns without waiting on the + * socket (#3384): the bytes of a record a recv could not hand over whole, + * or 1 for a whole record already read. poll(2) sees neither. */ +AETHER_WEAK int aether_pure_tls_server_pending(void* pure_conn) { + (void)pure_conn; + return 0; +} +/* 1 when std.cryptography.tls13_server is linked (its @c_callback overrides + * this), so server_set_tls_backend("aether") can refuse up front instead of + * every handshake failing (#1298). */ +AETHER_WEAK int aether_pure_tls_server_available(void) { + return 0; +} AETHER_WEAK void aether_pure_tls_server_close(void* pure_conn) { (void)pure_conn; } +/* The pure server's parsed chain and key for this PEM text (#3362), or NULL + * with the reason set when they do not load or are not a pair (#3328): the + * callback's reason (aether_callback_set_reason, #3426), so the module + * calls nothing of this file. std.cryptography.tls13_server overrides + * both. */ +AETHER_WEAK void* aether_pure_tls_server_load(const char* cert_pem, const char* key_pem) { + (void)cert_pem; (void)key_pem; + return NULL; +} +AETHER_WEAK void aether_pure_tls_server_free_creds(void* creds) { + (void)creds; +} + /* #2297: the registered implementation (aether_callback_lookup), else the * symbol above; see the client side in aether_http.c. */ #include "../../runtime/aether_callbacks.h" -typedef void* (*pt_server_accept_fn)(int, const char*, const char*); +typedef void* (*pt_server_accept_fn)(int, void*); typedef int (*pt_server_send_fn)(void*, const void*, int); typedef int (*pt_server_recv_fn)(void*, void*, int); +typedef int (*pt_server_pending_fn)(void*); typedef void (*pt_server_close_fn)(void*); -static void* pt_server_accept(int fd, const char* cert_path, const char* key_path) { - pt_server_accept_fn f = (pt_server_accept_fn)aether_callback_lookup("aether_pure_tls_server_accept"); - return f ? f(fd, cert_path, key_path) : aether_pure_tls_server_accept(fd, cert_path, key_path); +typedef int (*pt_server_available_fn)(void); +static int pt_server_available(void) { + pt_server_available_fn f = (pt_server_available_fn)aether_callback_lookup("aether_pure_tls_server_available"); + return f ? f() : aether_pure_tls_server_available(); +} +static void* pt_server_accept(int fd, void* creds) { + pt_server_accept_fn f = (pt_server_accept_fn)aether_callback_lookup("aether_pure_tls_server_accept_creds"); + return f ? f(fd, creds) : aether_pure_tls_server_accept_creds(fd, creds); } static int pt_server_send(void* c, const void* buf, int len) { pt_server_send_fn f = (pt_server_send_fn)aether_callback_lookup("aether_pure_tls_server_send"); return f ? f(c, buf, len) : aether_pure_tls_server_send(c, buf, len); } +/* -2 from the pure server is its socket's receive timeout, with the + * connection kept (#3405): it is reported as SSL_read's is, -1 with the + * timeout's error code, which conn_recv_timed_out reads, so a wait for a + * next request ends and the connection is parked. */ static int pt_server_recv(void* c, void* buf, int len) { pt_server_recv_fn f = (pt_server_recv_fn)aether_callback_lookup("aether_pure_tls_server_recv"); - return f ? f(c, buf, len) : aether_pure_tls_server_recv(c, buf, len); + int n = f ? f(c, buf, len) : aether_pure_tls_server_recv(c, buf, len); + if (n == -2) { +#if defined(_WIN32) + WSASetLastError(WSAETIMEDOUT); +#else + errno = EAGAIN; +#endif + return -1; + } + return n; +} +static int pt_server_pending(void* c) { + pt_server_pending_fn f = (pt_server_pending_fn)aether_callback_lookup("aether_pure_tls_server_pending"); + return f ? f(c) : aether_pure_tls_server_pending(c); } static void pt_server_close(void* c) { pt_server_close_fn f = (pt_server_close_fn)aether_callback_lookup("aether_pure_tls_server_close"); if (f) f(c); else aether_pure_tls_server_close(c); } +typedef void* (*pt_server_load_fn)(const char*, const char*); +typedef void (*pt_server_free_creds_fn)(void*); +/* The pure server's creds for this chain and key, or NULL with *why (#3328, + * #3362): what the OpenSSL path learns from loading them and + * SSL_CTX_check_private_key, so both backends refuse the same setups when + * the server is configured. */ +static void* pt_server_load(const char* cert_pem, const char* key_pem, const char** why) { + if (!pt_server_available()) { + *why = "the pure-Aether TLS server is not linked: add " + "`import std.cryptography.tls13_server` to the program"; + return NULL; + } + pt_server_load_fn f = (pt_server_load_fn)aether_callback_lookup("aether_pure_tls_server_load"); + aether_callback_set_reason(NULL); + void* creds = f ? f(cert_pem, key_pem) : aether_pure_tls_server_load(cert_pem, key_pem); + if (!creds) { + const char* reason = aether_callback_reason(); + *why = reason[0] ? reason : "the pure-Aether TLS server cannot use this certificate and key"; + } + return creds; +} +static void pt_server_free_creds(void* creds) { + if (!creds) return; + pt_server_free_creds_fn f = (pt_server_free_creds_fn)aether_callback_lookup("aether_pure_tls_server_free_creds"); + if (f) f(creds); else aether_pure_tls_server_free_creds(creds); +} + +/* The pure server's creds, counted: the server holds one reference, and a + * connection being accepted another, so a re-load on a running server frees + * the creds it replaces after the last handshake that uses them (#3371). */ +typedef struct { + atomic_int refs; + void* creds; +} PtCreds; +static PtCreds* pt_creds_new(void* creds) { + PtCreds* p = (PtCreds*)malloc(sizeof(PtCreds)); + if (!p) return NULL; + atomic_init(&p->refs, 1); + p->creds = creds; + return p; +} +static PtCreds* pt_creds_ref(PtCreds* p) { + if (p) atomic_fetch_add_explicit(&p->refs, 1, memory_order_relaxed); + return p; +} +static void pt_creds_unref(PtCreds* p) { + if (p && atomic_fetch_sub_explicit(&p->refs, 1, memory_order_acq_rel) == 1) { + pt_server_free_creds(p->creds); + free(p); + } +} /* Per-connection read buffer. Persists across requests on a * keep-alive connection so that pipelined bytes (the start of @@ -509,6 +630,10 @@ typedef struct HttpConn { HttpServerResponse* pending_res; HttpMiddlewareNode* pending_from; int64_t pending_t_start; + /* When the connection last went idle into the lot, so a wake with no + * request in it (a TLS KeyUpdate) parks it again for what is left of + * its idle time, not a fresh one (#3405). */ + int64_t parked_since_ms; } HttpConn; /* The parking lot holds HttpConn by pointer and needs exactly two things from @@ -1195,6 +1320,19 @@ static void server_openssl_init_once(void) { OpenSSL_add_all_algorithms(); done = 1; } + +/* Security level 2 at least on the server context too (#3312), as + * std/net/aether_http.c does for the client: a certificate with an RSA key + * under 2048 bits (or an EC key under 224) is refused when it is loaded, + * on every distribution, as the pure-Aether backend refuses it. A system + * set higher stays higher. */ +static void server_tls_min_security_level(SSL_CTX* ctx) { +#if !defined(LIBRESSL_VERSION_NUMBER) || LIBRESSL_VERSION_NUMBER >= 0x3060000fL + if (SSL_CTX_get_security_level(ctx) < 2) SSL_CTX_set_security_level(ctx, 2); +#else + (void)ctx; +#endif +} #endif // Portable case-insensitive substring search (strcasestr is a GNU extension) @@ -1256,8 +1394,9 @@ HttpServer* http_server_create(int port) { server->tls_enabled = 0; server->tls_ctx = NULL; server->is_pure_tls = 0; - server->cert_path = NULL; - server->key_path = NULL; + server->tls_cert_pem = NULL; + server->tls_key_pem = NULL; + server->pure_creds = NULL; server->h2_enabled = 0; server->h2_max_concurrent_streams = 0; /* nghttp2 default 100 when 0 */ /* HTTP/1.1 is persistent by default (RFC 9112 section 9.3), and a proxy @@ -1276,6 +1415,8 @@ HttpServer* http_server_create(int port) { atomic_init(&server->inflight_connections, 0); atomic_init(&server->loop_plan_gen, 0); pthread_mutex_init(&server->live_lock, NULL); + pthread_mutex_init(&server->tls_lock, NULL); + pthread_mutex_init(&server->tls_config_lock, NULL); server->live_fds = NULL; server->live_count = 0; server->live_cap = 0; @@ -1982,31 +2123,60 @@ const char* http_server_set_metrics_raw(HttpServer* server, static void loop_plan_changed(HttpServer* server); -const char* http_server_set_tls_raw(HttpServer* server, - const char* cert_path, - const char* key_path) { - if (!server) return "server is null"; - if (!cert_path || !*cert_path) return "cert_path is empty"; - if (!key_path || !*key_path) return "key_path is empty"; - - if (server->cert_path) free(server->cert_path); - if (server->key_path) free(server->key_path); - server->cert_path = strdup(cert_path); - server->key_path = strdup(key_path); +/* Zeroes a copy of a certificate or private key, then frees it: the key text + * must not outlive its use (#3362). aether_wipe's writes are not dropped as + * dead before the free (#3376). */ +static void tls_text_wipe_free(char* p) { + if (!p) return; + aether_wipe(p, strlen(p)); + free(p); +} - const char* env_pure = getenv("AETHER_PURE_TLS"); - if (env_pure && (strcmp(env_pure, "1") == 0 || strcasecmp(env_pure, "true") == 0)) { - server->is_pure_tls = 1; - server->tls_enabled = 1; - loop_plan_changed(server); /* #3269 */ - return ""; +/* The whole of a certificate or key file, at most TLS_PEM_MAX bytes, as a + * NUL-terminated string the caller wipes and frees; NULL with why. */ +#define TLS_PEM_MAX (1 << 20) +static char* tls_read_pem_file(const char* path, const char* what, char* why, size_t why_len) { + FILE* f = fopen(path, "rb"); + if (!f) { + snprintf(why, why_len, "cannot read the TLS %s: %s", what, strerror(errno)); + return NULL; } + char* buf = (char*)malloc(TLS_PEM_MAX + 1); + size_t n = buf ? fread(buf, 1, TLS_PEM_MAX + 1, f) : 0; + int bad = !buf || ferror(f); + fclose(f); + if (bad || n > TLS_PEM_MAX) { + if (buf) { + volatile char* v = buf; + for (size_t i = 0; i < n && i <= TLS_PEM_MAX; i++) v[i] = 0; + free(buf); + } + snprintf(why, why_len, "cannot read the TLS %s: %s", what, + bad ? "read failed" : "larger than 1 MiB"); + return NULL; + } + buf[n] = '\0'; + return buf; +} #ifdef AETHER_HAS_OPENSSL - server_openssl_init_once(); +/* Never ask for a passphrase: OpenSSL's default reads one from the terminal, + * which would hang a server whose key is encrypted. It is refused instead. */ +static int tls_pem_no_passphrase(char* buf, int size, int rwflag, void* u) { + (void)buf; (void)size; (void)rwflag; (void)u; + return -1; +} +/* An SSL_CTX serving this chain and key, or NULL with why. The first + * certificate is the leaf; every one after it is the chain the server sends + * with it (#3370): SSL_CTX_use_certificate_file read the first alone, so the + * intermediates in a CA's bundle never reached the client. */ +static SSL_CTX* tls_openssl_ctx_from_pem(HttpServer* server, const char* cert_pem, + const char* key_pem, const char** why) { + server_openssl_init_once(); SSL_CTX* ctx = SSL_CTX_new(TLS_server_method()); - if (!ctx) return "SSL_CTX_new failed"; + if (!ctx) { *why = "SSL_CTX_new failed"; return NULL; } + server_tls_min_security_level(ctx); /* Match the client side: TLS 1.2+ only. Older versions are * known-broken (POODLE, etc.) and there's no compat reason to * keep them around for an in-process server. */ @@ -2016,17 +2186,67 @@ const char* http_server_set_tls_raw(HttpServer* server, SSL_CTX_set_options(ctx, SSL_OP_NO_COMPRESSION | SSL_OP_NO_RENEGOTIATION); - if (SSL_CTX_use_certificate_file(ctx, cert_path, SSL_FILETYPE_PEM) != 1) { + ERR_clear_error(); + BIO* cb = BIO_new_mem_buf(cert_pem, -1); + X509* leaf = cb ? PEM_read_bio_X509_AUX(cb, NULL, tls_pem_no_passphrase, NULL) : NULL; + if (!leaf || SSL_CTX_use_certificate(ctx, leaf) != 1) { + X509_free(leaf); + BIO_free(cb); + SSL_CTX_free(ctx); + *why = "failed to load TLS certificate (check its PEM; an RSA key needs 2048 bits or more)"; + return NULL; + } + X509_free(leaf); + X509* ca; + while ((ca = PEM_read_bio_X509(cb, NULL, tls_pem_no_passphrase, NULL)) != NULL) { + if (SSL_CTX_add0_chain_cert(ctx, ca) != 1) { + X509_free(ca); + BIO_free(cb); + SSL_CTX_free(ctx); + *why = "failed to add a certificate of the TLS chain"; + return NULL; + } + } + BIO_free(cb); + /* The read that ends the loop fails for want of another certificate; + * any other failure is a malformed one. */ + unsigned long e = ERR_peek_last_error(); + if (e && !(ERR_GET_LIB(e) == ERR_LIB_PEM && ERR_GET_REASON(e) == PEM_R_NO_START_LINE)) { + SSL_CTX_free(ctx); + *why = "a certificate of the TLS chain is malformed (PEM)"; + return NULL; + } + ERR_clear_error(); + + BIO* kb = BIO_new_mem_buf(key_pem, -1); + EVP_PKEY* pk = kb ? PEM_read_bio_PrivateKey(kb, NULL, tls_pem_no_passphrase, NULL) : NULL; + BIO_free(kb); + if (!pk) { SSL_CTX_free(ctx); - return "failed to load TLS certificate (check cert_path and PEM format)"; + *why = strstr(key_pem, "ENCRYPTED") + ? "failed to load TLS private key: it is encrypted; give it unencrypted" + : "failed to load TLS private key (check its PEM)"; + return NULL; } - if (SSL_CTX_use_PrivateKey_file(ctx, key_path, SSL_FILETYPE_PEM) != 1) { + /* SSL_CTX_use_PrivateKey itself refuses a key that is not the leaf's: + * said as the pure server says it, not as a key that failed to load. */ + ERR_clear_error(); + if (SSL_CTX_use_PrivateKey(ctx, pk) != 1) { + unsigned long ke = ERR_peek_last_error(); + int mismatch = ERR_GET_LIB(ke) == ERR_LIB_X509 && + (ERR_GET_REASON(ke) == X509_R_KEY_VALUES_MISMATCH || + ERR_GET_REASON(ke) == X509_R_KEY_TYPE_MISMATCH); + EVP_PKEY_free(pk); SSL_CTX_free(ctx); - return "failed to load TLS private key (check key_path and PEM format)"; + *why = mismatch ? "TLS cert and private key do not match" + : "failed to load TLS private key (check its PEM)"; + return NULL; } + EVP_PKEY_free(pk); if (SSL_CTX_check_private_key(ctx) != 1) { SSL_CTX_free(ctx); - return "TLS cert and private key do not match"; + *why = "TLS cert and private key do not match"; + return NULL; } /* ALPN selection callback (#260 Tier 2). The callback inspects @@ -2035,21 +2255,182 @@ const char* http_server_set_tls_raw(HttpServer* server, * advertised list — the SSL_CTX itself doesn't have to be * rebuilt. */ SSL_CTX_set_alpn_select_cb(ctx, aether_alpn_select_cb, server); + return ctx; +} +#endif - /* Replace any prior context (idempotent re-load). */ - if (server->tls_ctx) { - SSL_CTX_free((SSL_CTX*)server->tls_ctx); +/* Serve TLS with this chain and key (PEM text the caller keeps), on the + * backend the server's choice names: the program's (server_set_tls_backend, + * #1298), else AETHER_PURE_TLS, else OpenSSL where it is built in. Both + * backends refuse a chain or key they cannot serve now, not at every + * handshake, and a refusal leaves the earlier setup as it was (#3328). On + * success the server keeps its own copies of the text, for a later backend + * change, and wipes them when they are replaced or the server is freed. */ +static const char* tls_apply_pem(HttpServer* server, const char* cert_pem, const char* key_pem) { + int pure = 0; + if (server->tls_backend == 1) { + pure = 1; + } else if (server->tls_backend == 0) { + const char* env_pure = getenv("AETHER_PURE_TLS"); + pure = env_pure && (strcmp(env_pure, "1") == 0 || strcasecmp(env_pure, "true") == 0); } +#ifndef AETHER_HAS_OPENSSL + pure = 1; +#endif + + const char* why = NULL; + PtCreds* creds = NULL; +#ifdef AETHER_HAS_OPENSSL + SSL_CTX* ctx = NULL; +#endif + if (pure) { + void* loaded = pt_server_load(cert_pem, key_pem, &why); + if (!loaded) return why; + creds = pt_creds_new(loaded); + if (!creds) { + pt_server_free_creds(loaded); + return "out of memory"; + } + } else { +#ifdef AETHER_HAS_OPENSSL + ctx = tls_openssl_ctx_from_pem(server, cert_pem, key_pem, &why); + if (!ctx) return why; +#endif + } + + /* The text is copied before anything is replaced, so running out of + * memory leaves the earlier setup too. */ + char* cert_copy = strdup(cert_pem); + char* key_copy = strdup(key_pem); + if (!cert_copy || !key_copy) { + tls_text_wipe_free(cert_copy); + tls_text_wipe_free(key_copy); + pt_creds_unref(creds); +#ifdef AETHER_HAS_OPENSSL + if (ctx) SSL_CTX_free(ctx); +#endif + return "out of memory"; + } + /* cert_pem and key_pem may be these very buffers (a backend change + * applies the server's own copies again): copied above, and not read + * after this. */ + tls_text_wipe_free(server->tls_cert_pem); + tls_text_wipe_free(server->tls_key_pem); + server->tls_cert_pem = cert_copy; + server->tls_key_pem = key_copy; + + /* Swapped under the lock accepts take their references under; what is + * replaced is released after, and freed by its last holder (#3371). The + * pure stack takes over from an OpenSSL context when it is chosen, and + * the other way round. */ + pthread_mutex_lock(&server->tls_lock); + PtCreds* old_creds = (PtCreds*)server->pure_creds; + server->pure_creds = creds; +#ifdef AETHER_HAS_OPENSSL + SSL_CTX* old_ctx = (SSL_CTX*)server->tls_ctx; server->tls_ctx = ctx; +#endif + server->is_pure_tls = pure; server->tls_enabled = 1; + pthread_mutex_unlock(&server->tls_lock); + pt_creds_unref(old_creds); +#ifdef AETHER_HAS_OPENSSL + if (old_ctx) SSL_CTX_free(old_ctx); +#endif loop_plan_changed(server); /* #3269 */ return ""; +} + +void* http_server_tls_ctx_ref(HttpServer* server) { +#ifdef AETHER_HAS_OPENSSL + if (!server) return NULL; + pthread_mutex_lock(&server->tls_lock); + SSL_CTX* ctx = (SSL_CTX*)server->tls_ctx; + if (ctx && SSL_CTX_up_ref(ctx) != 1) ctx = NULL; + pthread_mutex_unlock(&server->tls_lock); + return ctx; #else - server->is_pure_tls = 1; - server->tls_enabled = 1; - loop_plan_changed(server); - return ""; + (void)server; + return NULL; +#endif +} + +const char* http_server_set_tls_raw(HttpServer* server, + const char* cert_path, + const char* key_path) { + if (!server) return "server is null"; + if (!cert_path || !*cert_path) return "cert_path is empty"; + if (!key_path || !*key_path) return "key_path is empty"; + + /* Read here, for both backends, so they say the same about a file they + * cannot read; the pure server once read both at every connection. */ + static _Thread_local char why[600]; + char* cert = tls_read_pem_file(cert_path, "certificate", why, sizeof why); + if (!cert) return why; + char* key = tls_read_pem_file(key_path, "private key", why, sizeof why); + if (!key) { + tls_text_wipe_free(cert); + return why; + } + pthread_mutex_lock(&server->tls_config_lock); + const char* err = tls_apply_pem(server, cert, key); + pthread_mutex_unlock(&server->tls_config_lock); + tls_text_wipe_free(cert); + tls_text_wipe_free(key); + return err; +} + +const char* http_server_set_tls_pem_raw(HttpServer* server, + const char* cert_pem, + const char* key_pem) { + if (!server) return "server is null"; + if (!cert_pem || !*cert_pem) return "cert_pem is empty"; + if (!key_pem || !*key_pem) return "key_pem is empty"; + pthread_mutex_lock(&server->tls_config_lock); + const char* err = tls_apply_pem(server, cert_pem, key_pem); + pthread_mutex_unlock(&server->tls_config_lock); + return err; +} + +/* Choose this server's TLS backend (#1298): "aether" (the pure-Aether TLS + * 1.3 server, std.cryptography.tls13_server), "openssl", or "" for the + * default (AETHER_PURE_TLS, then OpenSSL where it is built in). Works before + * or after http_server_set_tls (it re-applies the certificate and key); + * call it before the server starts. Returns "" or why the backend cannot be + * used. */ +const char* http_server_set_tls_backend_raw(HttpServer* server, const char* name) { + if (!server) return "server is null"; + int b = 0; + if (!name || !*name) { + b = 0; + } else if (strcmp(name, "aether") == 0) { + if (!pt_server_available()) { + return "the pure-Aether TLS server is not linked: add " + "`import std.cryptography.tls13_server` to the program"; + } + b = 1; + } else if (strcmp(name, "openssl") == 0) { +#ifdef AETHER_HAS_OPENSSL + b = 2; +#else + return "this build has no OpenSSL, so the \"openssl\" TLS backend is not available"; #endif + } else { + return "unknown TLS backend (use \"aether\", \"openssl\" or \"\")"; + } + pthread_mutex_lock(&server->tls_config_lock); + int previous = server->tls_backend; + server->tls_backend = b; + const char* err = ""; + if (server->tls_enabled && server->tls_cert_pem && server->tls_key_pem) { + /* The server's own copies, applied again on the new backend; a + * backend that cannot serve them is not chosen, and the server keeps + * the one it had (#3328). */ + err = tls_apply_pem(server, server->tls_cert_pem, server->tls_key_pem); + if (err && *err) server->tls_backend = previous; + } + pthread_mutex_unlock(&server->tls_config_lock); + return err; } /* Enable HTTP/2 on this server (#260 Tier 2). Once enabled: @@ -4019,11 +4400,9 @@ static int ws_ready_now(HttpConn* conn, int timeout_ms) { #ifdef AETHER_HAS_OPENSSL if (conn->ssl && SSL_pending((SSL*)conn->ssl) > 0) return 1; #endif - /* The pure-Aether TLS backend buffers per record too, but exposes no - * pending count, so we cannot answer "ready" for it without consuming. - * Report not-ready and let the caller's timeout drive; a bounded recv - * still makes progress because the socket wakes when the next record - * lands. */ + /* The pure-Aether TLS backend buffers too: what it has decrypted and + * not handed over, and whole records it has read (#3384). */ + if (conn->pure_tls && pt_server_pending(conn->pure_tls) > 0) return 1; if (conn->fd < 0) return -1; @@ -4490,7 +4869,7 @@ HttpWsConn* http_ws_connect(const char* url) { * set1_ip_asc: older OpenSSL's set1_host does not detect IPs, and * silently pinning nothing is exactly the failure this prevents. */ X509_VERIFY_PARAM* vpm = SSL_get0_param(s); - X509_VERIFY_PARAM_set_hostflags(vpm, X509_CHECK_FLAG_NO_PARTIAL_WILDCARDS); + X509_VERIFY_PARAM_set_hostflags(vpm, AETHER_HOSTFLAGS); struct in_addr in4; struct in6_addr in6; if (inet_pton(AF_INET, host, &in4) == 1 || @@ -6399,6 +6778,71 @@ static void conn_apply_send_timeout(HttpServer* server, HttpConn* c) { #endif } +/* Bytes received on fd and not yet read, or -1. */ +static long conn_fd_unread(int fd) { +#if defined(_WIN32) + u_long n = 0; + if (ioctlsocket((SOCKET)fd, FIONREAD, &n) != 0) return -1; + return (long)n; +#else + int n = 0; + if (ioctl(fd, FIONREAD, &n) != 0) return -1; + return (long)n; +#endif +} + +/* A connection its socket says is readable: will a read find a request? + * + * On a plain connection readable is bytes of one, or the peer's end. On a + * TLS connection it can be records with no application data, a KeyUpdate + * above all, which a read handles and then waits behind for the whole idle + * timeout, holding a worker for nothing (#3405). So TLS asks the library + * without waiting: + * - the pure server handles the whole records the socket holds (its + * pending check takes only what FIONREAD counts) and reports data, or an + * end or an error; + * - OpenSSL peeks with the socket non-blocking for that one call. + * 1 when a read now has data, or an end or an error to report; 0 when the + * connection should wait in the lot instead. Readable with nothing to read + * is the peer's end, which a read must see. */ +static int conn_readable_has_request(HttpConn* conn) { + if (conn->pure_tls) { + /* Counted before the pending check, which takes what is there: bytes + * that held no data (a KeyUpdate) leave the socket empty after it, + * and only a socket empty before it is the peer's end. */ + long before = conn_fd_unread(conn->fd); + if (pt_server_pending(conn->pure_tls) != 0) return 1; + return before <= 0; + } +#ifdef AETHER_HAS_OPENSSL + if (conn->ssl) { + SSL* ssl = (SSL*)conn->ssl; + if (SSL_pending(ssl) > 0) return 1; + char b; +#if defined(_WIN32) + u_long on = 1, off = 0; + ioctlsocket((SOCKET)conn->fd, FIONBIO, &on); +#else + conn_set_nonblocking(conn, 1); +#endif + int n = SSL_peek(ssl, &b, 1); + int err = n > 0 ? SSL_ERROR_NONE : SSL_get_error(ssl, n); +#if defined(_WIN32) + ioctlsocket((SOCKET)conn->fd, FIONBIO, &off); +#else + conn_set_nonblocking(conn, 0); +#endif + if (n > 0) return 1; + if (err == SSL_ERROR_WANT_READ) { + ERR_clear_error(); + return 0; + } + return 1; + } +#endif + return 1; +} + /* Is the next request already here, or about to be? * * Bytes in the connection's own buffer (a pipelined request, or an over-read @@ -6416,6 +6860,9 @@ static int conn_next_request_imminent(HttpConn* conn) { #ifdef AETHER_HAS_OPENSSL if (conn->ssl && SSL_pending((SSL*)conn->ssl) > 0) return 1; #endif + /* The pure server's own buffered plaintext and records, as SSL_pending + * (#3384). */ + if (conn->pure_tls && pt_server_pending(conn->pure_tls) > 0) return 1; #if !defined(_WIN32) /* Take the next request rather than asking whether one is there. @@ -6432,8 +6879,9 @@ static int conn_next_request_imminent(HttpConn* conn) { * MSG_DONTWAIT rather than switching the socket to non-blocking: the flag * is free, where fcntl would cost two more syscalls than the poll it * replaces. TLS keeps the poll — SSL_read against a socket the library - * believes is blocking is a different contract. */ - if (!conn->ssl && conn_buf_ensure(conn, HTTP_CONN_BUF_CAP) == 0) { + * believes is blocking is a different contract, and a pure-TLS socket + * carries records, which a plain recv would take as plaintext. */ + if (!conn->ssl && !conn->pure_tls && conn_buf_ensure(conn, HTTP_CONN_BUF_CAP) == 0) { int space = conn->buf_cap - conn->write_pos - 1; if (space > 0) { ssize_t n = recv(conn->fd, conn->buf + conn->write_pos, @@ -6451,13 +6899,16 @@ static int conn_next_request_imminent(HttpConn* conn) { int grace_ms = http_pool_has_spare_worker() ? HTTP_PARK_GRACE_MS : 0; #if !defined(_WIN32) struct pollfd pfd = { .fd = conn->fd, .events = POLLIN, .revents = 0 }; - return poll(&pfd, 1, grace_ms) > 0; + /* Readable is a request only if a read would find one (#3405). */ + if (poll(&pfd, 1, grace_ms) <= 0) return 0; + return conn_readable_has_request(conn); #else fd_set rfds; FD_ZERO(&rfds); FD_SET(conn->fd, &rfds); struct timeval tv = { .tv_sec = 0, .tv_usec = grace_ms * 1000 }; - return select(conn->fd + 1, &rfds, NULL, NULL, &tv) > 0; + if (select(conn->fd + 1, &rfds, NULL, NULL, &tv) <= 0) return 0; + return conn_readable_has_request(conn); #endif } @@ -6507,6 +6958,7 @@ static void conn_serve(HttpServer* server, HttpConn* conn) { if (idle_since_ms == 0) idle_since_ms = conn_now_ms(); int64_t waited = conn_now_ms() - idle_since_ms; if (waited >= idle_ms) break; /* silent past the deadline: close */ + conn->parked_since_ms = idle_since_ms; if (http_park_add(lot, conn, (int)(idle_ms - waited)) == 0) { atomic_fetch_sub(&server->inflight_connections, 1); return; @@ -6542,6 +6994,7 @@ static void conn_serve(HttpServer* server, HttpConn* conn) { if (lot && !atomic_load(&server->stopping) && !conn_next_request_imminent(conn)) { int idle_ms = server->keep_alive_idle_ms > 0 ? server->keep_alive_idle_ms : 30000; + conn->parked_since_ms = conn_now_ms(); if (http_park_add(lot, conn, idle_ms) == 0) { atomic_fetch_sub(&server->inflight_connections, 1); return; @@ -6558,6 +7011,22 @@ static void conn_serve(HttpServer* server, HttpConn* conn) { * worker. Runs on the poller thread, so it must not serve the request here. */ void http_server_resume_connection(HttpServer* server, HttpConn* conn) { if (!server || !conn) return; + /* Woken by bytes with no request in them (a TLS KeyUpdate): back to the + * lot for what is left of its idle time, not on to a read that would + * wait the whole of it on this worker (#3405); past that time, closed, + * as a silent connection is. */ + HttpParkLot* lot = (HttpParkLot*)server->park_lot; + if (lot && (conn->pure_tls || conn->ssl) && !atomic_load(&server->stopping) && + !conn_readable_has_request(conn)) { + int idle_ms = server->keep_alive_idle_ms > 0 ? server->keep_alive_idle_ms : 30000; + int64_t left = (int64_t)idle_ms - (conn_now_ms() - conn->parked_since_ms); + if (left <= 0) { + conn_close(conn); + free(conn); + return; + } + if (http_park_add(lot, conn, (int)left) == 0) return; + } conn_serve(server, conn); } @@ -6710,8 +7179,21 @@ void http_server_drain_connection(HttpServer* server, int client_fd) { conn->applied_recv_timeout_ms = -1; if (server->tls_enabled) { - if (server->is_pure_tls) { - void* pure_conn = pt_server_accept(conn->fd, server->cert_path, server->key_path); + /* What this connection is accepted with, a reference taken under + * the lock a re-load swaps it under, and given back once the + * handshake is done (#3371). */ + pthread_mutex_lock(&server->tls_lock); + int pure = server->is_pure_tls; + PtCreds* creds = pure ? pt_creds_ref((PtCreds*)server->pure_creds) : NULL; +#ifdef AETHER_HAS_OPENSSL + SSL_CTX* ctx = NULL; + if (!pure && server->tls_ctx && SSL_CTX_up_ref((SSL_CTX*)server->tls_ctx) == 1) + ctx = (SSL_CTX*)server->tls_ctx; +#endif + pthread_mutex_unlock(&server->tls_lock); + if (pure) { + void* pure_conn = pt_server_accept(conn->fd, creds ? creds->creds : NULL); + pt_creds_unref(creds); if (!pure_conn) { /* pt_server_accept consumes the fd on failure too. */ free(conn); @@ -6720,8 +7202,11 @@ void http_server_drain_connection(HttpServer* server, int client_fd) { conn->pure_tls = pure_conn; } #ifdef AETHER_HAS_OPENSSL - else if (server->tls_ctx) { - if (conn_tls_accept(conn, (SSL_CTX*)server->tls_ctx) != 0) { + else if (ctx) { + /* SSL_new holds a reference of its own from here. */ + int rc = conn_tls_accept(conn, ctx); + SSL_CTX_free(ctx); + if (rc != 0) { close(client_fd); free(conn); return; @@ -7118,6 +7603,7 @@ static int http_server_serve(HttpServer* server) { // The join loop below assumes exactly n_threads accept threads; // continuing with a NULL ctx would write through NULL here and // leave a never-created thread to join. Fail loudly instead. + aether_fatal_flush(); /* the program's lines first (#3435, aether_fatal.h) */ fprintf(stderr, "aether: failed to allocate accept-thread context\n"); /* silent-ok: last line before abort */ abort(); } @@ -7410,8 +7896,10 @@ void http_server_free(HttpServer* server) { http_server_monitor_null_raw(server); /* gives back the closures' environments */ - if (server->cert_path) free(server->cert_path); - if (server->key_path) free(server->key_path); + tls_text_wipe_free(server->tls_cert_pem); + tls_text_wipe_free(server->tls_key_pem); + pt_creds_unref((PtCreds*)server->pure_creds); + server->pure_creds = NULL; free(server->host); // Free routes @@ -7488,6 +7976,8 @@ void http_server_free(HttpServer* server) { free(server->live_fds); pthread_mutex_destroy(&server->live_lock); + pthread_mutex_destroy(&server->tls_lock); + pthread_mutex_destroy(&server->tls_config_lock); #if !defined(_WIN32) && !defined(__wasi__) if (server->wake_fds[0] >= 0) close(server->wake_fds[0]); if (server->wake_fds[1] >= 0) close(server->wake_fds[1]); diff --git a/std/net/aether_http_server.h b/std/net/aether_http_server.h index f55a57b89..2b202bae5 100644 --- a/std/net/aether_http_server.h +++ b/std/net/aether_http_server.h @@ -203,8 +203,24 @@ typedef struct HttpServer { int tls_enabled; void* tls_ctx; int is_pure_tls; - char* cert_path; - char* key_path; + int tls_backend; /* 0 default (AETHER_PURE_TLS, then the build), + * 1 "aether", 2 "openssl": server_set_tls_backend (#1298) */ + /* The certificate chain and private key as PEM text (#3362), kept so a + * later server_set_tls_backend can apply them again, and wiped when they + * are replaced or the server is freed; and the pure server's parsed form + * of them, which every pure connection shares. */ + char* tls_cert_pem; + char* tls_key_pem; + void* pure_creds; + /* Guards tls_ctx, pure_creds and is_pure_tls: a connection takes a + * reference to what it is accepted with under it, so a server_set_tls + * on a running server frees what it replaces only once no accept still + * holds it (#3371). */ + pthread_mutex_t tls_lock; + /* Serializes the configuration calls (server_set_tls, server_set_tls_pem, + * server_set_tls_backend): each replaces tls_cert_pem and tls_key_pem, + * and two at once from two threads would free the old copies twice. */ + pthread_mutex_t tls_config_lock; // HTTP/2 (#260 Tier 2). When h2_enabled == 1: // - The TLS context advertises "h2" + "http/1.1" via ALPN so @@ -452,12 +468,32 @@ const char* http_server_set_keepalive_raw(HttpServer* server, // handshake error. Idempotent — calling twice with the same files is // a no-op success; calling with different files re-loads. // -// When the build does not include OpenSSL (AETHER_HAS_OPENSSL undef), -// returns "TLS unavailable: built without OpenSSL". +// When the build does not include OpenSSL (AETHER_HAS_OPENSSL undef), the +// pure-Aether server terminates TLS (import std.cryptography.tls13_server). const char* http_server_set_tls_raw(HttpServer* server, const char* cert_path, const char* key_path); +// http_server_set_tls with the certificate chain and private key given as PEM +// text instead of file paths (#3362), so a program that holds them in memory +// need not write the key to disk. Same checks, same results, both backends; +// the first certificate is the leaf and the rest its chain. The server keeps +// its own copy of the text and wipes it when it is replaced or the server is +// freed. +const char* http_server_set_tls_pem_raw(HttpServer* server, + const char* cert_pem, + const char* key_pem); + +// The server's OpenSSL context (an SSL_CTX*) with a reference taken under +// its TLS lock, for an accept path to hand SSL_new and then SSL_CTX_free; +// NULL when the server has none (#3371). +void* http_server_tls_ctx_ref(HttpServer* server); + +// Choose the TLS backend (#1298): "aether", "openssl" or "" (the default: +// AETHER_PURE_TLS, then OpenSSL where it is built in). Returns "" or why the +// backend cannot be used (not built in, or the pure server not linked). +const char* http_server_set_tls_backend_raw(HttpServer* server, const char* name); + // Enable HTTP/2 (#260 Tier 2). When enabled: // - the TLS context advertises "h2" via ALPN (with "http/1.1" as // fallback) so clients pick HTTP/2 when both peers can speak it; diff --git a/std/net/aether_net.c b/std/net/aether_net.c index 34433e547..758fb593f 100644 --- a/std/net/aether_net.c +++ b/std/net/aether_net.c @@ -6,6 +6,7 @@ #if !AETHER_HAS_NETWORKING TcpSocket* tcp_connect_raw(const char* h, int p) { (void)h; (void)p; return NULL; } +TcpSocket* tcp_connect_buffered_raw(const char* h, int p, int b) { (void)h; (void)p; (void)b; return NULL; } int tcp_send_raw(TcpSocket* s, const char* d) { (void)s; (void)d; return -1; } int tcp_send_n_raw(TcpSocket* s, const char* d, int n) { (void)s; (void)d; (void)n; return -1; } char* tcp_receive_raw(TcpSocket* s, int m) { (void)s; (void)m; return NULL; } @@ -24,6 +25,7 @@ int tcp_server_port_raw(TcpServer* s) { (void)s; return -1; } int tcp_server_poll_raw(TcpServer* s, int t) { (void)s; (void)t; return -1; } int tcp_set_nodelay_raw(TcpSocket* s, int on) { (void)s; (void)on; return -1; } int tcp_set_read_buffer_raw(TcpSocket* s, int b) { (void)s; (void)b; return -1; } +int tcp_available_raw(TcpSocket* s) { (void)s; return -1; } TcpSocket* tcp_accept_raw(TcpServer* s) { (void)s; return NULL; } int tcp_server_close(TcpServer* s) { (void)s; return 0; } int tcp_fd_raw(TcpSocket* s) { (void)s; return -1; } @@ -56,6 +58,7 @@ int aether_net_wouldblock(void) { return 0; } #include #include #include /* TCP_NODELAY (#2136) */ + #include /* FIONREAD (tcp_available_raw) */ #include #include #endif @@ -190,13 +193,25 @@ int aether_net_order(const char* host, int port, AetherAddrList* addrs) { } int aether_net_connect_addr(const struct sockaddr_storage* addr, socklen_t len, - long long timeout_ns, const char** why, int* timed_out) { + long long timeout_ns, int read_buffer, + const char** why, int* timed_out) { *timed_out = 0; int sockfd = (int)socket(addr->ss_family, SOCK_STREAM, 0); if (sockfd < 0) { *why = "could not create socket"; return -1; } + /* Before connect, so the SYN advertises a window this size (#3338). Set + * on a connected socket, the buffer shrinks, but the kernel still takes + * the window it already advertised: on Linux a reader that stopped for + * three seconds then drained what it had taken for sixteen more. */ + if (read_buffer > 0 && + setsockopt(sockfd, SOL_SOCKET, SO_RCVBUF, (const char*)&read_buffer, + sizeof(read_buffer)) != 0) { + close(sockfd); + *why = "could not set the receive buffer"; + return -1; + } if (timeout_ns <= 0) { if (connect(sockfd, (const struct sockaddr*)addr, len) < 0) { close(sockfd); @@ -289,7 +304,7 @@ int aether_net_connect_addr(const struct sockaddr_storage* addr, socklen_t len, } int aether_net_dial_addrs(const char* host, int port, AetherAddrList* addrs, - long long timeout_ns, const char** why) { + long long timeout_ns, int read_buffer, const char** why) { *why = "could not resolve host"; if (!addrs || addrs->n <= 0) return -1; /* A family that has connected to this host and port before is a proven @@ -320,7 +335,8 @@ int aether_net_dial_addrs(const char* host, int port, AetherAddrList* addrs, if (!last && addrs->ss[i].ss_family != proven) t = (left > 0 && left < first_try) ? left : first_try; int timed_out = 0; - int fd = aether_net_connect_addr(&addrs->ss[i], addrs->len[i], t, why, &timed_out); + int fd = aether_net_connect_addr(&addrs->ss[i], addrs->len[i], t, read_buffer, + why, &timed_out); if (fd >= 0) { if (addrs->n > 1) aether_net_note_family(host, port, addrs->ss[i].ss_family); return fd; @@ -338,7 +354,7 @@ int aether_net_dial(const char* host, int port, long long timeout_ns, const char *why = "could not resolve host"; return -1; } - return aether_net_dial_addrs(host, port, &addrs, timeout_ns, why); + return aether_net_dial_addrs(host, port, &addrs, timeout_ns, 0, why); } // Set read/write timeouts on a socket so blocking operations don't hang @@ -357,7 +373,31 @@ static void net_set_socket_timeouts(int fd, int timeout_sec) { #endif } -TcpSocket* tcp_connect_raw(const char* host, int port) { +/* A write to a connection the peer has closed raises SIGPIPE, which ends a + * process that has not ignored it, with no word said (#3457): the pure TLS + * stack's peer died writing to a client that had stopped reading, and its + * client writing after the peer closed, on macOS, whose socket buffers are + * small enough for a write to come after the close. The write fails with + * EPIPE instead, as the HTTP server's do: Linux names it on each send + * (MSG_NOSIGNAL), macOS and the BSDs on the socket (SO_NOSIGPIPE), and + * Windows raises no signal. */ +#if defined(MSG_NOSIGNAL) +#define AE_NET_NOSIGNAL MSG_NOSIGNAL +#else +#define AE_NET_NOSIGNAL 0 +#endif +static void net_no_sigpipe(int fd) { +#ifdef SO_NOSIGPIPE + int on = 1; + setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &on, sizeof(on)); +#else + (void)fd; +#endif +} + +/* tcp_connect_raw, and with `read_buffer` > 0 the receive buffer set before + * the connection is made (tcp_connect_buffered_raw). */ +static TcpSocket* tcp_connect_with(const char* host, int port, int read_buffer) { // Sandbox check: is TCP connect to this host and port allowed? char tcp_res[300]; if (!aether_sandbox_check("tcp", aether_net_resource(tcp_res, sizeof tcp_res, host, port))) return NULL; @@ -370,10 +410,13 @@ TcpSocket* tcp_connect_raw(const char* host, int port) { * thread-safe and returns caller-owned memory. Each address the host has, * IPv6 or IPv4, is tried as aether_net_dial_addrs describes. */ const char* why = NULL; - int sockfd = aether_net_dial(host, port, 0, &why); + AetherAddrList addrs; + if (aether_addr_resolve(host, port, &addrs) != 0) return NULL; + int sockfd = aether_net_dial_addrs(host, port, &addrs, 0, read_buffer, &why); if (sockfd < 0) return NULL; net_set_socket_timeouts(sockfd, 30); + net_no_sigpipe(sockfd); TcpSocket* sock = (TcpSocket*)malloc(sizeof(TcpSocket)); if (!sock) { close(sockfd); return NULL; } @@ -382,10 +425,19 @@ TcpSocket* tcp_connect_raw(const char* host, int port) { return sock; } +TcpSocket* tcp_connect_raw(const char* host, int port) { + return tcp_connect_with(host, port, 0); +} + +TcpSocket* tcp_connect_buffered_raw(const char* host, int port, int read_buffer) { + if (read_buffer <= 0) return NULL; + return tcp_connect_with(host, port, read_buffer); +} + int tcp_send_raw(TcpSocket* sock, const char* data) { if (!sock || !sock->connected || !data) return -1; - int sent = send(sock->fd, data, strlen(data), 0); + int sent = send(sock->fd, data, strlen(data), AE_NET_NOSIGNAL); return sent; } @@ -393,7 +445,7 @@ int tcp_send_n_raw(TcpSocket* sock, const char* data, int length) { if (!sock || !sock->connected || !data || length < 0) return -1; if (length == 0) return 0; - int sent = send(sock->fd, data, (size_t)length, 0); + int sent = send(sock->fd, data, (size_t)length, AE_NET_NOSIGNAL); return sent; } @@ -479,7 +531,7 @@ int tcp_send_bytes_raw(TcpSocket* sock, const void* data, int length) { if (!sock || !sock->connected || length < 0) return -1; if (length == 0) return 0; if (!data) return -1; - int sent = send(sock->fd, (const char*)data, (size_t)length, 0); + int sent = send(sock->fd, (const char*)data, (size_t)length, AE_NET_NOSIGNAL); return sent < 0 ? -1 : sent; } @@ -554,14 +606,14 @@ int tcp_send_all_raw(TcpSocket* sock, const void* data, int length) { #endif while (sent < length) { #ifdef _WIN32 - int n = send(sock->fd, p + sent, (size_t)(length - sent), 0); + int n = send(sock->fd, p + sent, (size_t)(length - sent), AE_NET_NOSIGNAL); if (n > 0) { sent += n; continue; } if (n < 0 && WSAGetLastError() == WSAEINTR) continue; #else - int n = (int)send(sock->fd, p + sent, (size_t)(length - sent), MSG_DONTWAIT); + int n = (int)send(sock->fd, p + sent, (size_t)(length - sent), MSG_DONTWAIT | AE_NET_NOSIGNAL); if (n > 0) { sent += n; wait.sent = 1; @@ -695,9 +747,11 @@ int tcp_set_nodelay_raw(TcpSocket* sock, int on) { } /* SO_RCVBUF: the socket's receive buffer, `bytes` as the kernel takes it - * (Linux doubles it for its bookkeeping). Setting it also stops the kernel - * growing the buffer by itself, as Linux, macOS and Windows otherwise do - * while data arrives, read or not. */ + * (Linux doubles it for its bookkeeping). Setting it asks the kernel not to + * grow the buffer by itself, as Linux, macOS and Windows otherwise do while + * data arrives, read or not. On a connected socket the window it advertised + * at connect still stands (tcp_connect_buffered_raw sets the size first, + * #3338). The size is a request a kernel may round up. */ int tcp_set_read_buffer_raw(TcpSocket* sock, int bytes) { if (!sock || !sock->connected || bytes <= 0) return -1; if (setsockopt(sock->fd, SOL_SOCKET, SO_RCVBUF, (const char*)&bytes, sizeof(bytes)) != 0) { @@ -706,6 +760,21 @@ int tcp_set_read_buffer_raw(TcpSocket* sock, int bytes) { return 0; } +/* How many received bytes are waiting to be read on `sock` (FIONREAD), or + * -1 on error. */ +int tcp_available_raw(TcpSocket* sock) { + if (!sock || !sock->connected) return -1; +#ifdef _WIN32 + u_long n = 0; + if (ioctlsocket(sock->fd, FIONREAD, &n) != 0) return -1; + return n > (u_long)INT_MAX ? INT_MAX : (int)n; +#else + int n = 0; + if (ioctl(sock->fd, FIONREAD, &n) != 0) return -1; + return n; +#endif +} + TcpSocket* tcp_accept_raw(TcpServer* server) { if (!server) return NULL; @@ -718,6 +787,7 @@ TcpSocket* tcp_accept_raw(TcpServer* server) { } net_set_socket_timeouts(newsockfd, 30); + net_no_sigpipe(newsockfd); TcpSocket* sock = (TcpSocket*)malloc(sizeof(TcpSocket)); if (!sock) { close(newsockfd); return NULL; } @@ -758,6 +828,7 @@ TcpSocket* tcp_socket_from_fd_owned(int fd) { if (flags >= 0) fcntl(fd, F_SETFL, flags & ~O_NONBLOCK); #endif net_set_socket_timeouts(fd, 30); + net_no_sigpipe(fd); TcpSocket* sock = (TcpSocket*)malloc(sizeof(TcpSocket)); if (!sock) { diff --git a/std/net/aether_net.h b/std/net/aether_net.h index ec4938dac..115219a52 100644 --- a/std/net/aether_net.h +++ b/std/net/aether_net.h @@ -13,6 +13,9 @@ typedef struct TcpReceiveResult { // TCP Client TcpSocket* tcp_connect_raw(const char* host, int port); +// tcp_connect_raw with the receive buffer (SO_RCVBUF) set to `read_buffer` +// bytes before the connection is made; NULL when `read_buffer` is not positive. +TcpSocket* tcp_connect_buffered_raw(const char* host, int port, int read_buffer); int tcp_send_raw(TcpSocket* sock, const char* data); int tcp_send_n_raw(TcpSocket* sock, const char* data, int length); // The bytes received as a fresh counted AetherString (cast to char*) the @@ -54,6 +57,8 @@ int tcp_set_nodelay_raw(TcpSocket* sock, int on); // SO_RCVBUF on a connected socket, which also stops the kernel growing it: // 0 on success, -1 on a null/closed handle, bytes <= 0, or setsockopt failure. int tcp_set_read_buffer_raw(TcpSocket* sock, int bytes); +// How many received bytes wait to be read (FIONREAD), or -1 on error. +int tcp_available_raw(TcpSocket* sock); TcpSocket* tcp_accept_raw(TcpServer* server); int tcp_server_close(TcpServer* server); diff --git a/std/net/aether_net_addr.h b/std/net/aether_net_addr.h index e3db65a61..cb9490982 100644 --- a/std/net/aether_net_addr.h +++ b/std/net/aether_net_addr.h @@ -244,14 +244,18 @@ static inline int aether_addr_port(const struct sockaddr_storage* addr) { #define AETHER_DIAL_FIRST_TRY_MS 250 /* Connect a fresh socket to `addr`: within `timeout_ns` when positive, else - * blocking. Returns the descriptor in blocking mode, or -1 with *why set and - * *timed_out 1 when the attempt ran out of time rather than failed. */ + * blocking. `read_buffer` > 0 is the socket's SO_RCVBUF, set before the + * connection is made (0 leaves the kernel's). Returns the descriptor in + * blocking mode, or -1 with *why set and *timed_out 1 when the attempt ran + * out of time rather than failed. */ int aether_net_connect_addr(const struct sockaddr_storage* addr, socklen_t len, - long long timeout_ns, const char** why, int* timed_out); + long long timeout_ns, int read_buffer, + const char** why, int* timed_out); /* Dial `host`:`port` over the already-resolved `addrs` (reordered in place - * by the remembered family). `timeout_ns` <= 0 means no limit. */ + * by the remembered family). `timeout_ns` <= 0 means no limit; `read_buffer` + * as aether_net_connect_addr. */ int aether_net_dial_addrs(const char* host, int port, AetherAddrList* addrs, - long long timeout_ns, const char** why); + long long timeout_ns, int read_buffer, const char** why); /* Resolve, then aether_net_dial_addrs. */ int aether_net_dial(const char* host, int port, long long timeout_ns, const char** why); /* The remembered family: put its addresses first. Returns that family, or 0 diff --git a/std/net/aether_tls_trust.c b/std/net/aether_tls_trust.c new file mode 100644 index 000000000..39ff95ba0 --- /dev/null +++ b/std/net/aether_tls_trust.c @@ -0,0 +1,161 @@ +// Windows trust anchors, the same for both HTTPS clients (#2675). +// +// Neither client read the Windows certificate store, so a program copied to a +// machine without MSYS2 or Git for Windows could make no HTTPS request at all, +// and the two looked for different bundle files (OpenSSL's list in +// aether_http.c, the pure-Aether client's in std.cryptography.tls13_client), +// so one could work on a machine where the other failed. Both now trust, +// unless SSL_CERT_FILE or SSL_CERT_DIR names the store, every certificate in +// the system ROOT store (what Python's ssl module loads on Windows) together +// with the first bundle file of the one list below. The bundle joins the store +// rather than standing in for it: Windows adds a root to the store on demand, +// the first time CryptoAPI needs it, so a root a server chains to can be +// missing from the store of a machine that has never met that server. +// +// The tls13_client module reaches these through extern declarations, which is +// why they exist (returning nothing) on every platform. They live in a file of +// their own (#3426): in aether_http.c, beside the OpenSSL code that uses them +// too, every program that imported the pure client linked the whole HTTP +// client for them. + +#include "aether_tls_trust.h" +#include "../../runtime/aether_resource_caps.h" +#include "../string/aether_string.h" + +#include +#include + +#ifdef _WIN32 +#include +#include + +/* The first file of the bundle list that exists, in `out`; "" when none + * does. In order: the bundle the release archive ships beside the program + * (\bin\prog.exe -> \share\ssl\ca-bundle.crt, or the program's own + * directory when it is not in a bin\), MSYS2's ca-certificates for MINGW64 + * and UCRT64 under both names it has used, and Git for Windows'. */ +const char* aether_tls_windows_ca_bundle(char* out, size_t cap) { + static const char* fixed[] = { + "C:\\msys64\\mingw64\\etc\\ssl\\certs\\ca-bundle.crt", + "C:\\msys64\\ucrt64\\etc\\ssl\\certs\\ca-bundle.crt", + "C:\\msys64\\mingw64\\etc\\ssl\\cert.pem", + "C:\\msys64\\ucrt64\\etc\\ssl\\cert.pem", + "C:\\Program Files\\Git\\mingw64\\etc\\ssl\\certs\\ca-bundle.crt", + NULL + }; + char exe_path[MAX_PATH]; + DWORD n = GetModuleFileNameA(NULL, exe_path, sizeof(exe_path)); + if (n > 0 && n < sizeof(exe_path)) { + char* p = strrchr(exe_path, '\\'); // ...\bin\prog.exe -> ...\bin + if (p) { + *p = '\0'; + char* q = strrchr(exe_path, '\\'); // ...\bin -> ... + if (q && _stricmp(q, "\\bin") == 0) *q = '\0'; + } + snprintf(out, cap, "%s\\share\\ssl\\ca-bundle.crt", exe_path); + DWORD a = GetFileAttributesA(out); + if (a != INVALID_FILE_ATTRIBUTES && !(a & FILE_ATTRIBUTE_DIRECTORY)) return out; + } + for (int i = 0; fixed[i]; i++) { + DWORD a = GetFileAttributesA(fixed[i]); + if (a != INVALID_FILE_ATTRIBUTES && !(a & FILE_ATTRIBUTE_DIRECTORY)) { + snprintf(out, cap, "%s", fixed[i]); + return out; + } + } + if (cap) out[0] = '\0'; + return out; +} + +/* Calls `each` with the DER of every certificate in the ROOT system store of + * the current user, which includes the machine's. Returns how many. */ +int aether_tls_windows_root_store_each(void (*each)(const unsigned char* der, size_t len, void* arg), + void* arg) { + HCERTSTORE store = CertOpenSystemStoreW(0, L"ROOT"); + if (!store) return 0; + int n = 0; + PCCERT_CONTEXT c = NULL; + while ((c = CertEnumCertificatesInStore(store, c)) != NULL) { + if ((c->dwCertEncodingType & X509_ASN_ENCODING) && c->cbCertEncoded > 0) { + each(c->pbCertEncoded, (size_t)c->cbCertEncoded, arg); + n++; + } + } + CertCloseStore(store, 0); + return n; +} + +typedef struct { char* buf; size_t len, cap; int oom; } PemOut; + +static void pem_out_append(PemOut* o, const char* s, size_t n) { + if (o->oom) return; + if (o->len + n + 1 > o->cap) { + size_t want = o->cap ? o->cap * 2 : 65536; + while (want < o->len + n + 1) want *= 2; + char* grown = (char*)aether_caps_realloc(o->buf, o->cap, want); + if (!grown) { o->oom = 1; return; } + o->buf = grown; + o->cap = want; + } + memcpy(o->buf + o->len, s, n); + o->len += n; + o->buf[o->len] = '\0'; +} + +/* One certificate as a PEM block, base64 in lines of 64. */ +static void pem_out_cert(const unsigned char* der, size_t len, void* arg) { + static const char b64[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + PemOut* o = (PemOut*)arg; + pem_out_append(o, "-----BEGIN CERTIFICATE-----\n", 28); + char line[65]; + size_t col = 0; + for (size_t i = 0; i < len; i += 3) { + unsigned v = (unsigned)der[i] << 16; + if (i + 1 < len) v |= (unsigned)der[i + 1] << 8; + if (i + 2 < len) v |= der[i + 2]; + line[col++] = b64[(v >> 18) & 63]; + line[col++] = b64[(v >> 12) & 63]; + line[col++] = i + 1 < len ? b64[(v >> 6) & 63] : '='; + line[col++] = i + 2 < len ? b64[v & 63] : '='; + if (col == 64) { + line[col++] = '\n'; + pem_out_append(o, line, col); + col = 0; + } + } + if (col) { + line[col++] = '\n'; + pem_out_append(o, line, col); + } + pem_out_append(o, "-----END CERTIFICATE-----\n", 26); +} +#endif // _WIN32 + +/* Every certificate of the Windows ROOT system store as PEM text, a fresh + * counted string the caller releases; "" when there is none, and on any other + * platform. NULL only when out of memory. */ +char* aether_tls_windows_store_pem(void) { +#ifdef _WIN32 + PemOut o = { NULL, 0, 0, 0 }; + aether_tls_windows_root_store_each(pem_out_cert, &o); + if (o.oom) { aether_caps_free(o.buf, o.cap); return NULL; } + if (o.buf) return (char*)string_adopt_caps_buffer(o.buf, o.len, o.cap); +#endif + return (char*)string_empty(); +} + +/* The first bundle file of the list above that exists, a fresh counted string + * the caller releases; "" when none does, and on any other platform. NULL only + * when out of memory. */ +char* aether_tls_windows_bundle(void) { +#ifdef _WIN32 + char path[MAX_PATH + 64]; + aether_tls_windows_ca_bundle(path, sizeof(path)); + size_t n = strlen(path); +#else + const char* path = ""; + size_t n = 0; +#endif + return (char*)string_new_with_length(path, n); +} diff --git a/std/net/aether_tls_trust.h b/std/net/aether_tls_trust.h new file mode 100644 index 000000000..a0a643fee --- /dev/null +++ b/std/net/aether_tls_trust.h @@ -0,0 +1,29 @@ +#ifndef AETHER_TLS_TRUST_H +#define AETHER_TLS_TRUST_H + +/* The Windows trust anchors, the same for both HTTPS clients (#2675): see + * aether_tls_trust.c. std.http.client's OpenSSL path (aether_http.c) calls the + * first two; std.cryptography.tls13_client the last two, through externs. */ + +#include + +#ifdef _WIN32 +/* The first file of the shared bundle list that exists, in `out`; "" when + * none does. Returns `out`. */ +const char* aether_tls_windows_ca_bundle(char* out, size_t cap); +/* Calls `each` with the DER of every certificate in the ROOT system store of + * the current user, which includes the machine's. Returns how many. */ +int aether_tls_windows_root_store_each(void (*each)(const unsigned char* der, size_t len, void* arg), + void* arg); +#endif + +/* Every certificate of the Windows ROOT system store as PEM text, a fresh + * counted string the caller releases; "" when there is none, and on any other + * platform. NULL only when out of memory. */ +char* aether_tls_windows_store_pem(void); +/* The first bundle file of the shared list that exists, a fresh counted + * string the caller releases; "" when none does, and on any other platform. + * NULL only when out of memory. */ +char* aether_tls_windows_bundle(void); + +#endif /* AETHER_TLS_TRUST_H */ diff --git a/std/os/aether_os.c b/std/os/aether_os.c index d92634f9d..84b476455 100644 --- a/std/os/aether_os.c +++ b/std/os/aether_os.c @@ -3506,17 +3506,15 @@ typedef struct { int token; HANDLE h; HANDLE job; } WinProc; static WinProc g_winprocs[4096]; static int g_winproc_count = 0; static int g_winproc_next_token = WINPROC_TOKEN_BASE; -static CRITICAL_SECTION g_winproc_cs; -static int g_winproc_cs_init = 0; - -static void winproc_lock(void) { - /* One-time CS init. Spawns are effectively single-threaded from a build - * scheduler, but guard anyway so a threaded caller can't corrupt the - * table. The init race is itself benign for the single-threaded case. */ - if (!g_winproc_cs_init) { InitializeCriticalSection(&g_winproc_cs); g_winproc_cs_init = 1; } - EnterCriticalSection(&g_winproc_cs); -} -static void winproc_unlock(void) { LeaveCriticalSection(&g_winproc_cs); } +/* Initialized statically, so two threads spawning at once cannot both make + * it, as they could the critical section made on first use that it replaces + * (#3346). It allocates nothing either: the critical section took a debug + * record from the heap the first time a spawn had to wait on it. Never taken + * while held. */ +static SRWLOCK g_winproc_srw = SRWLOCK_INIT; + +static void winproc_lock(void) { AcquireSRWLockExclusive(&g_winproc_srw); } +static void winproc_unlock(void) { ReleaseSRWLockExclusive(&g_winproc_srw); } /* Register a live handle, and the child's Job Object if it has one; return * its token (or -1 if the table is full). */ diff --git a/std/sandbox/module.ae b/std/sandbox/module.ae index 8723ae3d9..0a369fa0c 100644 --- a/std/sandbox/module.ae +++ b/std/sandbox/module.ae @@ -126,14 +126,25 @@ new(name: string) -> Grants { // grant_* call made with it from inside the block, by code it was passed to) // does not widen what the running block may do. For the same reason the block // is not given the list: its parameter is null. +// +// The grants apply to the thread that runs the block, and to every program +// and library on the same runtime the block calls into on that thread: an +// export of another library called from inside the block (through a host +// service, say) is held to these grants too, and an enforce of its own can +// only narrow them. A message the block sends is handled with no grants, +// as an actor's thread handles it: also when the send steps an idle +// receiver of the same core inline, on this thread, since a step puts the +// thread's levels aside until it ends (#3393). A library built without +// --shared-runtime on macOS or Windows has a runtime of its own and cannot +// read these grants: called +// from inside the block, its checked operations are refused, with a message +// naming --shared-runtime (docs/containment-sandbox.md). enforce(perms: Grants, code: fn) { frozen = freeze(perms as ptr) defer free_list(frozen) sandbox_push(frozen) - sandbox_install() call(code, null) sandbox_pop() - sandbox_uninstall() } // A private copy of a grant list, with copies of its strings: what enforce diff --git a/std/stream/README.md b/std/stream/README.md new file mode 100644 index 000000000..fc57f3139 --- /dev/null +++ b/std/stream/README.md @@ -0,0 +1,141 @@ +# std.stream + +A stream between two actors whose memory stays bounded however fast the +producer is (#426). + +A send never blocks. When a consumer's mailbox is full, the scheduler parks +the message in an overflow buffer that grows without bound, because blocking +the sender is how actor systems deadlock. That is right for the scheduler, +but it means a producer that outruns its consumer (a file reader feeding a +compressor, a log tailer feeding an uploader) grows the process until it +dies. A stream puts the bound back, at the stream instead of the mailbox. + +The producer starts with `window` credits. Each chunk it sends costs one, and +the consumer returns one for each chunk it has handled. With no credit left +the producer stops, and it goes on when the next `StreamCredit` arrives. So +at most `window` chunks of the stream are ever in flight, and the producer +never waits: running out of credit is a return value, not a block. + +```aether,run +import std.stream +import std.string + +message Start { s: ptr, data: string } +message Result {} + +actor Producer { + state s: ptr = null + state data = "" + state at = 0 + receive { + Start(s: st, data: d) -> { + s = st + data = d + at = stream.write(s, data, 0, 4) + if at == string.length(data) { + stream.finish(s) + } + } + StreamCredit(s: st, n) -> { + if stream.credit(st, n) == 0 { + s = null // the stream has ended, and is freed + } else if at < string.length(data) { + at = stream.write(s, data, at, 4) + if at == string.length(data) { + stream.finish(s) + } + } + } + } +} + +actor Consumer { + state text = "" + state chunks = 0 + receive { + StreamChunk(s, data, last) -> { + if last == 0 { + text = string.concat(text, data) + chunks = chunks + 1 + } + stream.consumed(s, last) + } + Result() -> { reply "${chunks} chunks: ${text}" } + } +} + +main() { + p = spawn(Producer()) + c = spawn(Consumer()) + s = stream.connect(p, c, 2) + p ! Start { s: s, data: "a credit-windowed stream" } + wait_for_idle() + println(c ? Result {}) +} +``` +```output +6 chunks: a credit-windowed stream +``` + +Six chunks of four bytes went through a window of two. The producer never +had more than two out at once. + +## The calls + +| Call | Whose | What it does | +|---|---|---| +| `stream.connect(producer, consumer, window)` | whoever wires them | A stream with at most `window` chunks in flight, or `null` for a window under 1. Give it to both actors. | +| `stream.offer(s, data)` | producer | Sends `data` as one chunk: `1` sent, `0` the window is full (offer it again after the next credit), `-1` after `finish`. | +| `stream.write(s, data, at, chunk)` | producer | Sends `data` from byte `at` on, in chunks of at most `chunk` bytes, while the window allows. Returns the byte it reached: the length of `data` when all of it went, less when the window filled (call again from there after the next credit). | +| `stream.finish(s)` | producer | Ends the stream. The consumer gets a last chunk after every chunk sent before it. | +| `stream.credit(s, n)` | producer, for each `StreamCredit` | `1`: `n` more chunks may go. `0`: this was the consumer's end of the stream, which is freed now; drop it. | +| `stream.consumed(s, last)` | consumer, after handling each `StreamChunk` | Returns the chunk's credit or, for the last chunk, ends the stream on the consumer's side; drop it after. | +| `stream.available(s)` | producer | Chunks it may send now. | +| `stream.in_flight(s)` | producer | Chunks sent whose credit has not come back. | +| `stream.peak(s)` | producer | The most chunks that were in flight at once. Never more than the window. | + +The two messages, which the program's actors handle under these names: + +```aether,fragment +message StreamChunk { s: ptr, data: string, last: int } // last 1: the end, no data +message StreamCredit { s: ptr, n: int } // n 0: the consumer's end +``` + +## How it ends, and who frees it + +Messages from one sender to one actor arrive in the order sent, both ways. +So the consumer's `StreamChunk`s come in the order the producer sent them, +the last one last, and the producer's `StreamCredit`s come in the order the +consumer sent them, the end (`n` 0) last. When `stream.credit` sees the end, +every chunk has been handled and every credit has come back, so nothing of +the stream is in flight. The stream is freed there, by the producer's call. + +An actor that drops out midway leaks the stream, as it would any message +addressed to it. A stream is for two actors that both run it to the end. + +## Choices made + +- **The unit of credit is a chunk, not a byte.** The producer picks the chunk + size (`stream.write`'s `chunk`), so the memory a stream can hold is at most + `window` times that size. To bound bytes, pick the chunk size and set the + window to the budget divided by it. +- **The consumer's buffer is its mailbox.** At most `window` chunks of the + stream are ever queued there, so the stream needs no buffer of its own. +- **A chunk is a copy.** `data` is a string, so it holds any bytes + (`std.bytes`' `string_from_slice` makes one from a `byte[]`), and a send + copies it. The consumer owns what it gets and the producer keeps what it + sent, so nothing is shared across the send and no credit has to carry a + release. +- **The read is the arm.** An actor never blocks, so a consumer reads a + stream the way a reactor reads a socket: when data is there. Its + `StreamChunk` arm is the read loop's body, and `last` is the end of file. +- **No sequence numbers and no reordering.** Delivery between two actors in + one process is in order, so there is nothing to reorder. Sequencing and + session ids belong with a transport that can lose or reorder messages, and + will be designed with one. +- **A library, not a runtime feature.** It is a struct and two messages over + plain sends; the scheduler is unchanged. + +Run the module's tests with `ae test std/stream/test_stream.ae`. They check +order and integrity on binary data, the window bound, the end, misuse, and, +where the heap count is exact, that whole streams leak nothing. diff --git a/std/stream/module.ae b/std/stream/module.ae new file mode 100644 index 000000000..17e1d541c --- /dev/null +++ b/std/stream/module.ae @@ -0,0 +1,212 @@ +// std.stream: credit-windowed streams between actors (#426). +// Import with: import std.stream +// +// A send never blocks, so a producer that outruns its consumer grows the +// consumer's mailbox, and then the scheduler's overflow, without bound. A +// stream bounds it. The producer holds `window` credits; each chunk it sends +// costs one, and the consumer returns one for each chunk it has handled. At +// most `window` chunks of a stream are in flight, however fast the producer, +// and the producer learns it may go on from a StreamCredit, never by waiting. +// +// Whoever wires the two actors connects them and gives both the stream: +// +// s = stream.connect(producer, consumer, 8) +// +// The producer sends while it has credit (stream.write cuts a string into +// chunks and sends as many as the window allows, returning where it got +// to), finishes after the last chunk, and hands every StreamCredit to +// stream.credit, which says when the stream has ended: +// +// StreamCredit(s, n) -> { +// if stream.credit(s, n) == 0 { /* ended and freed: drop s */ } +// else { at = stream.write(s, data, at, 4096) ... } +// } +// +// The consumer handles each chunk, then says so: +// +// StreamChunk(s, data, last) -> { +// if last == 0 { use(data) } +// stream.consumed(s, last) +// } +// +// The chunk with `last` 1 carries no data and ends the stream; once the +// consumer has said it consumed it, it drops the stream. Chunks arrive in +// the order sent, and the credits and the end come back in the order the +// consumer sent them, so the end is the producer's last StreamCredit: the +// stream is freed there, with nothing of it still in flight. The README +// has a whole producer and consumer. +// +// `data` is a string, so it holds any bytes (std.bytes' string_from_slice +// makes one from a byte[]); a send copies it, so the consumer owns what it +// receives and the producer keeps what it sent. + +import std.string + +exports( + StreamChunk, StreamCredit, + connect, offer, write, finish, credit, consumed, + available, in_flight, peak +) + +// One chunk of stream `s`; `last` 1 (and no data) ends it. +message StreamChunk { s: ptr, data: string, last: int } +// `n` more chunks of stream `s` may be sent; `n` 0 is the consumer's end of +// it, after its last credit. +message StreamCredit { s: ptr, n: int } + +struct Stream { + producer: actor_ref + consumer: actor_ref + window: int + // The rest is the producer's alone. + credits: int // chunks it may still send + peak: int // the most chunks in flight at once + finished: int // it sent the last chunk +} + +// A stream from `producer` to `consumer` with at most `window` chunks in +// flight, or null when `window` is less than 1. Give it to both actors; the +// consumer's end frees it (see stream.credit). +connect(producer: actor_ref, consumer: actor_ref, window: int) -> ptr { + if window < 1 { + return null + } + st = heap.new(Stream) + st.producer = producer + st.consumer = consumer + st.window = window + st.credits = window + st.peak = 0 + st.finished = 0 + return st as ptr +} + +// The producer's: sends `data` as the next chunk. 1 when it was sent; 0 when +// the window is full (send it again after the next StreamCredit); -1 after +// finish. +offer(s: @keep ptr, data: string) -> int { + if s == null { + return -1 + } + st = s as *Stream + if st.finished == 1 { + return -1 + } + if st.credits == 0 { + return 0 + } + st.credits = st.credits - 1 + if st.window - st.credits > st.peak { + st.peak = st.window - st.credits + } + st.consumer ! StreamChunk { s: s, data: data, last: 0 } + return 1 +} + +// The producer's: sends `data` from byte `at` on, in chunks of at most +// `chunk` bytes, while the window allows. Returns the byte it got to: the +// length of `data` when all of it went, less when the window filled (call +// again from there after the next StreamCredit); -1 after finish, or when +// `chunk` is less than 1. +write(s: @keep ptr, data: string, at: int, chunk: int) -> int { + if s == null || chunk < 1 { + return -1 + } + st = s as *Stream + if st.finished == 1 { + return -1 + } + n = string.length(data) + pos = at + if pos < 0 { + pos = 0 + } + while pos < n && st.credits > 0 { + end = pos + chunk + if end > n { + end = n + } + _sent = offer(s, string.substring(data, pos, end)) + pos = end + } + return pos +} + +// The producer's: the stream is done. The consumer gets the last chunk after +// every chunk sent before it. It costs no credit; the producer still takes +// StreamCredits until stream.credit says the stream has ended. +finish(s: @keep ptr) { + if s == null { + return + } + st = s as *Stream + if st.finished == 1 { + return + } + st.finished = 1 + st.consumer ! StreamChunk { s: s, data: "", last: 1 } +} + +// The producer's, for each StreamCredit it receives: 1 when `n` chunks more +// may be sent; 0 when this is the consumer's end of the stream, which is +// freed now (the producer drops it). +credit(s: ptr, n: int) -> int { + if s == null { + return 0 + } + st = s as *Stream + if n == 0 { + heap.free(st) + return 0 + } + if n > 0 { + st.credits = st.credits + n + if st.credits > st.window { + st.credits = st.window + } + } + return 1 +} + +// The consumer's, in its StreamChunk arm once it has handled the chunk: +// returns its credit to the producer or, for the last chunk (`last` 1, the +// chunk's own field), ends the stream for the consumer, which drops it. +consumed(s: @keep ptr, last: int) { + if s == null { + return + } + st = s as *Stream + if last == 1 { + st.producer ! StreamCredit { s: s, n: 0 } + } else { + st.producer ! StreamCredit { s: s, n: 1 } + } +} + +// The producer's: chunks it may send now. +available(s: ptr) -> int { + if s == null { + return 0 + } + st = s as *Stream + return st.credits +} + +// The producer's: chunks sent whose credit has not come back yet. +in_flight(s: ptr) -> int { + if s == null { + return 0 + } + st = s as *Stream + return st.window - st.credits +} + +// The producer's: the most chunks that were in flight at once (never more +// than the window). +peak(s: ptr) -> int { + if s == null { + return 0 + } + st = s as *Stream + return st.peak +} diff --git a/std/stream/test_stream.ae b/std/stream/test_stream.ae new file mode 100644 index 000000000..cf79413a5 --- /dev/null +++ b/std/stream/test_stream.ae @@ -0,0 +1,256 @@ +// std.stream (#426): a credit-windowed stream between two actors. +// +// - Order and integrity: binary data (zero bytes included) cut into chunks +// arrives whole and in order, compared chunk by chunk. +// - The window: a producer that has more to send than the window fills it +// and stops (stream.write returns short), resumes on each credit, and +// never has more than `window` chunks in flight (stream.peak); every +// credit is back before the consumer's end (in_flight 0 there). +// - The end: an empty stream is only its last chunk; a window of 1 works. +// - Misuse: no stream for a window under 1; nothing sent after finish. +// - No leaks: where the heap count is exact (mem.heap_in_use_exact), whole +// streams, actors spawned and released each round, hold the heap steady +// (mem.steady_growth); elsewhere the leak gates cover this program. + +import std.bytes +import std.mem +import std.stream +import std.string + +extern exit(code: int) +extern scheduler_release_actor(a: ptr) @c_import +extern scheduler_reclaim_released() -> int @c_import + +message Start { s: ptr, data: string, chunk: int } +message Expect { data: string } +message Fulls {} +message Peak {} +message Drained {} +message Ended {} +message Got {} +message Bad {} +message Chunks {} + +actor Producer { + state s: ptr = null + state data = "" + state at = 0 + state chunk = 1 + state fulls = 0 + state peak = 0 + state drained = -1 + state ended = 0 + receive { + Start(s: st, data: d, chunk: c) -> { + s = st + data = d + chunk = c + at = stream.write(s, data, 0, chunk) + peak = stream.peak(s) + if at < string.length(data) { + fulls = fulls + 1 + } else { + stream.finish(s) + } + } + StreamCredit(s: st, n) -> { + if n == 0 { + drained = stream.in_flight(st) + } + if stream.credit(st, n) == 0 { + ended = ended + 1 + s = null + } else if at < string.length(data) { + at = stream.write(s, data, at, chunk) + if stream.peak(s) > peak { + peak = stream.peak(s) + } + if at < string.length(data) { + fulls = fulls + 1 + } else { + stream.finish(s) + } + } + } + Fulls() -> { reply fulls } + Peak() -> { reply peak } + Drained() -> { reply drained } + Ended() -> { reply ended } + } +} + +actor Consumer { + state expect = "" + state got = 0 + state bad = 0 + state chunks = 0 + state ended = 0 + receive { + Expect(data: d) -> { expect = d } + StreamChunk(s: st, data, last) -> { + if last == 0 { + n = string.length(data) + if string.equals(string.substring(expect, got, got + n), data) == 0 { + bad = bad + 1 + } + got = got + n + chunks = chunks + 1 + } else { + ended = ended + 1 + } + stream.consumed(st, last) + } + Got() -> { reply got } + Bad() -> { reply bad } + Chunks() -> { reply chunks } + Ended() -> { reply ended } + } +} + +fn check(cond: bool, msg: string) { + if !cond { + println("FAIL: ${msg}") + exit(1) + } +} + +// `n` bytes of every value, zero included. +fn binary(n: int) -> string { + b = bytes.new(n) + i = 0 + while i < n { + _set = bytes.set(b, i, (i * 7 + i / 256) % 256) + i = i + 1 + } + out = bytes.to_string(b, n) + bytes.free(b) + return out +} + +// One whole stream of `data` in chunks of `chunk` bytes through a window of +// `window`; checks what both ends saw. +fn run(data: string, chunk: int, window: int, quiet: bool) { + p = spawn(Producer()) + c = spawn(Consumer()) + s = stream.connect(p, c, window) + check(s != null, "connect gave no stream") + c ! Expect { data: data } + // Only one sender's messages keep their order: the producer's first + // chunks could reach the consumer before main's Expect. So the consumer + // has taken Expect before the producer starts. + wait_for_idle() + p ! Start { s: s, data: data, chunk: chunk } + wait_for_idle() + + n = string.length(data) + want = (n + chunk - 1) / chunk + int got = c ? Got {} + int bad = c ? Bad {} + int chunks = c ? Chunks {} + int c_end = c ? Ended {} + int p_end = p ? Ended {} + int fulls = p ? Fulls {} + int peak = p ? Peak {} + int drained = p ? Drained {} + check(got == n, "the consumer got ${got} bytes of ${n}") + check(bad == 0, "${bad} chunks differ from what was sent") + check(chunks == want, "the consumer got ${chunks} chunks of ${want}") + check(c_end == 1, "the consumer saw ${c_end} ends") + check(p_end == 1, "the producer saw ${p_end} ends") + check(peak <= window, "${peak} chunks in flight through a window of ${window}") + if want > window { + check(peak == window, "a producer with more to send filled ${peak} of ${window}") + check(fulls >= 1, "the window never stopped a producer with more to send") + } + check(drained == 0, "${drained} credits still out at the consumer's end") + if !quiet { + println(" ${n} bytes, ${chunks} chunks, window ${window}: peak ${peak}, stopped ${fulls} times") + } + let pp: ptr = p + let cp: ptr = c + scheduler_release_actor(pp) + scheduler_release_actor(cp) + wait_for_idle() + // Reclaimed now, so the next run's spawns reuse their blocks: left to the + // cores, a parked core holds the reclaim back past them, the spawns take + // new blocks, and the high-water mark that sets could rise in every + // window the leak check measures, a leak it is not. One that never + // becomes reusable still grows every run. + check(scheduler_reclaim_released() == 0, "released actors still retired after a run") +} + +fn round() { + data = binary(5000) + run(data, 64, 4, true) + run(data, 5000, 2, true) + run("", 16, 3, true) +} + +// Takes no stream messages: what it is sent is dropped, so the calls below +// are the only ones on the stream, made by main as its producer. +actor Deaf { + state n = 0 + receive { + Got() -> { reply n } + } +} + +fn misuse() { + d = spawn(Deaf()) + check(stream.connect(d, d, 0) == null, "a window of 0 gave a stream") + check(stream.connect(d, d, -1) == null, "a negative window gave a stream") + check(stream.offer(null, "x") == -1, "an offer on no stream did not say so") + check(stream.write(null, "x", 0, 1) == -1, "a write on no stream did not say so") + check(stream.credit(null, 1) == 0, "a credit on no stream did not say so") + check(stream.available(null) == 0, "no stream has credit") + + s = stream.connect(d, d, 2) + check(stream.available(s) == 2, "a new stream does not hold its window") + check(stream.offer(s, "a") == 1, "the first offer was refused") + check(stream.in_flight(s) == 1, "one chunk sent is not one in flight") + check(stream.offer(s, "b") == 1, "the second offer was refused") + check(stream.offer(s, "c") == 0, "an offer through a full window was not refused") + check(stream.write(s, "cd", 0, 1) == 0, "a write through a full window moved") + check(stream.credit(s, 1) == 1, "a credit ended the stream") + check(stream.available(s) == 1, "a credit did not free one chunk") + check(stream.credit(s, 5) == 1, "a second credit ended the stream") + check(stream.available(s) == 2, "credits went past the window") + check(stream.peak(s) == 2, "the peak is not the window") + stream.finish(s) + check(stream.offer(s, "d") == -1, "an offer after finish was not refused") + check(stream.write(s, "d", 0, 1) == -1, "a write after finish was not refused") + check(stream.credit(s, 0) == 0, "the end did not end the stream") + let dp: ptr = d + scheduler_release_actor(dp) + wait_for_idle() +} + +main() { + data = binary(70000) + run(data, 1000, 8, false) + run(data, 333, 1, false) + run(data, 100000, 4, false) + run("", 16, 3, false) + misuse() + + i = 0 + while i < 10 { + round() + i = i + 1 + } + check(scheduler_reclaim_released() == 0, "released actors still retired after the warm-up") + if !mem.heap_in_use_exact() { + println("OK") // count not exact here: the leak gates check this program + return + } + grown = mem.steady_growth(|| { + j = 0 + while j < 10 { + round() + j = j + 1 + } + check(scheduler_reclaim_released() == 0, "released actors still retired after a window") + }, 5) + check(grown == 0, "streams grew the heap by ${grown} bytes in every run") + println("OK") +} diff --git a/std/supervise/README.md b/std/supervise/README.md new file mode 100644 index 000000000..0ea82e712 --- /dev/null +++ b/std/supervise/README.md @@ -0,0 +1,89 @@ +# std.supervise + +Supervisors that restart actors (#2974). A supervisor starts its children +from factories, links them, and restarts a child that dies in place, under +the ref its siblings and clients already hold: the factory runs again, and its +spawn of the child's actor type takes the child's own block +(`actors.rebirth_begin`, docs/actor-concurrency.md, "Restart in place"). + +```aether,run +import std.actors +import std.supervise + +message Work {} +message Count {} + +actor Worker { + state done = 0 + receive { + Work() -> { + done = done + 1 + if done == 2 { panic("out of paper") } + } + Count() -> { reply done } + } +} + +main() { + sup = supervise.start(strategy: "one_for_one", max_restarts: 3, within_secs: 60) { + supervise.child("worker", || -> spawn(Worker())) + } + w = supervise.which(sup, "worker") + w ! Work {} + w ! Work {} // panics: the worker restarts + wait_for_idle() + w ! Work {} // the same ref reaches the new incarnation + wait_for_idle() + int n = w ? Count {} + println("restarts ${supervise.restarts(sup)}, incarnation ${actors.incarnation(w)}, done ${n}") +} +``` +```output +restarts 1, incarnation 1, done 1 +``` + +## Strategies + +| `strategy` | A child dies: restarted | +|---|---| +| `"one_for_one"` | that child alone | +| `"one_for_all"` | every child: the others are killed, newest first, and all restart in start order once each one's `Down` has come | +| `"rest_for_one"` | that child and every child started after it | + +A death while a restart is still waiting for a `Down` (a sibling killed for +it has not ended yet) joins that restart: each child is restarted once, the +children of both deaths together in start order. + +A child's restart kind is `"permanent"` (`supervise.child`, restarted after +any end), `"transient"` (restarted unless it ended `"normal"`) or +`"temporary"` (never), the last two through +`supervise.child_restart(name, factory, kind)`. + +## The budget + +More than `max_restarts` restarts within `within_secs` seconds (defaults 3 +and 5), or a factory that spawns nothing or panics at a restart, and the +supervisor gives up: it kills its children, newest first, and panics with the +reason (`"supervisor gave up: more than 3 restarts within 60 s (worker: out +of paper)"`). So an actor that monitors it gets a `Down` with that reason, +and a supervisor it is linked to (a supervisor of supervisors) takes the +failure in turn. + +## Factories + +A factory is a closure that spawns the child: `|| -> spawn(Worker())`, or a +block that spawns it and configures it (`|| { w = spawn(Worker()); w ! Config {} }`); +the first actor it spawns is the child. `supervise.start` runs each factory, +in order, before it returns, so `supervise.which(sup, name)` has every child's +ref at once; a factory that spawns nothing there panics the caller. At a +restart the factory runs again on the supervisor, and its spawn takes the +dead child's block: messages queued at the death and sends while it is dead +are dropped and counted (`actors.dead_sends()`), and sends after the restart +reach the new incarnation. + +A supervisor holds up to `MAX_CHILDREN` (31) children. + +## Exports + +`start`, `child`, `child_restart`, `which`, `restarts`, `stop`, +`SuperviseChild`, `SuperviseSpec`, `MAX_CHILDREN`. diff --git a/std/supervise/module.ae b/std/supervise/module.ae new file mode 100644 index 000000000..01e8dcfe2 --- /dev/null +++ b/std/supervise/module.ae @@ -0,0 +1,360 @@ +// std.supervise: supervisors that restart actors (#2974). +// Import with: import std.supervise +// +// sup = supervise.start(strategy: "one_for_one", max_restarts: 3, within_secs: 60) { +// supervise.child("worker", || -> spawn(Worker())) +// supervise.child_restart("db", || -> spawn(Db()), "transient") +// } +// +// A supervisor is an ordinary actor on std.actors. It traps exits and links +// each child, so a child's death reaches it as a Down, and its own death +// takes the children with it. A child that dies is restarted in place, under +// the ref its siblings and clients already hold (actors.rebirth_begin, the +// child's factory, actors.rebirth_end), by its strategy: +// +// one_for_one the dead child alone; +// one_for_all every child: the others are killed, newest first, and all +// restart in start order once each one's Down has come; +// rest_for_one the dead child and every child started after it. +// +// A death while a restart still waits for a Down joins it: each child is +// restarted once, the ones of both deaths together in start order. +// +// A child's restart kind says whether it is restarted: "permanent" (always, +// the default), "transient" (unless it ended "normal") or "temporary" +// (never). More than max_restarts restarts within within_secs, or a factory +// that fails (it spawns nothing, or panics), and the supervisor gives up: it +// kills its children, newest first, and panics with the reason, so its own +// watchers get a Down with it and a supervisor it is linked to takes the +// failure in turn. + +import std.actors + +exports( + start, child, child_restart, which, restarts, stop, + SuperviseChild, SuperviseSpec, + MAX_CHILDREN +) + +const MAX_CHILDREN = 31 + +extern aether_actor_capture_begin() +extern aether_actor_capture_end() -> actor_ref + +struct SuperviseChild { + name: string + factory: fn + restart: int // 0 permanent, 1 transient, 2 temporary + ref: actor_ref + phase: int // 0 not started, 1 alive, 2 ended (its Down came), 3 killed by the supervisor, its Down to come +} + +struct SuperviseSpec { + count: int + kids: SuperviseChild[32] +} + +message SuperviseStart {} +message SuperviseRestart {} +message SuperviseGiveUp { why: string } +message SuperviseStop {} + +actor Supervisor { + state strategy = 0 // 0 one_for_one, 1 one_for_all, 2 rest_for_one + state max_restarts = 3 + state within_ns: long = 0 + state within_secs = 0 + state kids: SuperviseChild[32] + state count = 0 + // 1 for a child to restart: each one at most once, restarted in start + // order, whichever deaths asked for it (#3390). + state pending: int[32] + state times: long[64] // the last restarts, a ring + state tnext = 0 + state tcount = 0 + state total = 0 + state stopping = 0 + + receive { + SuperviseStart() -> { + // Started by supervise.start; linked here, so a death reaches + // this actor as a Down (one that came first is taken at once). + actors.trap_exits(true) + i = 0 + while i < count { + if kids[i].phase == 1 { + actors.link(kids[i].ref) + } + i = i + 1 + } + } + + Down(ref, reason) -> { + if stopping == 0 { + i = 0 + found = -1 + while i < count && found < 0 { + if kids[i].ref == ref && kids[i].phase != 0 { + found = i + } + i = i + 1 + } + if found >= 0 { + if kids[found].phase == 3 { + // Killed for a restart: it can be restarted now. + kids[found].phase = 2 + self ! SuperviseRestart {} + } else if kids[found].phase == 1 { + kids[found].phase = 2 + again = kids[found].restart == 0 || + (kids[found].restart == 1 && reason != "normal") + if again { + // The restarts already made inside the window. + now = clock_ns() + recent = 0 + k = 0 + while k < tcount { + if now - times[k] < within_ns { + recent = recent + 1 + } + k = k + 1 + } + if recent >= max_restarts { + stopping = 1 + self ! SuperviseGiveUp { + why: "supervisor gave up: more than ${max_restarts} restarts within ${within_secs} s (${kids[found].name}: ${reason})" + } + } else { + times[tnext] = now + tnext = (tnext + 1) % 64 + if tcount < 64 { + tcount = tcount + 1 + } + first = found + if strategy == 1 { + first = 0 + } + // The ones to restart with it are killed, newest first. + if strategy != 0 { + j = count - 1 + while j >= first { + if j != found && kids[j].phase == 1 { + kids[j].phase = 3 + actors.kill(kids[j].ref) + } + j = j - 1 + } + } + // And restarted in start order, together with + // the children an earlier death left pending: + // one already pending is not added again. + j = first + while j < count { + // A temporary sibling is not restarted with it. + if j == found || (strategy != 0 && kids[j].restart != 2) { + if kids[j].phase == 2 || kids[j].phase == 3 { + pending[j] = 1 + } + } + j = j + 1 + } + self ! SuperviseRestart {} + } + } + } + } + } + } + + SuperviseRestart() -> { + busy = 1 + while busy == 1 && stopping == 0 { + // The first pending child in start order. + i = 0 + while i < count && pending[i] == 0 { + i = i + 1 + } + if i == count { + busy = 0 + } else if kids[i].phase == 3 { + // Its Down is still to come; the Down goes on from here. + busy = 0 + } else { + rc = actors.rebirth_begin(kids[i].ref) + if rc == 0 { + // A message from before its death is still on its way. + self ! SuperviseRestart {} + busy = 0 + } else if rc < 0 { + stopping = 1 + self ! SuperviseGiveUp { why: "supervisor gave up: child ${kids[i].name} cannot be restarted" } + busy = 0 + } else { + failed = 0 + aether_actor_capture_begin() + f = kids[i].factory + try { + call(f) + } catch e { + failed = 1 + } + _c = aether_actor_capture_end() + ok = actors.rebirth_end(kids[i].ref) + if failed == 1 || ok != 1 { + stopping = 1 + self ! SuperviseGiveUp { why: "supervisor gave up: the restart of ${kids[i].name} failed" } + busy = 0 + } else { + kids[i].phase = 1 + actors.link(kids[i].ref) + total = total + 1 + pending[i] = 0 + } + } + } + } + } + + SuperviseGiveUp(why) -> { + stopping = 1 + j = count - 1 + while j >= 0 { + if kids[j].phase == 1 || kids[j].phase == 3 { + actors.kill(kids[j].ref) + } + j = j - 1 + } + panic(why) + } + + SuperviseStop() -> { + stopping = 1 + j = count - 1 + while j >= 0 { + if kids[j].phase == 1 { + actors.kill(kids[j].ref) + } + j = j - 1 + } + actors.stop() + } + } +} + +spec_new() -> *SuperviseSpec { + s = heap.new(SuperviseSpec) + s.count = 0 + return s +} + +spec_free(p: ptr) { + heap.free(p as *SuperviseSpec) +} + +// Starts a supervisor with the children the block lists (supervise.child), +// and returns its ref. Each child is started in order when the supervisor +// runs; its factory is a closure that spawns it (`|| -> spawn(Worker())`), +// and may configure it. +builder start(strategy: string = "one_for_one", max_restarts: int = 3, + within_secs: int = 5): actor_ref[Supervisor] with spec_new { + st = 0 + if strategy == "one_for_all" { + st = 1 + } else if strategy == "rest_for_one" { + st = 2 + } else if strategy != "one_for_one" { + panic("supervise.start: strategy \"${strategy}\" is not one_for_one, one_for_all or rest_for_one") + } + sup = spawn(Supervisor()) + sup.strategy = st + sup.max_restarts = max_restarts + sup.within_secs = within_secs + sup.within_ns = (within_secs as long) * 1000000 * 1000 + if _builder != null { + s = _builder as *SuperviseSpec + i = 0 + while i < s.count { + sup.kids[i] = s.kids[i] + i = i + 1 + } + sup.count = s.count + heap.free(s) + } + // Each child started here, in order: its ref is the supervisor's from + // now on, the same across its restarts. + i = 0 + while i < sup.count { + failed = 0 + aether_actor_capture_begin() + f = sup.kids[i].factory + try { + call(f) + } catch e { + failed = 1 + } + r = aether_actor_capture_end() + if failed == 1 || r == null { + panic("supervise.start: child ${sup.kids[i].name} did not start") + } + sup.kids[i].ref = r + sup.kids[i].phase = 1 + i = i + 1 + } + sup ! SuperviseStart {} + return sup +} + +// In a supervise.start block: a child named `name`, started by `factory`, +// restarted after any end ("permanent"). +child(_ctx: ptr, name: string, factory: @keep fn) { + add_child(_ctx, name, factory, "permanent") +} + +// As child, with its restart kind: "permanent" (restarted after any end), +// "transient" (unless it ended "normal") or "temporary" (never). +child_restart(_ctx: ptr, name: string, factory: @keep fn, restart: string) { + add_child(_ctx, name, factory, restart) +} + +add_child(ctx: ptr, name: string, factory: @keep fn, restart: string) { + if ctx == null { + return + } + s = ctx as *SuperviseSpec + if s.count >= MAX_CHILDREN { + panic("supervise.child: more than ${MAX_CHILDREN} children") + } + k = 0 + if restart == "transient" { + k = 1 + } else if restart == "temporary" { + k = 2 + } else if restart != "permanent" { + panic("supervise.child: restart \"${restart}\" is not permanent, transient or temporary") + } + s.kids[s.count] = SuperviseChild { name: name, factory: factory, restart: k, ref: null, phase: 0 } + s.count = s.count + 1 +} + +// The ref of the child named `name` (the same across its restarts), or null. +which(sup: actor_ref[Supervisor], name: string) -> actor_ref { + i = 0 + at = MAX_CHILDREN + while i < sup.count { + if sup.kids[i].name == name { + at = i + } + i = i + 1 + } + return sup.kids[at].ref +} + +// How many restarts the supervisor has made. +restarts(sup: actor_ref[Supervisor]) -> int { + return sup.total +} + +// Kills the children, newest first, and ends the supervisor normally. +stop(sup: actor_ref) { + sup ! SuperviseStop {} +} diff --git a/std/tcp/README.md b/std/tcp/README.md index bfd49866b..44ae2335e 100644 --- a/std/tcp/README.md +++ b/std/tcp/README.md @@ -89,9 +89,20 @@ between polls. `set_nodelay(sock, true)` turns on `TCP_NODELAY` for request/response lines (the runtime's headers already declare `setsockopt` with the platform's signature, so it cannot be declared from Aether). `set_read_buffer(sock, bytes)` sets the socket's receive buffer -(`SO_RCVBUF`), which also stops the kernel growing it by itself while data -arrives, read or not: a client that has to stop taking bytes when it stops -reading, a test of a server's send timeout for one, sets it small. +(`SO_RCVBUF`), which asks the kernel not to grow it by itself while data +arrives, read or not. On a connected socket the window advertised when the +connection opened still stands, so a client that has to take little while +it does not read (a test of a server's send timeout, say) connects with +`connect_buffered(host, port, bytes)` instead, which sets the buffer before +the connection is made (#3338). The size is a request a kernel may round +up: macOS holds about 300 KB for a 16 KB request on loopback. +`available(sock)` says how many bytes are waiting in the socket's receive +buffer, readable without blocking (`FIONREAD`). +`close_drained(sock)` closes once what the peer already sent is read and +dropped (at most 256 KiB, 5 ms for each more to arrive). A socket closed with +bytes unread ends with a reset, and a peer that gets one may discard what it +had not read yet, such as an alert just sent (#3465). A plain close is +`tcp_close`. ```aether,fragment srv, err = tcp.listen_on("127.0.0.1", 0) @@ -107,6 +118,6 @@ while !stopping { ## Exports -`connect`, `listen`, `listen_on`, `accept`, `read`, `read_n`, `read_into`, +`connect`, `connect_buffered`, `listen`, `listen_on`, `accept`, `available`, `read`, `read_n`, `read_into`, `write`, `write_n`, `write_slice`, `write_all`, `poll`, `poll2`, `server_poll`, `server_port`, `set_nodelay`, -`set_read_buffer`, `fd`, `server_fd`, `tcp_close`, `tcp_server_close`. +`set_read_buffer`, `fd`, `server_fd`, `close_drained`, `tcp_close`, `tcp_server_close`. diff --git a/std/tcp/module.ae b/std/tcp/module.ae index 050e99088..2cc7019a0 100644 --- a/std/tcp/module.ae +++ b/std/tcp/module.ae @@ -5,19 +5,22 @@ // The implementations are in std/net/aether_net.c. exports( - tcp_connect_raw, tcp_send_raw, + tcp_connect_raw, tcp_connect_buffered_raw, tcp_send_raw, tcp_receive_raw, tcp_receive_n_raw, tcp_close, tcp_listen_raw, tcp_listen_on_raw, tcp_accept_raw, tcp_server_close, tcp_server_port_raw, tcp_server_poll_raw, tcp_set_nodelay_raw, tcp_set_read_buffer_raw, + tcp_available_raw, tcp_fd_raw, tcp_server_fd_raw, tcp_poll_raw, tcp_poll2_raw, tcp_send_bytes_raw, tcp_receive_into_raw, - connect, write, write_n, write_slice, write_all, read, read_n, read_into, + connect, connect_buffered, write, write_n, write_slice, write_all, read, read_n, read_into, listen, listen_on, accept, fd, server_fd, - server_port, server_poll, set_nodelay, set_read_buffer, poll, poll2 + server_port, server_poll, set_nodelay, set_read_buffer, available, poll, poll2, + close_drained ) // Raw externs - escape hatch extern tcp_connect_raw(host: string, port: int) -> ptr +extern tcp_connect_buffered_raw(host: string, port: int, read_buffer: int) -> ptr extern tcp_send_raw(sock: ptr, data: string) -> int // `data` is `ptr`, not `string`, and this is not exported (#2301): write_n // passes a byte[] slice's raw data pointer. A `string` parameter routes @@ -41,6 +44,7 @@ extern tcp_server_port_raw(server: ptr) -> int extern tcp_server_poll_raw(server: ptr, timeout_ms: int) -> int extern tcp_set_nodelay_raw(sock: ptr, on: int) -> int extern tcp_set_read_buffer_raw(sock: ptr, bytes: int) -> int +extern tcp_available_raw(sock: ptr) -> int extern tcp_accept_raw(server: ptr) -> ptr extern tcp_server_close(server: ptr) -> int extern tcp_fd_raw(sock: ptr) -> int @@ -58,6 +62,26 @@ connect(host: string, port: int) -> { return sock, "" } +// connect, with the receive buffer (SO_RCVBUF) set to `read_buffer` bytes +// before the connection is made, so the window the connection opens with +// is that size. A client meant to take little while it does not read (a +// test of a server's send timeout) connects this way: set_read_buffer on a +// connected socket shrinks the buffer, but the kernel still takes the +// window it advertised when connecting (#3338). The size is a request: a +// kernel may round it up (macOS holds about 300 KB for a 16 KB request on +// loopback). Returns (socket, "") or (null, error); `read_buffer` must be +// positive. +connect_buffered(host: string, port: int, read_buffer: int) -> { + if read_buffer <= 0 { + return null, "read_buffer must be positive" + } + sock = tcp_connect_buffered_raw(host, port, read_buffer) + if sock == null { + return null, "connect failed" + } + return sock, "" +} + // Write data to a socket. Returns (bytes_sent, "") on success, // (0, error) on failure. // @@ -214,6 +238,34 @@ poll2(a: ptr, b: ptr, timeout_ms: int) -> { return a_ready, b_ready, "" } +// Close `sock` once what the peer has already sent is read and dropped. A +// socket closed with bytes unread ends with a reset, and a peer that gets +// one may discard what it had not read yet: an alert just sent, which the +// peer then sees as a bare close. Bounded: at most 256 KiB, and 5 ms for +// each more to arrive. For the close after an error the peer must still +// read (the pure TLS stack's, #3368); a plain close is tcp.close. +close_drained(sock: ptr) { + if sock == null { + return + } + buf = make([]byte, 16384) + total = 0 + going = 1 + while going == 1 && total < 262144 { + if tcp_poll_raw(sock, 5) != 1 { + going = 0 + } else { + got = tcp_receive_into_raw(sock, buf, 16384) + if got <= 0 { + going = 0 + } else { + total = total + got + } + } + } + tcp_close(sock) +} + // Listen on a port. Returns (server, "") on success, (null, error) on // failure. Caller must tcp.server_close the returned server. listen(port: int) -> { @@ -265,11 +317,12 @@ set_nodelay(sock: ptr, on: bool) -> string { } // SO_RCVBUF: how many bytes the kernel holds for this socket before the -// reader takes them. Setting it also stops the kernel growing the buffer -// by itself while data arrives, which Linux, macOS and Windows do whether -// the program reads or not. A client that must stop taking bytes when it -// stops reading (a test of a server's send timeout, say) sets it small. -// Returns "" on success or an error. +// reader takes them. Setting it asks the kernel not to grow the buffer by +// itself while data arrives, which Linux, macOS and Windows do whether the +// program reads or not. On a connected socket the window advertised at +// connect still stands: a client that must take little while it does not +// read connects with connect_buffered instead. Returns "" on success or +// an error. set_read_buffer(sock: ptr, bytes: int) -> string { if tcp_set_read_buffer_raw(sock, bytes) != 0 { return "set_read_buffer failed" @@ -277,6 +330,12 @@ set_read_buffer(sock: ptr, bytes: int) -> string { return "" } +// How many received bytes are waiting on `sock`, readable without blocking, +// or -1 on error (FIONREAD). +available(sock: ptr) -> int { + return tcp_available_raw(sock) +} + // Accept an incoming connection. Returns (socket, "") on success, // (null, error) on failure or shutdown. accept(server: ptr) -> { diff --git a/std/tcp/test_tcp_buffered.ae b/std/tcp/test_tcp_buffered.ae new file mode 100644 index 000000000..b1fde078e --- /dev/null +++ b/std/tcp/test_tcp_buffered.ae @@ -0,0 +1,70 @@ +// std.tcp: connect_buffered sets the receive buffer before the connection is +// made, and available says how many bytes wait to be read (#3338). +// +// set_read_buffer on a connected socket shrinks the buffer but leaves the +// window the connection opened with, so a client meant to take little while +// it does not read took that window anyway; connect_buffered sizes the +// buffer first. No kernel promises the size (macOS's loopback has queued far +// more), so a reader that needs to know asks available. +// +// Over loopback on an ephemeral port: a window under 1 refused; a buffered +// connection that carries data both ways; available counting what the peer +// sent before any read, then 0 once it is read; and available on no socket. + +import std.tcp +import std.string +import std.time + +extern exit(code: int) + +fn check(cond: bool, msg: string) { + if !cond { + println("FAIL: ${msg}") + exit(1) + } +} + +main() { + srv, lerr = tcp.listen_on("127.0.0.1", 0) + check(lerr == "", "tcp listen_on") + port = tcp.server_port(srv) + + nosock, zerr = tcp.connect_buffered("127.0.0.1", port, 0) + check(nosock == null, "a read buffer of 0 gave a socket") + check(zerr == "read_buffer must be positive", "a read buffer of 0 said '${zerr}'") + _neg, nerr = tcp.connect_buffered("127.0.0.1", port, -5) + check(nerr != "", "a negative read buffer was taken") + + conn, cerr = tcp.connect_buffered("127.0.0.1", port, 16384) + check(cerr == "", "connect_buffered: ${cerr}") + peer, aerr = tcp.accept(srv) + check(aerr == "", "accept: ${aerr}") + + check(tcp.available(conn) == 0, "bytes waiting before the peer sent any") + msg = "eleven byte" + _w, werr = tcp.write(peer, msg) + check(werr == "", "peer write: ${werr}") + // Until all of it has arrived, a second at most. + t0 = time.now_ms() + while tcp.available(conn) < string.length(msg) && time.now_ms() - t0 < 1000 { + _r, _perr = tcp.poll(conn, 10) + } + check(tcp.available(conn) == string.length(msg), + "${tcp.available(conn)} bytes waiting, want ${string.length(msg)}") + got, rerr = tcp.read(conn, 64) + check(rerr == "", "read: ${rerr}") + check(got == msg, "read '${got}'") + check(tcp.available(conn) == 0, "bytes still waiting after reading them all") + + // And the other way, through the small buffer's side. + _w2, werr2 = tcp.write(conn, "back") + check(werr2 == "", "client write: ${werr2}") + back, berr = tcp.read(peer, 64) + check(berr == "" && back == "back", "the peer read '${back}' (${berr})") + + check(tcp.available(null) == -1, "no socket has bytes waiting") + tcp.close(conn) + tcp.close(peer) + tcp.server_close(srv) + println("OK") +} diff --git a/tests/ae_sweep_prune.txt b/tests/ae_sweep_prune.txt index 1b17a437b..5f37d9e20 100644 --- a/tests/ae_sweep_prune.txt +++ b/tests/ae_sweep_prune.txt @@ -11,6 +11,7 @@ /lib/ tests/integration/actor_alignment/ tests/integration/actor_ref_after_release/ +tests/integration/actor_ref_field_reject/ tests/integration/actor_state_base_member_reject/ tests/integration/ae_inspect/ tests/integration/ae_add_hash_pin/ @@ -87,6 +88,25 @@ tests/integration/emit_csrc_cross/ tests/integration/emit_lib_clause_sets/ tests/integration/emit_lib_const/ tests/integration/emit_lib_deadline/ +# #2956: work.ae is a library with no main; the driver builds it and its C host +# calls it under a deadline and a cancel from another thread. +tests/integration/emit_lib_interrupt/ +# #2956: alloc.ae is a library with no main; the driver builds it and its C host +# calls it past a memory cap and with allocations made to fail. +tests/integration/emit_lib_alloc_failure/ +# #3377: kinds.ae is a library and outside.ae a program the driver runs with +# allocations made to fail; the driver builds both. +tests/integration/emit_lib_generated_alloc/ +# #2954: accts.ae is a library with no main, and app.ae imports it in binary +# form; the driver builds them with C hosts in every runtime form. +tests/integration/emit_lib_accounts/ +# #3392: steps.ae is a library with no main; the driver builds it into a C +# host, on threads and (make ci-coop) on the cooperative scheduler. +tests/integration/emit_lib_actor_step_context/ +# #3388: results.ae and words.ae are libraries with no main, and app.ae +# imports words.ae in binary form; the driver builds them and fails an +# allocation in each call. +tests/integration/emit_lib_failed_result/ tests/integration/emit_lib_keeps_main/ tests/integration/emit_lib_net/ tests/integration/emit_lib_typed_ptr/ @@ -167,6 +187,8 @@ tests/integration/http_server_sse/ tests/integration/https_pure_tls_vertical/ tests/integration/https_client_pure_tls/ tests/integration/https_windows_trust/ +tests/integration/https_partial_chain/ +tests/integration/https_pure_server_keys/ tests/integration/http_server_tls/ tests/integration/http_server_websocket/ tests/integration/http_ipv6/ @@ -207,6 +229,8 @@ tests/integration/os_spawn_stdio_slot/ tests/integration/render_rule/ tests/integration/panic_actor_step_drain/ tests/integration/panic_categories/ +# #2955, #2956: catches.ae is built by the driver, which reads what it links. +tests/integration/panic_link_surface/ tests/integration/panic_stack_trace/ tests/integration/panic_unwind_no_leak/ tests/integration/ptr_index_diagnostic/ @@ -248,6 +272,8 @@ tests/integration/transitive_module_import/ tests/integration/ufcs_cross_module/ tests/integration/uncalled_module_fn_checked/ tests/integration/varargs_forward/ +# #3375: counter.ae is built by the driver, which runs it with and without AETHER_VERBOSE. +tests/integration/verbose_env/ tests/integration/wasi_panic_link/ tests/integration/wasm_emit_lib_exports/ tests/integration/when_in_imported_module/ @@ -284,3 +310,40 @@ tests/integration/extern_fnptr_bare_function/ tests/integration/local_named_after_called_function/ # #3113: reject_*.ae must not compile and panic_shadowed.ae must panic; the driver checks each. tests/integration/array_index_checked/ +# #1298, #3311, #3326: the TLS peers, servers and probes take certificates, +# ports and a shim from their drivers, which run them against each other. +tests/integration/crypto_ct_valgrind/ +tests/integration/crypto_tls13_peer/ +tests/integration/https_openssl_policy/ +tests/integration/https_tls_backend_select/ +# #2955: inner.ae and outer.ae are libraries its C host loads one inside the other. +tests/integration/sandbox_nested_libraries/ +# #3336, #3352: resumepeer.ae is the scripted PSK/0-RTT server resumeclient.ae +# connects to; the driver runs them together and against openssl s_server. +tests/integration/crypto_tls13_resume/ +# #2956: frames.ae is a library and app.ae its importer; the driver builds both +# and its C host loads the library. +tests/integration/emit_lib_execution_frame/ +# #3362, #3370: server.ae and client.ae take certificates and ports from their driver. +tests/integration/https_server_tls_pem/ +# #3371: reload.ae takes its certificates from its driver. +tests/integration/https_tls_reload/ +# #3293: rngfail.ae (with rng_fail_shim.c) and listener.ae dial each other; test_random_failure.sh pairs them. +tests/integration/crypto_random_failure/ +# #3376: mtls.ae takes its certificate, key and server port from its driver. +tests/integration/crypto_tls13_mtls_signature/ +# #3382: probe.ae links probe_shim.c (aether.toml); test_key_wipe.sh builds and runs it. +tests/integration/crypto_key_wipe/ +# #3398: probe.ae links shim.c (aether.toml); test_pointer_store_published.sh builds and runs it. +tests/integration/pointer_store_published/ +# #3413: probe.ae links handle.c and force-includes handle.h (aether.toml); test_owned_drop_signatures.sh builds and runs it. +tests/integration/owned_drop_signatures/ +# #2957: app.ae imports a hand-made catalog (fake_catalog.c) and abi_lib.ae is a +# library with no main; test_abi_policy.sh builds both against each boundary. +tests/integration/abi_policy/ +# #2957: mlib.ae is a library with no main and app.ae its importer; test_abi_matrix.sh +# builds them with this Aether and with the release in AETHER_ABI_OLD_HOME. +tests/integration/abi_matrix/ +# #3419: plain.ae links shim.c and cross.ae is a library for cross_host.c; +# test_step_word.sh builds and runs both. +tests/integration/step_word/ diff --git a/tests/compiler/test_slices.c b/tests/compiler/test_slices.c index 86cc6212f..c14544583 100644 --- a/tests/compiler/test_slices.c +++ b/tests/compiler/test_slices.c @@ -214,14 +214,16 @@ TEST(slices_typechecker_wraps_array_argument_and_return) { } TEST(slices_typechecker_slice_decays_for_extern_ptr_null_and_arithmetic) { + /* Not `free`: a slice given to `free` is the builtin release whatever + * extern `free` is declared (#3450), and `arr[..]` a view it refuses. */ ASTNode* ast = parse_source( - "extern free(p: ptr)\n" + "extern c_use(p: ptr)\n" "extern take(p: int[])\n" "main() { arr = [1, 2, 3]\n v = arr[..]\n" - " take(v)\n z = v == null\n q = v + 1\n free(v) }"); /* free last: a use after it is E0903 (#1300) */ + " take(v)\n z = v == null\n q = v + 1\n c_use(v) }"); ASSERT_NOT_NULL(ast); ASSERT_EQ(1, typecheck_program(ast)); - ASTNode* fr = find_call(ast, "free"); + ASTNode* fr = find_call(ast, "c_use"); ASSERT_NOT_NULL(fr); ASSERT_EQ(AST_SLICE_TO_PTR, fr->children[0]->type); ASTNode* tk = find_call(ast, "take"); @@ -282,9 +284,14 @@ TEST(slices_codegen_lowers_to_fat_pointer_helpers) { * mark that makes a `free` of it a named panic. */ ASSERT_NOT_NULL(strstr(c, "sum(aether_slice_borrowed(aether_slice_make((void*)(arr), 3)))")); ASSERT_NOT_NULL(strstr(c, "aether_slice_sub(aether_slice_borrowed(aether_slice_make((void*)(arr), 3)), (int64_t)(1), (int64_t)(3), sizeof(int)")); - /* #2369: Aether's malloc is a zeroing allocation */ - ASSERT_NOT_NULL(strstr(c, "AetherSlice view = aether_slice_view((void*)(calloc(1, (size_t)(8))))")); - ASSERT_NOT_NULL(strstr(c, "free((void*)((int*)(view).ptr))")); + /* #2369: Aether's malloc is a zeroing allocation, made by the checked + * helper that still returns NULL when out of memory (#3377). */ + ASSERT_NOT_NULL(strstr(c, "AetherSlice view = aether_slice_view((void*)(_aether_alloc_or_null((size_t)(8))))")); + /* #3450: the builtin release, the program's `extern free` aside: the + * view of what malloc gave, taken from its place so the scope releases + * nothing after it. */ + ASSERT_NOT_NULL(strstr(c, "aether_slice_free(aether_slice_take((AetherSlice*)&(view))")); + ASSERT_NULL(strstr(c, "free((void*)((int*)(view).ptr))")); /* the extern keeps C's shape; the call site wraps the result */ ASSERT_NOT_NULL(strstr(c, "int* raw_ints(")); ASSERT_NOT_NULL(strstr(c, "aether_slice_view((void*)(raw_ints()))")); @@ -292,6 +299,29 @@ TEST(slices_codegen_lowers_to_fat_pointer_helpers) { free_ast_node(ast); } +/* #3450: `extern free(p: ptr)` in the program turned `free(xs)` of a `make` + * buffer into C's free of its pointer, while slice ownership still released + * the buffer at scope exit: a double free (ML-KEM's matrix, in a program + * that declared it). The slice stays a slice, and the call is the builtin + * release, which takes it from its place. */ +TEST(slices_free_of_a_buffer_is_the_release_under_extern_free) { + ASTNode* ast = parse_source( + "extern free(p: ptr)\n" + "f(n: int) -> int {\n xs = make([]int, n)\n xs[0] = 3\n v = xs[0]\n free(xs)\n return v\n}\n" + "main() { r = f(4)\n println(r) }"); + ASSERT_NOT_NULL(ast); + ASSERT_EQ(1, typecheck_program(ast)); + ASTNode* fr = find_call(ast, "free"); + ASSERT_NOT_NULL(fr); + /* Not decayed to its pointer (slice ownership's take wraps it). */ + ASSERT_TRUE(fr->children[0]->type != AST_SLICE_TO_PTR); + char* c = generate_c(ast); + ASSERT_NOT_NULL(strstr(c, "aether_slice_free(aether_slice_take((AetherSlice*)&(xs))")); + ASSERT_NULL(strstr(c, "free((void*)((int*)(xs).ptr))")); + free(c); + free_ast_node(ast); +} + TEST(slices_codegen_extern_param_and_aether_struct_field) { ASTNode* ast = parse_source( "extern take(p: byte[], n: int)\n" diff --git a/tests/conformance/bogo/config.json b/tests/conformance/bogo/config.json new file mode 100644 index 000000000..d1186a817 --- /dev/null +++ b/tests/conformance/bogo/config.json @@ -0,0 +1,262 @@ +{ + "DisabledTests": { + "CheckRecordVersion-TLS13": "by design: RFC 8446 5.1 has legacy_record_version ignored for all purposes, and the pure stack ignores it", + "GarbageInitialRecordVersion-TLS13": "by design: RFC 8446 5.1 has legacy_record_version ignored for all purposes, and the pure stack ignores it", + "EMS-Forbidden-TLS13": "by design: TLS 1.3 only, the client offers no extended_master_secret, so the server never echoes one", + "RenegotiationInfo-Forbidden-TLS13": "by design: TLS 1.3 only, the client offers no renegotiation_info, so the server never echoes one", + "SendUnsolicitedOCSPOnCertificate-TLS13": "by design: the client always offers status_request, so a stapled OCSP response is solicited", + "ExtensionTrailingData-TrustAnchors-ClientHello-Server-TLS-TLS13": "by design: no trust anchor IDs (draft-ietf-tls-trust-anchor-ids); RFC 8446 4.2 has an unknown extension ignored", + "TrustAnchors-Empty-ClientHello": "by design: no trust anchor IDs (draft-ietf-tls-trust-anchor-ids); RFC 8446 4.2 has an unknown extension ignored", + "TrustAnchors-TooLong-ClientHello": "by design: no trust anchor IDs (draft-ietf-tls-trust-anchor-ids); RFC 8446 4.2 has an unknown extension ignored", + "TrustAnchors-NonMinimalComponent-ClientHello": "by design: no trust anchor IDs (draft-ietf-tls-trust-anchor-ids); RFC 8446 4.2 has an unknown extension ignored", + "TrustAnchors-TruncatedComponent-ClientHello": "by design: no trust anchor IDs (draft-ietf-tls-trust-anchor-ids); RFC 8446 4.2 has an unknown extension ignored", + "DuplicateCertCompressionExt-TLS13": "by design: no certificate compression (RFC 8879); RFC 8446 4.2 has an unknown extension ignored", + "TLS-ECH-Server-ECHInner-NotEmpty": "by design: no Encrypted Client Hello; RFC 8446 4.2 has an unknown extension ignored", + "PAKE-Extension-Duplicate": "by design: no PAKE extension (draft-bmw-tls-pake13); RFC 8446 4.2 has an unknown extension ignored", + "ServerCertificateType-Server-RejectsDefaultOnly-TLS13": "by design: no raw public keys (RFC 7250 server_certificate_type); RFC 8446 4.2 has an unknown extension ignored", + "UnencryptedEncryptedExtensions": "by design: RFC 8446 5 has a record of a type not expected there refused with unexpected_message, as OpenSSL does; BoringSSL tries to decrypt it and sends bad_record_mac", + "EncryptedExtensionsWithKeyShare-TLS13": "by design: RFC 8446 4.2 has a known extension in a message that may not carry it refused with illegal_parameter; BoringSSL sends unsupported_extension", + "Ed25519DefaultDisable-NoAdvertise": "by design: the client offers Ed25519 (RFC 8446 4.2.3); BoringSSL leaves it out by default", + "Ed25519DefaultDisable-NoAccept": "by design: the client offers Ed25519 (RFC 8446 4.2.3); BoringSSL leaves it out by default", + "UnofferedExtension-Client-TLS13": "by design: the client always offers ALPN http/1.1 (RFC 7301), so the server's ALPN answers it; a protocol it did not offer is illegal_parameter, as BoringSSL sends then", + "UnsolicitedServerNameAck-TLS-TLS13": "by design: the client sends as server_name the name it verifies the certificate for (RFC 6066 3; the shim verifies test), so the server's acknowledgement answers it", + "CheckECDSACurve-NotApplicable-TLS13": "by design: the server's key exchange groups are X25519MLKEM768, X25519 and secp256r1 (RFC 8446 9.1); a client offering only secp384r1 shares none with it", + "AlternateEmptyRecordsAndWarningAlerts": "TLS 1.3 only: the pure stack implements no earlier version", + "Basic-Server-ECDHE-ECDSA-TLS-Async": "TLS 1.3 only: the pure stack implements no earlier version", + "Basic-Server-ECDHE-ECDSA-TLS-Async-ImplicitHandshake": "TLS 1.3 only: the pure stack implements no earlier version", + "Basic-Server-ECDHE-ECDSA-TLS-Async-PackHandshake": "TLS 1.3 only: the pure stack implements no earlier version", + "Basic-Server-ECDHE-ECDSA-TLS-Async-SplitHandshakeRecords": "TLS 1.3 only: the pure stack implements no earlier version", + "Basic-Server-ECDHE-ECDSA-TLS-Sync": "TLS 1.3 only: the pure stack implements no earlier version", + "Basic-Server-ECDHE-ECDSA-TLS-Sync-ImplicitHandshake": "TLS 1.3 only: the pure stack implements no earlier version", + "Basic-Server-ECDHE-ECDSA-TLS-Sync-PackHandshake": "TLS 1.3 only: the pure stack implements no earlier version", + "Basic-Server-ECDHE-ECDSA-TLS-Sync-SplitHandshakeRecords": "TLS 1.3 only: the pure stack implements no earlier version", + "Basic-Server-ECDHE-RSA-TLS-Async": "TLS 1.3 only: the pure stack implements no earlier version", + "Basic-Server-ECDHE-RSA-TLS-Async-ImplicitHandshake": "TLS 1.3 only: the pure stack implements no earlier version", + "Basic-Server-ECDHE-RSA-TLS-Async-PackHandshake": "TLS 1.3 only: the pure stack implements no earlier version", + "Basic-Server-ECDHE-RSA-TLS-Async-SplitHandshakeRecords": "TLS 1.3 only: the pure stack implements no earlier version", + "Basic-Server-ECDHE-RSA-TLS-Sync": "TLS 1.3 only: the pure stack implements no earlier version", + "Basic-Server-ECDHE-RSA-TLS-Sync-ImplicitHandshake": "TLS 1.3 only: the pure stack implements no earlier version", + "Basic-Server-ECDHE-RSA-TLS-Sync-PackHandshake": "TLS 1.3 only: the pure stack implements no earlier version", + "Basic-Server-ECDHE-RSA-TLS-Sync-SplitHandshakeRecords": "TLS 1.3 only: the pure stack implements no earlier version", + "Basic-Server-RSA-TLS-Async": "TLS 1.3 only: the pure stack implements no earlier version", + "Basic-Server-RSA-TLS-Async-ImplicitHandshake": "TLS 1.3 only: the pure stack implements no earlier version", + "Basic-Server-RSA-TLS-Async-PackHandshake": "TLS 1.3 only: the pure stack implements no earlier version", + "Basic-Server-RSA-TLS-Async-SplitHandshakeRecords": "TLS 1.3 only: the pure stack implements no earlier version", + "Basic-Server-RSA-TLS-Sync": "TLS 1.3 only: the pure stack implements no earlier version", + "Basic-Server-RSA-TLS-Sync-ImplicitHandshake": "TLS 1.3 only: the pure stack implements no earlier version", + "Basic-Server-RSA-TLS-Sync-PackHandshake": "TLS 1.3 only: the pure stack implements no earlier version", + "Basic-Server-RSA-TLS-Sync-SplitHandshakeRecords": "TLS 1.3 only: the pure stack implements no earlier version", + "Client-RejectJDK11DowngradeRandom": "TLS 1.3 only: the pure stack implements no earlier version", + "Client-TooLongSessionID": "TLS 1.3 only: the pure stack implements no earlier version", + "ClientAuth-NoCertificate-Client-TLS-Async": "TLS 1.3 only: the pure stack implements no earlier version", + "ClientAuth-NoCertificate-Client-TLS-Async-ImplicitHandshake": "TLS 1.3 only: the pure stack implements no earlier version", + "ClientAuth-NoCertificate-Client-TLS-Async-PackHandshake": "TLS 1.3 only: the pure stack implements no earlier version", + "ClientAuth-NoCertificate-Client-TLS-Async-SplitHandshakeRecords": "TLS 1.3 only: the pure stack implements no earlier version", + "ClientAuth-NoCertificate-Client-TLS-Sync": "TLS 1.3 only: the pure stack implements no earlier version", + "ClientAuth-NoCertificate-Client-TLS-Sync-ImplicitHandshake": "TLS 1.3 only: the pure stack implements no earlier version", + "ClientAuth-NoCertificate-Client-TLS-Sync-PackHandshake": "TLS 1.3 only: the pure stack implements no earlier version", + "ClientAuth-NoCertificate-Client-TLS-Sync-SplitHandshakeRecords": "TLS 1.3 only: the pure stack implements no earlier version", + "ClientHelloVersionTooHigh": "TLS 1.3 only: the pure stack implements no earlier version", + "ConflictingVersionNegotiation": "TLS 1.3 only: the pure stack implements no earlier version", + "ConflictingVersionNegotiation-2": "TLS 1.3 only: the pure stack implements no earlier version", + "Downgrade-TLS1-Client-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "Downgrade-TLS1-Server-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "Downgrade-TLS11-Client-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "Downgrade-TLS11-Server-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "Downgrade-TLS12-Client-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "Downgrade-TLS12-Server-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "DuplicateExtensionClient-TLS-TLS1": "TLS 1.3 only: the pure stack implements no earlier version", + "DuplicateExtensionClient-TLS-TLS11": "TLS 1.3 only: the pure stack implements no earlier version", + "DuplicateExtensionClient-TLS-TLS12": "TLS 1.3 only: the pure stack implements no earlier version", + "ECDSACurveMismatch-Verify-TLS12": "TLS 1.3 only: the pure stack implements no earlier version", + "EchoTLS13CompatibilitySessionID": "TLS 1.3 only: the pure stack implements no earlier version", + "EmptyExtensions-ClientHello-TLS1": "TLS 1.3 only: the pure stack implements no earlier version", + "EmptyExtensions-ClientHello-TLS11": "TLS 1.3 only: the pure stack implements no earlier version", + "EmptyExtensions-ClientHello-TLS12": "TLS 1.3 only: the pure stack implements no earlier version", + "EmptyExtensions-ServerHello-TLS1": "TLS 1.3 only: the pure stack implements no earlier version", + "EmptyExtensions-ServerHello-TLS11": "TLS 1.3 only: the pure stack implements no earlier version", + "EmptyExtensions-ServerHello-TLS12": "TLS 1.3 only: the pure stack implements no earlier version", + "ExpectOmitExtensions": "TLS 1.3 only: the pure stack implements no earlier version", + "ExtraCompressionMethods-TLS12": "TLS 1.3 only: the pure stack implements no earlier version", + "FallbackSCSV": "TLS 1.3 only: the pure stack implements no earlier version", + "InvalidCompressionMethod": "TLS 1.3 only: the pure stack implements no earlier version", + "LooseInitialRecordVersion-TLS1": "TLS 1.3 only: the pure stack implements no earlier version", + "LooseInitialRecordVersion-TLS11": "TLS 1.3 only: the pure stack implements no earlier version", + "LooseInitialRecordVersion-TLS12": "TLS 1.3 only: the pure stack implements no earlier version", + "MajorVersionTolerance": "TLS 1.3 only: the pure stack implements no earlier version", + "MaxCBCPadding": "TLS 1.3 only: the pure stack implements no earlier version", + "MinimumVersion-Client-TLS1-TLS1-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "MinimumVersion-Client-TLS1-TLS11-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "MinimumVersion-Client-TLS1-TLS12-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "MinimumVersion-Client2-TLS1-TLS1-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "MinimumVersion-Client2-TLS1-TLS11-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "MinimumVersion-Client2-TLS1-TLS12-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "MinimumVersion-Client2-TLS11-TLS1-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "MinimumVersion-Client2-TLS11-TLS11-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "MinimumVersion-Client2-TLS11-TLS12-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "MinimumVersion-Client2-TLS12-TLS1-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "MinimumVersion-Client2-TLS12-TLS11-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "MinimumVersion-Client2-TLS12-TLS12-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "MinimumVersion-Client2-TLS13-TLS1-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "MinimumVersion-Client2-TLS13-TLS11-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "MinimumVersion-Client2-TLS13-TLS12-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "MinimumVersion-Server-TLS1-TLS1-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "MinimumVersion-Server-TLS1-TLS11-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "MinimumVersion-Server-TLS1-TLS12-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "MinimumVersion-Server2-TLS1-TLS1-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "MinimumVersion-Server2-TLS1-TLS11-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "MinimumVersion-Server2-TLS1-TLS12-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "MinimumVersion-Server2-TLS11-TLS11-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "MinimumVersion-Server2-TLS11-TLS12-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "MinimumVersion-Server2-TLS12-TLS12-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "MinorVersionTolerance": "TLS 1.3 only: the pure stack implements no earlier version", + "NoClientCertificate-TLS1": "TLS 1.3 only: the pure stack implements no earlier version", + "NoClientCertificate-TLS11": "TLS 1.3 only: the pure stack implements no earlier version", + "NoClientCertificate-TLS12": "TLS 1.3 only: the pure stack implements no earlier version", + "NoCommonCurves": "TLS 1.3 only: the pure stack implements no earlier version", + "NoCommonSignatureAlgorithms-TLS12-Fallback": "TLS 1.3 only: the pure stack implements no earlier version", + "NoExtendedMasterSecret-TLS1-Client": "TLS 1.3 only: the pure stack implements no earlier version", + "NoExtendedMasterSecret-TLS1-Server": "TLS 1.3 only: the pure stack implements no earlier version", + "NoExtendedMasterSecret-TLS11-Client": "TLS 1.3 only: the pure stack implements no earlier version", + "NoExtendedMasterSecret-TLS11-Server": "TLS 1.3 only: the pure stack implements no earlier version", + "NoExtendedMasterSecret-TLS12-Client": "TLS 1.3 only: the pure stack implements no earlier version", + "NoExtendedMasterSecret-TLS12-Server": "TLS 1.3 only: the pure stack implements no earlier version", + "NoSSL3-Client-Unsolicited": "TLS 1.3 only: the pure stack implements no earlier version", + "OmitExtensions-ClientHello-TLS1": "TLS 1.3 only: the pure stack implements no earlier version", + "OmitExtensions-ClientHello-TLS11": "TLS 1.3 only: the pure stack implements no earlier version", + "OmitExtensions-ClientHello-TLS12": "TLS 1.3 only: the pure stack implements no earlier version", + "OmitExtensions-ServerHello-TLS1": "TLS 1.3 only: the pure stack implements no earlier version", + "OmitExtensions-ServerHello-TLS11": "TLS 1.3 only: the pure stack implements no earlier version", + "OmitExtensions-ServerHello-TLS12": "TLS 1.3 only: the pure stack implements no earlier version", + "PartialFinishedWithServerHelloDone": "TLS 1.3 only: the pure stack implements no earlier version", + "PartialNewSessionTicketWithServerHelloDone": "TLS 1.3 only: the pure stack implements no earlier version", + "PointFormat-Client-Missing": "TLS 1.3 only: the pure stack implements no earlier version", + "PointFormat-Client-Tolerance": "TLS 1.3 only: the pure stack implements no earlier version", + "PointFormat-Server-Missing": "TLS 1.3 only: the pure stack implements no earlier version", + "PointFormat-Server-Tolerance": "TLS 1.3 only: the pure stack implements no earlier version", + "PointFormat-ServerHello-TLS12": "TLS 1.3 only: the pure stack implements no earlier version", + "RSAKeyUsage-Client-WantEncipherment-GotEnciphermentTLS1": "TLS 1.3 only: the pure stack implements no earlier version", + "RSAKeyUsage-Client-WantEncipherment-GotEnciphermentTLS11": "TLS 1.3 only: the pure stack implements no earlier version", + "RSAKeyUsage-Client-WantEncipherment-GotEnciphermentTLS12": "TLS 1.3 only: the pure stack implements no earlier version", + "RSAKeyUsage-Client-WantSignature-GotSignature-TLS1": "TLS 1.3 only: the pure stack implements no earlier version", + "RSAKeyUsage-Client-WantSignature-GotSignature-TLS11": "TLS 1.3 only: the pure stack implements no earlier version", + "RSAKeyUsage-Client-WantSignature-GotSignature-TLS12": "TLS 1.3 only: the pure stack implements no earlier version", + "Renegotiate-Client-Forbidden-1": "TLS 1.3 only: the pure stack implements no earlier version", + "Renegotiate-Server-Forbidden": "TLS 1.3 only: the pure stack implements no earlier version", + "Renegotiate-Server-NoExt": "TLS 1.3 only: the pure stack implements no earlier version", + "Renegotiate-Server-NoExt-SCSV": "TLS 1.3 only: the pure stack implements no earlier version", + "SendSNIWarningAlert": "TLS 1.3 only: the pure stack implements no earlier version", + "SendWarningAlerts-Pass": "TLS 1.3 only: the pure stack implements no earlier version", + "Server-Sign-RSA-TLS1": "TLS 1.3 only: the pure stack implements no earlier version", + "Server-Sign-RSA-TLS11": "TLS 1.3 only: the pure stack implements no earlier version", + "Server-SignDefault-Ed25519-TLS12": "TLS 1.3 only: the pure stack implements no earlier version", + "Server-SignDefault-RSA-TLS1": "TLS 1.3 only: the pure stack implements no earlier version", + "Server-SignDefault-RSA-TLS11": "TLS 1.3 only: the pure stack implements no earlier version", + "Server-SignDefault-RSA_PKCS1_SHA1-TLS12": "TLS 1.3 only: the pure stack implements no earlier version", + "Server-SignDefault-RSA_PKCS1_SHA256-TLS12": "TLS 1.3 only: the pure stack implements no earlier version", + "Server-SignDefault-RSA_PKCS1_SHA256_LEGACY-TLS12": "TLS 1.3 only: the pure stack implements no earlier version", + "Server-SignDefault-RSA_PKCS1_SHA384-TLS12": "TLS 1.3 only: the pure stack implements no earlier version", + "Server-SignDefault-RSA_PKCS1_SHA512-TLS12": "TLS 1.3 only: the pure stack implements no earlier version", + "Server-SignDefault-RSA_PSS_SHA256-TLS12": "TLS 1.3 only: the pure stack implements no earlier version", + "Server-SignDefault-RSA_PSS_SHA384-TLS12": "TLS 1.3 only: the pure stack implements no earlier version", + "Server-SignDefault-RSA_PSS_SHA512-TLS12": "TLS 1.3 only: the pure stack implements no earlier version", + "ServerAuth-SHA1-Fallback-ECDSA": "TLS 1.3 only: the pure stack implements no earlier version", + "ServerAuth-SHA1-Fallback-RSA": "TLS 1.3 only: the pure stack implements no earlier version", + "ServerAuth-SignatureType": "TLS 1.3 only: the pure stack implements no earlier version", + "ServerNameExtensionClientMissing-TLS-TLS1": "TLS 1.3 only: the pure stack implements no earlier version", + "ServerNameExtensionClientMissing-TLS-TLS11": "TLS 1.3 only: the pure stack implements no earlier version", + "ServerNameExtensionClientMissing-TLS-TLS12": "TLS 1.3 only: the pure stack implements no earlier version", + "SessionTicketsDisabled-Client-TLS-Async": "TLS 1.3 only: the pure stack implements no earlier version", + "SessionTicketsDisabled-Client-TLS-Async-ImplicitHandshake": "TLS 1.3 only: the pure stack implements no earlier version", + "SessionTicketsDisabled-Client-TLS-Async-PackHandshake": "TLS 1.3 only: the pure stack implements no earlier version", + "SessionTicketsDisabled-Client-TLS-Async-SplitHandshakeRecords": "TLS 1.3 only: the pure stack implements no earlier version", + "SessionTicketsDisabled-Client-TLS-Sync": "TLS 1.3 only: the pure stack implements no earlier version", + "SessionTicketsDisabled-Client-TLS-Sync-ImplicitHandshake": "TLS 1.3 only: the pure stack implements no earlier version", + "SessionTicketsDisabled-Client-TLS-Sync-PackHandshake": "TLS 1.3 only: the pure stack implements no earlier version", + "SessionTicketsDisabled-Client-TLS-Sync-SplitHandshakeRecords": "TLS 1.3 only: the pure stack implements no earlier version", + "SessionTicketsDisabled-Server-TLS-Async": "TLS 1.3 only: the pure stack implements no earlier version", + "SessionTicketsDisabled-Server-TLS-Async-ImplicitHandshake": "TLS 1.3 only: the pure stack implements no earlier version", + "SessionTicketsDisabled-Server-TLS-Async-PackHandshake": "TLS 1.3 only: the pure stack implements no earlier version", + "SessionTicketsDisabled-Server-TLS-Async-SplitHandshakeRecords": "TLS 1.3 only: the pure stack implements no earlier version", + "SessionTicketsDisabled-Server-TLS-Sync": "TLS 1.3 only: the pure stack implements no earlier version", + "SessionTicketsDisabled-Server-TLS-Sync-ImplicitHandshake": "TLS 1.3 only: the pure stack implements no earlier version", + "SessionTicketsDisabled-Server-TLS-Sync-PackHandshake": "TLS 1.3 only: the pure stack implements no earlier version", + "SessionTicketsDisabled-Server-TLS-Sync-SplitHandshakeRecords": "TLS 1.3 only: the pure stack implements no earlier version", + "SupportedCurves-ServerHello-TLS12": "TLS 1.3 only: the pure stack implements no earlier version", + "TLS-ECH-Server-ECHInner-Absent-TLS12": "TLS 1.3 only: the pure stack implements no earlier version", + "TLS-TLS1-ECDHE_ECDSA_WITH_AES_128_CBC_SHA-LargeRecord": "TLS 1.3 only: the pure stack implements no earlier version", + "TLS-TLS1-ECDHE_ECDSA_WITH_AES_256_CBC_SHA-LargeRecord": "TLS 1.3 only: the pure stack implements no earlier version", + "TLS-TLS1-ECDHE_RSA_WITH_AES_128_CBC_SHA-LargeRecord": "TLS 1.3 only: the pure stack implements no earlier version", + "TLS-TLS1-ECDHE_RSA_WITH_AES_256_CBC_SHA-LargeRecord": "TLS 1.3 only: the pure stack implements no earlier version", + "TLS-TLS1-RSA_WITH_AES_128_CBC_SHA-LargeRecord": "TLS 1.3 only: the pure stack implements no earlier version", + "TLS-TLS1-RSA_WITH_AES_256_CBC_SHA-LargeRecord": "TLS 1.3 only: the pure stack implements no earlier version", + "TLS-TLS11-ECDHE_ECDSA_WITH_AES_128_CBC_SHA-LargeRecord": "TLS 1.3 only: the pure stack implements no earlier version", + "TLS-TLS11-ECDHE_ECDSA_WITH_AES_256_CBC_SHA-LargeRecord": "TLS 1.3 only: the pure stack implements no earlier version", + "TLS-TLS11-ECDHE_RSA_WITH_AES_128_CBC_SHA-LargeRecord": "TLS 1.3 only: the pure stack implements no earlier version", + "TLS-TLS11-ECDHE_RSA_WITH_AES_256_CBC_SHA-LargeRecord": "TLS 1.3 only: the pure stack implements no earlier version", + "TLS-TLS11-RSA_WITH_AES_128_CBC_SHA-LargeRecord": "TLS 1.3 only: the pure stack implements no earlier version", + "TLS-TLS11-RSA_WITH_AES_256_CBC_SHA-LargeRecord": "TLS 1.3 only: the pure stack implements no earlier version", + "TLS-TLS12-ECDHE_ECDSA_WITH_AES_128_CBC_SHA-LargeRecord": "TLS 1.3 only: the pure stack implements no earlier version", + "TLS-TLS12-ECDHE_ECDSA_WITH_AES_128_CBC_SHA256-LargeRecord": "TLS 1.3 only: the pure stack implements no earlier version", + "TLS-TLS12-ECDHE_ECDSA_WITH_AES_128_GCM_SHA256-LargeRecord": "TLS 1.3 only: the pure stack implements no earlier version", + "TLS-TLS12-ECDHE_ECDSA_WITH_AES_256_CBC_SHA-LargeRecord": "TLS 1.3 only: the pure stack implements no earlier version", + "TLS-TLS12-ECDHE_ECDSA_WITH_AES_256_GCM_SHA384-LargeRecord": "TLS 1.3 only: the pure stack implements no earlier version", + "TLS-TLS12-ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256-LargeRecord": "TLS 1.3 only: the pure stack implements no earlier version", + "TLS-TLS12-ECDHE_RSA_WITH_AES_128_CBC_SHA-LargeRecord": "TLS 1.3 only: the pure stack implements no earlier version", + "TLS-TLS12-ECDHE_RSA_WITH_AES_128_CBC_SHA256-LargeRecord": "TLS 1.3 only: the pure stack implements no earlier version", + "TLS-TLS12-ECDHE_RSA_WITH_AES_128_GCM_SHA256-LargeRecord": "TLS 1.3 only: the pure stack implements no earlier version", + "TLS-TLS12-ECDHE_RSA_WITH_AES_256_CBC_SHA-LargeRecord": "TLS 1.3 only: the pure stack implements no earlier version", + "TLS-TLS12-ECDHE_RSA_WITH_AES_256_GCM_SHA384-LargeRecord": "TLS 1.3 only: the pure stack implements no earlier version", + "TLS-TLS12-ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256-LargeRecord": "TLS 1.3 only: the pure stack implements no earlier version", + "TLS-TLS12-RSA_WITH_AES_128_CBC_SHA-LargeRecord": "TLS 1.3 only: the pure stack implements no earlier version", + "TLS-TLS12-RSA_WITH_AES_128_GCM_SHA256-LargeRecord": "TLS 1.3 only: the pure stack implements no earlier version", + "TLS-TLS12-RSA_WITH_AES_256_CBC_SHA-LargeRecord": "TLS 1.3 only: the pure stack implements no earlier version", + "TLS-TLS12-RSA_WITH_AES_256_GCM_SHA384-LargeRecord": "TLS 1.3 only: the pure stack implements no earlier version", + "TrailingDataWithFinished-Client-TLS1": "TLS 1.3 only: the pure stack implements no earlier version", + "TrailingDataWithFinished-Client-TLS11": "TLS 1.3 only: the pure stack implements no earlier version", + "TrailingDataWithFinished-Client-TLS12": "TLS 1.3 only: the pure stack implements no earlier version", + "TrailingDataWithFinished-Server-TLS1": "TLS 1.3 only: the pure stack implements no earlier version", + "TrailingDataWithFinished-Server-TLS11": "TLS 1.3 only: the pure stack implements no earlier version", + "TrailingDataWithFinished-Server-TLS12": "TLS 1.3 only: the pure stack implements no earlier version", + "TrailingMessageData-CertificateRequest-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "TrailingMessageData-ClientFinished-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "TrailingMessageData-ClientKeyExchange-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "TrailingMessageData-NewSessionTicket-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "TrailingMessageData-ServerCertificate-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "TrailingMessageData-ServerFinished-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "TrailingMessageData-ServerHello-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "TrailingMessageData-ServerHelloDone-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "TrailingMessageData-ServerKeyExchange-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "UnknownCipher": "TLS 1.3 only: the pure stack implements no earlier version", + "UnknownCurve": "TLS 1.3 only: the pure stack implements no earlier version", + "UnknownExtension-Client": "TLS 1.3 only: the pure stack implements no earlier version", + "UnofferedExtension-Client": "TLS 1.3 only: the pure stack implements no earlier version", + "UnsolicitedServerNameAck-TLS-TLS1": "TLS 1.3 only: the pure stack implements no earlier version", + "UnsolicitedServerNameAck-TLS-TLS11": "TLS 1.3 only: the pure stack implements no earlier version", + "UnsolicitedServerNameAck-TLS-TLS12": "TLS 1.3 only: the pure stack implements no earlier version", + "VersionNegotiation-Client-TLS13-TLS1-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "VersionNegotiation-Client-TLS13-TLS11-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "VersionNegotiation-Client-TLS13-TLS12-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "VersionNegotiation-Client2-TLS13-TLS1-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "VersionNegotiation-Client2-TLS13-TLS11-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "VersionNegotiation-Client2-TLS13-TLS12-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "VersionNegotiation-Server-TLS13-TLS1-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "VersionNegotiation-Server-TLS13-TLS11-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "VersionNegotiation-Server-TLS13-TLS12-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "VersionNegotiation-Server2-TLS13-TLS1-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "VersionNegotiation-Server2-TLS13-TLS11-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "VersionNegotiation-Server2-TLS13-TLS12-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "VersionNegotiationExtension-TLS1-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "VersionNegotiationExtension-TLS11-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "VersionNegotiationExtension-TLS12-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "WrongMessageType-CertificateRequest-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "WrongMessageType-ClientFinished-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "WrongMessageType-ClientKeyExchange-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "WrongMessageType-NewSessionTicket-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "WrongMessageType-ServerCertificate-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "WrongMessageType-ServerFinished-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "WrongMessageType-ServerHello-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "WrongMessageType-ServerHelloDone-TLS": "TLS 1.3 only: the pure stack implements no earlier version", + "WrongMessageType-ServerKeyExchange-TLS": "TLS 1.3 only: the pure stack implements no earlier version" + } +} diff --git a/tests/conformance/bogo/run.sh b/tests/conformance/bogo/run.sh new file mode 100644 index 000000000..0bb2259f2 --- /dev/null +++ b/tests/conformance/bogo/run.sh @@ -0,0 +1,78 @@ +#!/bin/sh +# BoGo, BoringSSL's TLS test suite (ssl/test/runner), against the pure-Aether +# TLS 1.3 stack through shim.ae (#1298): std.cryptography.tls13_client, and +# tls13_server for the tests where the shim is the server. +# +# sh tests/conformance/bogo/run.sh [runner flags, e.g. -test 'Basic-*'] +# +# BoringSSL is fetched at a pinned commit (git checks every object against +# the commit's hash) into $CONFORMANCE_CACHE (default +# $HOME/.cache/aether-conformance); it is never committed. No CI job runs +# BoGo: it is run by hand (make conformance-bogo), and needs Go (go.mod says +# which; 1.27 at this commit) and git. ci-crypto-ct caches the same +# directory for x509-limbo, not for this. +# config.json disables each test that fails, with the reason; a test that +# fails and is not disabled fails the run. Tests that need a flag the shim +# does not implement exit 89 and count as "unimplemented" +# (-allow-unimplemented); the shim's header lists the flags it handles. + +set -e + +BORINGSSL_COMMIT=e651545da6e26e1f4420723a3b69a78ed1b9c51d + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +AE="$ROOT/build/ae" +CACHE="${CONFORMANCE_CACHE:-$HOME/.cache/aether-conformance}" +SRC="$CACHE/boringssl-$BORINGSSL_COMMIT" + +[ -x "$AE" ] || { echo " [FAIL] bogo: build/ae is not built"; exit 1; } +command -v go >/dev/null 2>&1 || { echo " [FAIL] bogo: no go to build the runner"; exit 1; } + +if [ "$(git -C "$SRC" rev-parse HEAD 2>/dev/null || :)" != "$BORINGSSL_COMMIT" ]; then + rm -rf "$SRC" + mkdir -p "$SRC" + git -C "$SRC" init -q + git -C "$SRC" remote add origin https://github.com/google/boringssl + git -C "$SRC" sparse-checkout set ssl/test/runner util >/dev/null 2>&1 || : + git -C "$SRC" fetch -q --depth 1 --filter=blob:none origin "$BORINGSSL_COMMIT" \ + || { echo " [FAIL] bogo: could not fetch BoringSSL $BORINGSSL_COMMIT"; exit 1; } + git -C "$SRC" checkout -q FETCH_HEAD + [ "$(git -C "$SRC" rev-parse HEAD)" = "$BORINGSSL_COMMIT" ] \ + || { echo " [FAIL] bogo: BoringSSL is not at $BORINGSSL_COMMIT"; exit 1; } +fi + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +"$AE" build "$SCRIPT_DIR/shim.ae" -o "$TMP/shim" >"$TMP/build.log" 2>&1 \ + || { sed -n '1,20p' "$TMP/build.log"; echo " [FAIL] bogo: shim.ae did not build"; exit 1; } +SHIM="$TMP/shim" +[ -x "$SHIM.exe" ] && SHIM="$SHIM.exe" +(cd "$SRC/ssl/test/runner" && go test -c -o "$TMP/runner" .) >"$TMP/runner.log" 2>&1 \ + || { sed -n '1,20p' "$TMP/runner.log"; echo " [FAIL] bogo: the runner did not build"; exit 1; } +RUNNER="$TMP/runner" +[ -x "$RUNNER.exe" ] && RUNNER="$RUNNER.exe" +CONFIG="$SCRIPT_DIR/config.json" +if command -v cygpath >/dev/null 2>&1; then + # The runner is a native Windows program there: give it Windows paths. + SHIM=$(cygpath -m "$SHIM"); CONFIG=$(cygpath -m "$CONFIG") +fi + +# The runner reads its certificates and keys relative to its directory. +start=$(date +%s) +rc=0 +(cd "$SRC/ssl/test/runner" && "$RUNNER" -shim-path "$SHIM" -shim-config "$CONFIG" \ + -allow-unimplemented -loose-errors -pipe -num-workers "${BOGO_WORKERS:-4}" "$@") >"$TMP/out.txt" 2>&1 || rc=1 +passed=$(grep -c "^PASSED (" "$TMP/out.txt" || :) +failed=$(grep -c "^FAILED (" "$TMP/out.txt" || :) +unimpl=$(grep -c "^UNIMPLEMENTED (" "$TMP/out.txt" || :) +summary="$passed passed, $failed failed, $unimpl unimplemented; config.json disables the rest" +if [ "$rc" = 0 ] && [ "$failed" = 0 ]; then + echo " [PASS] bogo ($(( $(date +%s) - start )) s): $summary" + exit 0 +fi +[ -n "$BOGO_OUT" ] && cp "$TMP/out.txt" "$BOGO_OUT" +grep -A14 "^FAILED (" "$TMP/out.txt" | head -300 +tail -5 "$TMP/out.txt" +echo " [FAIL] bogo: $summary" +exit 1 diff --git a/tests/conformance/bogo/shim.ae b/tests/conformance/bogo/shim.ae new file mode 100644 index 000000000..d050004ae --- /dev/null +++ b/tests/conformance/bogo/shim.ae @@ -0,0 +1,239 @@ +// A BoGo shim (BoringSSL's ssl/test/runner) for the pure-Aether TLS 1.3 +// stack: std.cryptography.tls13_client, and tls13_server with -server +// (#1298). +// +// The runner starts one shim per connection test with flags saying what to +// do. The shim dials the runner's port, sends its 64-bit shim id (little +// endian), runs the handshake as client or server, writes "hello" first +// when asked, then echoes every byte it reads back XORed with 0xFF until the +// runner closes. Exit 0 is success, 1 a failure (the reason on stderr; the +// runner runs with -loose-errors, so BoringSSL's error names are not +// needed), and 89 "unimplemented": a flag this shim does not handle, which +// the runner (with -allow-unimplemented) counts apart from passes and +// failures. Everything not listed below is unimplemented: +// +// -port N -shim-id N -ipv6 where to connect, and the id to send +// -server be the server (else the client) +// -cert-file P -key-file P the server's chain and key (ECDSA +// P-256, P-384, P-521, Ed25519 or RSA, +// #3326). A client certificate is +// unimplemented. +// -trust-cert P the client's trust anchor; the runner's +// certificates name "test", which is the +// host the client verifies +// -min-version N -max-version N TLS 1.3 (772) must be in the range +// -expect-version N must be 772 +// -shim-writes-first write "hello" after the handshake +// -key-update update the keys after the handshake +// (conn_key_update, #3320) +// -async -implicit-handshake no effect here (I/O is blocking) +// -is-handshaker-supported answers "No" +// +// run.sh builds this and the runner; config.json lists the tests that are +// disabled and why. + +import std.os +import std.io +import std.fs +import std.tcp +import std.bytes +import std.string +import std.cryptography +import std.cryptography.tls13_client +import std.cryptography.tls13_server + +extern exit(code: int) + +const TLS13 = 772 + +fn unimplemented(why: string) { + _w = io.stderr_write("UNIMPLEMENTED: ${why}\n") + exit(89) +} + +fn fail(why: string) { + _w = io.stderr_write("FAILED: ${why}\n") + exit(1) +} + +// The flag's value, or exit 89 when it has none. +fn value_of(i: int, n: int) -> string { + if i + 1 >= n { unimplemented("${os.args_get(i)} has no value") } + return os.args_get(i + 1) +} + +fn int_of(s: string) -> int { + v = 0 + n = string.length(s) + i = 0 + while i < n { + c = string_char_at(s, i) + if c < 48 || c > 57 { unimplemented("not a number: ${s}") } + v = v * 10 + (c - 48) + i = i + 1 + } + return v +} + +extern string_char_at(str: string, index: int) -> int + +// 64-bit little-endian of the decimal `s` (the runner's ids are small, but +// all eight bytes are written). +fn shim_id_bytes(s: string) -> ptr { + b = bytes.new(8) + v = 0 + n = string.length(s) + i = 0 + while i < n { + v = v * 10 + (string_char_at(s, i) - 48) + i = i + 1 + } + k = 0 + while k < 8 { + bytes.set(b, k, v & 0xFF) + v = v >> 8 + k = k + 1 + } + return b +} + +// The pure server's credentials: a chain, and its key of any kind the +// server signs with (tls13_server.load_private_key, #3326). +fn server_creds(cert_file: string, key_file: string) -> (ptr, ptr, ptr) { + cert_pem, cerr = fs.read(cert_file) + if cerr != "" { fail("cannot read ${cert_file}: ${cerr}") } + key_pem, kerr = fs.read(key_file) + if kerr != "" { fail("cannot read ${key_file}: ${kerr}") } + chain, lens = tls13_server.parse_cert_chain_pem(cert_pem) + key, lerr = tls13_server.load_private_key(key_pem) + if chain == null || key == null { unimplemented("cannot load the server credentials: ${lerr}") } + return chain, lens, key +} + +main() { + n = os.args_count() + port = 0 + shim_id = "0" + host = "127.0.0.1" + server = 0 + cert_file = "" + key_file = "" + trust = "" + writes_first = 0 + key_update = 0 + i = 1 + while i < n { + f = os.args_get(i) + if f == "-is-handshaker-supported" { + println("No") + exit(0) + } else if f == "-port" { + port = int_of(value_of(i, n)) + i = i + 1 + } else if f == "-shim-id" { + shim_id = value_of(i, n) + i = i + 1 + } else if f == "-ipv6" { + host = "::1" + } else if f == "-server" { + server = 1 + } else if f == "-cert-file" { + cert_file = value_of(i, n) + i = i + 1 + } else if f == "-key-file" { + key_file = value_of(i, n) + i = i + 1 + } else if f == "-trust-cert" { + trust = value_of(i, n) + i = i + 1 + } else if f == "-min-version" { + if int_of(value_of(i, n)) > TLS13 { unimplemented("-min-version above TLS 1.3") } + i = i + 1 + } else if f == "-max-version" { + if int_of(value_of(i, n)) < TLS13 { unimplemented("-max-version below TLS 1.3") } + i = i + 1 + } else if f == "-expect-version" { + if int_of(value_of(i, n)) != TLS13 { unimplemented("-expect-version other than TLS 1.3") } + i = i + 1 + } else if f == "-key-update" { + key_update = 1 + } else if f == "-shim-writes-first" { + writes_first = 1 + } else if f == "-async" || f == "-implicit-handshake" { + // Blocking I/O does what these ask of BoringSSL's shim. + } else { + unimplemented(f) + } + i = i + 1 + } + if server == 0 && trust == "" { unimplemented("a client test without -trust-cert") } + if server == 1 && (cert_file == "" || key_file == "") { unimplemented("a server test without credentials") } + if server == 0 && (cert_file != "" || key_file != "") { unimplemented("a client certificate (connect_socket presents none)") } + + chain = null + lens = null + key = null + if server == 1 { + chain, lens, key = server_creds(cert_file, key_file) + } + + sock, derr = tcp.connect(host, port) + if derr != "" { fail("cannot connect to the runner: ${derr}") } + idb = shim_id_bytes(shim_id) + _sent, werr = tcp.write_all(sock, bytes.view(idb)[0..8]) + bytes.free(idb) + if werr != "" { fail("cannot send the shim id: ${werr}") } + + conn = null + herr = "" + if server == 1 { + xpriv, rerr = cryptography.random_buffer(32) + if rerr != "" { fail(rerr) } + conn, herr = tls13_server.accept(sock, chain, lens, key, xpriv) + bytes.free(xpriv) + } else { + conn, herr = tls13_client.connect_socket(sock, "test", trust) + } + if herr != "" || conn == null { fail("handshake: ${herr}") } + + if key_update == 1 { + kerr = "" + if server == 1 { kerr = tls13_server.conn_key_update(conn, 0) } else { kerr = tls13_client.conn_key_update(conn, 0) } + if kerr != "" { fail("key update: ${kerr}") } + } + + if writes_first == 1 { + hello = bytes.new(5) + bytes.set(hello, 0, 104); bytes.set(hello, 1, 101); bytes.set(hello, 2, 108) + bytes.set(hello, 3, 108); bytes.set(hello, 4, 111) + serr = "" + if server == 1 { serr = tls13_server.conn_send(conn, bytes.view(hello)[0..5]) } else { serr = tls13_client.conn_send(conn, bytes.view(hello)[0..5]) } + bytes.free(hello) + if serr != "" { fail("write: ${serr}") } + } + + // Echo, each byte XORed with 0xFF, until the runner closes. + going = 1 + while going == 1 { + buf = null + blen = 0 + rerr2 = "" + if server == 1 { buf, blen, rerr2 = tls13_server.conn_recv(conn) } else { buf, blen, rerr2 = tls13_client.conn_recv(conn) } + if rerr2 != "" { fail("read: ${rerr2}") } + if buf == null { + going = 0 + } else { + k = 0 + while k < blen { + bytes.set(buf, k, (bytes.get(buf, k) ^ 0xFF) & 0xFF) + k = k + 1 + } + serr2 = "" + if server == 1 { serr2 = tls13_server.conn_send(conn, bytes.view(buf)[0..blen]) } else { serr2 = tls13_client.conn_send(conn, bytes.view(buf)[0..blen]) } + bytes.free(buf) + if serr2 != "" { fail("write: ${serr2}") } + } + } + if server == 1 { tls13_server.close_conn(conn) } else { tls13_client.close_conn(conn) } + exit(0) +} diff --git a/tests/conformance/x509_limbo/expected.txt b/tests/conformance/x509_limbo/expected.txt new file mode 100644 index 000000000..59be1afd7 --- /dev/null +++ b/tests/conformance/x509_limbo/expected.txt @@ -0,0 +1,154 @@ +# x509-limbo cases std.cryptography.tls13_cert does not run (SKIP), does not +# agree with (FAIL), or agrees with where OpenSSL does not (AGREE, a note), +# one per line: SKIP|FAIL|AGREE . limbo.ae fails the run on any +# other disagreement, and on a FAIL line whose case has come to agree. Every +# case not listed runs and agrees, the bettertls namespace included. +# +# The rule: std.http's two TLS backends accept the same chains, except where +# OpenSSL accepts something unsafe; there the pure backend refuses, and the +# OpenSSL backend is tightened wherever a flag allows it (#3312, #3313). +# "OpenSSL" below is `openssl verify` 3.6.4 set up as that backend verifies +# a server: purpose sslserver, the host or IP, no partial chains, plus the +# backend's own flags where the command has none (NO_PARTIAL_WILDCARDS, +# NEVER_CHECK_SUBJECT, security level 2). +# +# bettertls, where every case agrees with limbo, differs from OpenSSL on 183 +# cases, none where the pure backend accepts what OpenSSL refuses for a +# reason that matters: +# 90 accepted here, refused by OpenSSL, which applies DNS name constraints +# to the subject CN of a leaf with no DNS SAN; neither backend uses the +# CN for names; +# 71 refused here, accepted by `openssl verify`, which matches the host +# against the CN; the OpenSSL backend never does (#3313); +# 22 accepted here, refused by OpenSSL, which does not try the other path +# that is valid. + +# ---- Not run ---- + +# Revocation: no CRLs. The pure TLS client checks a stapled OCSP response +# only (std/http/README.md). +crl::certificate-not-on-crl SKIP no CRL support (stapled OCSP only) +crl::certificate-serial-on-crl-different-issuer SKIP no CRL support (stapled OCSP only) +crl::crlnumber-critical SKIP no CRL support (stapled OCSP only) +crl::crlnumber-missing SKIP no CRL support (stapled OCSP only) +crl::issuer-missing-crlsign SKIP no CRL support (stapled OCSP only) +crl::issuer-no-keyusage-extension SKIP no CRL support (stapled OCSP only) +crl::issuer-valid-crlsign-and-keycertsign SKIP no CRL support (stapled OCSP only) +crl::revoked-certificate-with-crl SKIP no CRL support (stapled OCSP only) +crl::structure::crl-duplicate-revoked-serial SKIP no CRL support (stapled OCSP only) +crl::structure::crl-empty SKIP no CRL support (stapled OCSP only) +crl::structure::crl-invalid-signature SKIP no CRL support (stapled OCSP only) +crl::structure::crl-unknown-critical-extension SKIP no CRL support (stapled OCSP only) +crl::structure::crl-unknown-noncritical-extension SKIP no CRL support (stapled OCSP only) +crl::structure::crl-very-large SKIP no CRL support (stapled OCSP only) +crl::structure::crl-wrong-signing-key SKIP no CRL support (stapled OCSP only) +crl::structure::entry-unknown-critical-extension SKIP no CRL support (stapled OCSP only) +crl::structure::entry-unknown-noncritical-extension SKIP no CRL support (stapled OCSP only) + +# ML-DSA certificates (RFC 9881): the standard library has no ML-DSA. +rfc9881::ml-dsa-44 SKIP no ML-DSA signatures (no std.cryptography ML-DSA module) +rfc9881::ml-dsa-44-bad-signature SKIP no ML-DSA signatures (no std.cryptography ML-DSA module) +rfc9881::ml-dsa-44-key-agreement SKIP no ML-DSA signatures (no std.cryptography ML-DSA module) +rfc9881::ml-dsa-44-key-encipherment SKIP no ML-DSA signatures (no std.cryptography ML-DSA module) +rfc9881::ml-dsa-65 SKIP no ML-DSA signatures (no std.cryptography ML-DSA module) +rfc9881::ml-dsa-87 SKIP no ML-DSA signatures (no std.cryptography ML-DSA module) + +# ---- Differs from limbo, as OpenSSL does (FAIL) ---- + +# A leaf whose keyUsage does not allow digitalSignature: TLS 1.3 signs the +# CertificateVerify with the leaf's key, so RFC 8446 4.4.2.2 requires the +# bit (#3309). limbo's RFC 5280 view, with no TLS in it, accepts. +pathlen::validation-ignores-pathlen-in-leaf FAIL refused, as by OpenSSL: the leaf's keyUsage lacks digitalSignature, which TLS needs (RFC 8446 4.4.2.2) +rfc5280::ca-as-leaf FAIL refused, as by OpenSSL: the leaf's keyUsage lacks digitalSignature, which TLS needs (RFC 8446 4.4.2.2) + +# Only a self-signed root ends a path (#3310); pinning an intermediate on +# both backends is #3311. +cve::cve-2024-0567 FAIL refused, as by OpenSSL: the only trusted certificate is not a self-signed root (#3310, #3311) +rfc5280::root-and-intermediate-swapped FAIL refused, as by OpenSSL: the only trusted certificate is not a self-signed root (#3310, #3311) + +# Issuance rules for CAs that change nothing a path authorises. +rfc5280::aki::intermediate-missing-aki FAIL accepted, as by OpenSSL: authorityKeyIdentifier is not required +rfc5280::aki::leaf-missing-aki FAIL accepted, as by OpenSSL: authorityKeyIdentifier is not required +rfc5280::ski::intermediate-missing-ski FAIL accepted, as by OpenSSL: subjectKeyIdentifier is not required +rfc5280::ski::root-missing-ski FAIL accepted, as by OpenSSL: subjectKeyIdentifier is not required +webpki::aki::root-with-aki-missing-keyidentifier FAIL accepted, as by OpenSSL: the root's AKI names the root itself by issuer and serial +rfc5280::leaf-ku-keycertsign FAIL accepted, as by OpenSSL: keyCertSign without cA gives a leaf nothing, as cA alone makes an issuer +rfc5280::nc::not-allowed-in-ee-critical FAIL accepted, as by OpenSSL: name constraints in an end entity constrain nothing it could issue +rfc5280::nc::not-allowed-in-ee-noncritical FAIL accepted, as by OpenSSL: name constraints in an end entity constrain nothing it could issue +rfc5280::nc::permitted-nonempty-excluded-empty-sequence FAIL accepted, as by OpenSSL: an empty excludedSubtrees excludes nothing +webpki::nc::intermediate-permitted-excluded-subtrees-both-null FAIL accepted, as by OpenSSL: a nameConstraints with no subtrees constrains nothing +rfc5280::pc::ica-noncritical-pc FAIL accepted, as by OpenSSL: policies are not processed, and a non-critical policyConstraints asks for nothing +rfc5280::root-missing-basic-constraints FAIL accepted, as by OpenSSL: a trust anchor is trusted as configured (RFC 5280 6.1.1) +rfc5280::root-non-critical-basic-constraints FAIL accepted, as by OpenSSL: a trust anchor is trusted as configured (RFC 5280 6.1.1) +webpki::eku::root-has-eku FAIL accepted, as by OpenSSL: a trust anchor's extendedKeyUsage is honoured as a restriction +rfc5280::san::noncritical-with-empty-subject FAIL accepted, as by OpenSSL: the SAN's criticality changes nothing it names (BoringSSL's test runner issues such leaves) +webpki::san::san-critical-with-nonempty-subject FAIL accepted, as by OpenSSL: a critical SAN is allowed with a subject (RFC 5280 4.2.1.6) +rfc5280::serial::too-long FAIL accepted, as by OpenSSL: the serial plays no part in validation +rfc5280::serial::zero FAIL accepted, as by OpenSSL: RFC 5280 4.1.2.2 asks users to handle zero serials gracefully +webpki::malformed-aia FAIL accepted, as by OpenSSL: authority information access is not used +webpki::forbidden-rsa-key-not-divisible-by-8-in-leaf FAIL accepted, as by OpenSSL: the modulus length (2048 bits or more) is what matters, not its multiple of 8 +webpki::forbidden-rsa-not-divisible-by-8-in-root FAIL accepted, as by OpenSSL: the modulus length (2048 bits or more) is what matters, not its multiple of 8 + +# A certificate with basicConstraints cA TRUE as the end entity, when its +# keyUsage allows signing: a self-signed certificate made by `openssl req +# -x509` with the default configuration is one, and pinning it is supported. +webpki::ee-basicconstraints-ca FAIL accepted, as by OpenSSL: a cA TRUE certificate may be the end entity + +# The subject common name is never used for names (subjectAltName only, RFC +# 9525; the OpenSSL backend never checks the subject either, #3313), so what +# it holds changes nothing. +webpki::cn::case-mismatch FAIL accepted, as by OpenSSL: the subject CN is not used for names +webpki::cn::ipv4-hex-mismatch FAIL accepted, as by OpenSSL: the subject CN is not used for names +webpki::cn::ipv4-leading-zeros-mismatch FAIL accepted, as by OpenSSL: the subject CN is not used for names +webpki::cn::ipv6-non-rfc5952-mismatch FAIL accepted, as by OpenSSL: the subject CN is not used for names +webpki::cn::ipv6-uncompressed-mismatch FAIL accepted, as by OpenSSL: the subject CN is not used for names +webpki::cn::ipv6-uppercase-mismatch FAIL accepted, as by OpenSSL: the subject CN is not used for names +webpki::cn::not-in-san FAIL accepted, as by OpenSSL: the subject CN is not used for names +webpki::cn::punycode-not-in-san FAIL accepted, as by OpenSSL: the subject CN is not used for names +webpki::cn::utf8-vs-punycode-mismatch FAIL accepted, as by OpenSSL: the subject CN is not used for names + +# extendedKeyUsage on the end entity: absent means any purpose (RFC 5280 +# 4.2.1.12), as OpenSSL and Go's crypto/x509 read it; a critical one, or one +# that also lists anyExtendedKeyUsage, is read for the purpose it names +# (CABF forbids both; RFC 5280 does not). +webpki::eku::ee-without-eku FAIL accepted, as by OpenSSL (and Go): no extendedKeyUsage means any purpose +webpki::eku::ee-critical-eku FAIL accepted, as by OpenSSL: a critical extendedKeyUsage is processed +webpki::eku::ee-anyeku FAIL accepted, as by OpenSSL: serverAuth is listed; anyExtendedKeyUsage beside it is not refused + +# Names. +rfc5280::nc::permitted-dns-match-noncritical FAIL accepted, as by OpenSSL: a non-critical nameConstraints is honoured (which can only refuse names) +rfc5280::san::underscore-dns FAIL accepted, as by OpenSSL and Go: an underscore is allowed in a dNSName +webpki::san::public-suffix-multi-label-wildcard-san FAIL accepted, as by OpenSSL: no public suffix list; "*.com" is refused, "*.co.uk" is not +webpki::san::public-suffix-private-namespace-wildcard-san FAIL accepted, as by OpenSSL: no public suffix list; "*.s3.amazonaws.com" is not refused + +# ---- Agrees with limbo, where OpenSSL does not (AGREE) ---- + +# Refused here, accepted by OpenSSL, because accepting is unsafe. +cve::cve-2025-61727 AGREE refused; OpenSSL 3.6.4 accepts: a name-constraint bypass (a wildcard SAN under an excluded name it covers) +rfc5280::nc::nc-forbids-dnsname-wildcard-san AGREE refused; OpenSSL 3.6.4 accepts: a name-constraint bypass (a wildcard SAN under an excluded name it covers) +rfc5280::nc::nc-permits-invalid-dns-san AGREE refused; OpenSSL accepts a malformed DNS SAN under a name constraint, which no subtree can be judged against +rfc5280::nc::nc-permits-invalid-email-san AGREE refused; OpenSSL accepts a malformed mailbox under a name constraint, which no subtree can be judged against +rfc5280::nc::invalid-dnsname-leading-period AGREE refused; OpenSSL reads a leading period in a DNS constraint as subdomains only, which leaves the name itself out of an excluded subtree +rfc5280::nc::permitted-empty-sequence-excluded-nonempty AGREE refused; OpenSSL reads an empty permittedSubtrees as no constraint, which would leave a constrained CA unconstrained +webpki::nc::intermediate-permitted-excluded-subtrees-both-empty-sequences AGREE refused; OpenSSL reads an empty permittedSubtrees as no constraint, which would leave a constrained CA unconstrained +webpki::forbidden-weak-rsa-in-leaf AGREE refused; openssl verify accepts RSA-1024 at security level 1, the OpenSSL backend refuses it at level 2 (#3312) +webpki::forbidden-weak-rsa-key-in-root AGREE refused; openssl verify accepts RSA-1024 at security level 1, the OpenSSL backend refuses it at level 2 (#3312) +webpki::forbidden-dsa-leaf AGREE refused; a DSA leaf cannot sign a TLS 1.3 CertificateVerify, so the handshake fails on both backends (openssl verify accepts the chain) +webpki::forbidden-p192-leaf AGREE refused; a P-192 leaf cannot sign a TLS 1.3 CertificateVerify, so the handshake fails on both backends (openssl verify accepts the chain) +webpki::forbidden-dsa-root AGREE refused; this code verifies no DSA signature, so a DSA root issues nothing here (openssl verify accepts the chain) +webpki::forbidden-p192-root AGREE refused; this code verifies no P-192 signature, so a P-192 root issues nothing here (openssl verify accepts the chain) + +# The subject common name is never used for names (#3313 makes the OpenSSL +# backend agree; `openssl verify -verify_hostname` falls back to it). +webpki::san::no-san AGREE refused, as by the OpenSSL backend since #3313: a leaf without a SAN names no host +webpki::v1-cert AGREE refused, as by the OpenSSL backend since #3313: a v1 leaf has no SAN, so it names no host + +# Accepted here, refused by OpenSSL, where this code follows RFC 5280. +rfc5280::nc::nc-forbids-alternate-chain-ica AGREE accepted; OpenSSL refuses, as it does not try the other path that is valid +rfc5280::nc::nc-forbids-same-chain-ica AGREE accepted; OpenSSL refuses, as it does not try the other path that is valid +rfc5280::validity::notafter-exact AGREE accepted; RFC 5280 4.1.2.5 makes notAfter inclusive, OpenSSL refuses at that second +rfc5280::validity::notafter-fractional AGREE accepted; RFC 5280 4.1.2.5 makes notAfter inclusive, OpenSSL refuses at that second + +# Comparison artifacts, not differences between the backends. +pathlen::max-chain-depth-1-self-issued AGREE accepted; `openssl verify -verify_depth 1` counts self-issued certificates, but the backend runs at depth 100 +webpki::san::wildcard-embedded-leftmost-san AGREE refused; `openssl verify` has no flag for NO_PARTIAL_WILDCARDS, which the OpenSSL backend sets diff --git a/tests/conformance/x509_limbo/limbo.ae b/tests/conformance/x509_limbo/limbo.ae new file mode 100644 index 000000000..93c085641 --- /dev/null +++ b/tests/conformance/x509_limbo/limbo.ae @@ -0,0 +1,227 @@ +// x509-limbo (C2SP/x509-limbo) against std.cryptography.tls13_cert's path +// and hostname validation (#1298). +// +// Every testcase of limbo.json is validated as the pure TLS client validates +// a server: the trusted certificates as anchors, the untrusted intermediates +// as the chain, the peer certificate as the leaf, at the case's validation +// time, for the case's purpose (SERVER or CLIENT), then the expected peer +// name. The result is compared with expected_result. +// +// The expected-results file lists every case that does not run or does not +// agree, one per line, with its reason: +// +// SKIP not run (a feature this validator does not have) +// FAIL runs, and the result differs from limbo's +// AGREE runs and agrees with limbo, where OpenSSL does not +// (a note: checked as any case that is not listed) +// +// A mismatch not in the file fails the run, and so does a FAIL entry that +// now agrees (so the file stays exact). Lines starting with # are comments. +// +// Environment: +// LIMBO_JSON path to limbo.json (required) +// LIMBO_EXPECTED path to the expected-results file (required) +// LIMBO_PREFIX only the ids starting with this ("bettertls::" for the +// bettertls namespace, "" for all) +// LIMBO_EXCLUDE skip the ids starting with this +// LIMBO_PRINT=1 print a " FAIL " line per disagreement, +// to seed the expected-results file +// +// Prints a summary line and "ALL PASS", or the unexpected cases and exits 1. +// run.sh fetches the pinned limbo.json and runs this, in the Linux CI job +// ci-crypto-ct. + +import std.os +import std.fs +import std.json +import std.list +import std.map +import std.string +import std.cryptography.tls13_cert + +extern exit(code: int) +extern string_char_at_n(str: string, known_length: int, index: int) -> int + +// "2024-01-01T00:00:00+00:00" (limbo writes UTC) -> "20240101000000". +fn time_key(t: string) -> string { + n = string.length(t) + out = "" + i = 0 + count = 0 + while i < n && count < 14 { + c = string_char_at_n(t, n, i) + if c >= 48 && c <= 57 { + out = "${out}${string.substring(t, i, i + 1)}" + count = count + 1 + } + i = i + 1 + } + return out +} + +// Parse every PEM certificate in the JSON array `arr` into a fresh list. +fn certs_of(arr: ptr) -> ptr { + l = list_new() + if arr == null { return l } + n = json.array_size(arr) + i = 0 + while i < n { + pem = json.get_string_raw(json.array_get_raw(arr, i)) + tls13_cert.trust_store_add_pem(l, pem) + i = i + 1 + } + return l +} + +fn json_list_has(arr: ptr, s: string) -> int { + if arr == null { return 0 } + n = json.array_size(arr) + i = 0 + while i < n { + if json.get_string_raw(json.array_get_raw(arr, i)) == s { return 1 } + i = i + 1 + } + return 0 +} + +// Our verdict for one testcase: "" (SUCCESS) or the reason for FAILURE. +fn validate_case(t: ptr) -> string { + anchors = certs_of(json.object_get_raw(t, "trusted_certs")) + inters = certs_of(json.object_get_raw(t, "untrusted_intermediates")) + leaves = list_new() + tls13_cert.trust_store_add_pem(leaves, json.get_string_raw(json.object_get_raw(t, "peer_certificate"))) + reason = "" + if list_size(leaves) != 1 { + reason = "the peer certificate does not parse" + } else { + leaf = list.at(leaves, 0) as *LeafCert + at = "" + vt = json.object_get_raw(t, "validation_time") + if vt != null && json.is_null(vt) != 1 { at = time_key(json.get_string_raw(vt)) } + purpose = tls13_cert.PURPOSE_SERVER + if json.get_string_raw(json.object_get_raw(t, "validation_kind")) == "CLIENT" { + purpose = tls13_cert.PURPOSE_CLIENT + } + depth = -1 + md = json.object_get_raw(t, "max_chain_depth") + if md != null && json.is_null(md) != 1 { depth = json.get_int(md) } + reason = tls13_cert.validate_chain(leaf, inters, anchors, at, purpose, depth) + if reason == "" { + // The leaf's keyUsage must allow what the case asks for. + ku = json.object_get_raw(t, "key_usage") + if json_list_has(ku, "digitalSignature") == 1 && (leaf.ext_bits & 8) != 0 && (leaf.key_usage & 1) == 0 { + reason = "the leaf's keyUsage lacks digitalSignature" + } + } + if reason == "" { + pn = json.object_get_raw(t, "expected_peer_name") + if pn != null && json.is_null(pn) != 1 { + kind = json.get_string_raw(json.object_get_raw(pn, "kind")) + value = json.get_string_raw(json.object_get_raw(pn, "value")) + if kind == "DNS" || kind == "IP" { + reason = tls13_cert.check_hostname(leaf, value) + } else { + reason = "peer name kind ${kind} is not supported" + } + } + } + } + tls13_cert.free_anchors(anchors) + tls13_cert.free_anchors(inters) + tls13_cert.free_anchors(leaves) + return reason +} + +main() { + doc, ferr = fs.read(os.getenv("LIMBO_JSON")) + if ferr != "" { + println("FAIL: cannot read LIMBO_JSON: ${ferr}") + exit(1) + } + exp_text, eerr = fs.read(os.getenv("LIMBO_EXPECTED")) + if eerr != "" { + println("FAIL: cannot read LIMBO_EXPECTED: ${eerr}") + exit(1) + } + prefix = os.getenv("LIMBO_PREFIX") + exclude = os.getenv("LIMBO_EXCLUDE") + print_mode = os.getenv("LIMBO_PRINT") == "1" + + // id -> "SKIP" / "FAIL" from the expected-results file. + expected = map_new() + lines = string.split(exp_text, "\n") + nl = string.array_size(lines) + li = 0 + while li < nl { + line = string.trim(string.array_get(lines, li)) + if string.length(line) > 0 && string.starts_with(line, "#") != 1 { + parts = string.split(line, " ") + if string.array_size(parts) >= 2 { + map_put_string_owned(expected, string.array_get(parts, 0), string.array_get(parts, 1)) + } + string.array_free(parts) + } + li = li + 1 + } + string.array_free(lines) + + root, jerr = json.parse(doc) + if jerr != "" { + println("FAIL: limbo.json does not parse: ${jerr}") + exit(1) + } + cases = json.object_get_raw(root, "testcases") + n = json.array_size(cases) + agree = 0 + skipped = 0 + known = 0 + bad = 0 + ran = 0 + i = 0 + while i < n { + t = json.array_get_raw(cases, i) + id = json.get_string_raw(json.object_get_raw(t, "id")) + take = 1 + if prefix != "" && string.starts_with(id, prefix) != 1 { take = 0 } + if exclude != "" && string.starts_with(id, exclude) == 1 { take = 0 } + if take == 1 { + mark, merr = map.get_string(expected, id) + if merr != "" || mark == null { mark = "" } + if mark == "SKIP" { + skipped = skipped + 1 + } else { + ran = ran + 1 + reason = validate_case(t) + ours = "SUCCESS" + if reason != "" { ours = "FAILURE" } + want = json.get_string_raw(json.object_get_raw(t, "expected_result")) + if ours == want { + if mark == "FAIL" { + println("NOW AGREES (remove from the expected file): ${id}") + bad = bad + 1 + } else { + agree = agree + 1 + } + } else { + if mark == "FAIL" { + known = known + 1 + } else { + bad = bad + 1 + if print_mode { + if reason == "" { reason = "accepted" } + println("${id} FAIL ${reason}") + } else { + println("UNEXPECTED: ${id}: expected ${want}, got ${ours} (${reason})") + } + } + } + } + } + i = i + 1 + } + println("x509-limbo: ${ran} run, ${agree} agree, ${known} known disagreements, ${skipped} skipped, ${bad} unexpected") + json.free(root) + map_free(expected) + if bad > 0 { exit(1) } + println("ALL PASS") +} diff --git a/tests/conformance/x509_limbo/run.sh b/tests/conformance/x509_limbo/run.sh new file mode 100644 index 000000000..73710fc52 --- /dev/null +++ b/tests/conformance/x509_limbo/run.sh @@ -0,0 +1,75 @@ +#!/bin/sh +# x509-limbo (https://github.com/C2SP/x509-limbo) against +# std.cryptography.tls13_cert's path and name validation (#1298), in two +# parts: limbo's own cases, and the bettertls namespace (Netflix's bettertls +# name-constraint suite, which limbo imports and ships in the same file). +# +# sh tests/conformance/x509_limbo/run.sh [limbo | bettertls | all] +# +# limbo.json (40 MB) is fetched at a pinned commit and checked against its +# SHA-256 into $CONFORMANCE_CACHE (default $HOME/.cache/aether-conformance), +# which CI's ci-crypto-ct job caches; it is never committed. To move the pin, +# change both values below and update expected.txt from a LIMBO_PRINT=1 run. +# limbo.ae and expected.txt say what each case checks and which do not agree. + +set -e + +LIMBO_COMMIT=1080e565f26e544bbe9cf99953be8a57c08e7e20 +LIMBO_SHA256=611e337b9fb477b927bae65434650692cb9d2070ff5b05f975d0403510acd4de + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +AE="$ROOT/build/ae" +CACHE="${CONFORMANCE_CACHE:-$HOME/.cache/aether-conformance}" +JSON="$CACHE/x509-limbo-$LIMBO_COMMIT.json" +WHAT="${1:-all}" + +[ -x "$AE" ] || { echo " [FAIL] x509-limbo: build/ae is not built"; exit 1; } + +sha256_of() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | cut -d' ' -f1 + else + shasum -a 256 "$1" | cut -d' ' -f1 + fi +} + +if [ ! -f "$JSON" ] || [ "$(sha256_of "$JSON")" != "$LIMBO_SHA256" ]; then + mkdir -p "$CACHE" + curl -fsSL --retry 3 -o "$JSON.part" \ + "https://raw.githubusercontent.com/C2SP/x509-limbo/$LIMBO_COMMIT/limbo.json" \ + || { rm -f "$JSON.part"; echo " [FAIL] x509-limbo: could not fetch limbo.json"; exit 1; } + got="$(sha256_of "$JSON.part")" + if [ "$got" != "$LIMBO_SHA256" ]; then + rm -f "$JSON.part" + echo " [FAIL] x509-limbo: limbo.json has SHA-256 $got, want $LIMBO_SHA256" + exit 1 + fi + mv "$JSON.part" "$JSON" +fi + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +"$AE" build "$SCRIPT_DIR/limbo.ae" -o "$TMP/limbo" >"$TMP/build.log" 2>&1 \ + || { sed -n '1,20p' "$TMP/build.log"; echo " [FAIL] x509-limbo: limbo.ae did not build"; exit 1; } + +# run +run() { + start=$(date +%s) + if LIMBO_JSON="$JSON" LIMBO_EXPECTED="$SCRIPT_DIR/expected.txt" LIMBO_PREFIX="$2" LIMBO_EXCLUDE="$3" \ + "$TMP/limbo"; then + echo " [PASS] $1 ($(( $(date +%s) - start )) s)" + return 0 + fi + echo " [FAIL] $1" + return 1 +} + +rc=0 +case "$WHAT" in + limbo | all) run x509-limbo "" "bettertls::" || rc=1 ;; +esac +case "$WHAT" in + bettertls | all) run bettertls "bettertls::" "" || rc=1 ;; +esac +exit $rc diff --git a/tests/crypto_wipe/check.ae b/tests/crypto_wipe/check.ae new file mode 100644 index 000000000..58cfd2daf --- /dev/null +++ b/tests/crypto_wipe/check.ae @@ -0,0 +1,185 @@ +// Every buffer the cryptography modules free is wiped first (#3402, #3414). +// +// A buffer a cryptographic module frees may have held a key, a nonce, a +// keystream block, a hash state or the text of a private key, and freed +// memory keeps what it held until it is handed out again. A rule decided +// call by call missed one module after another (the X25519 scalar, the +// TLS key schedule's salts), so the rule is the whole tree's: under +// std/cryptography a std.bytes buffer is freed with bytes.wipe_free, never +// bytes.free. A public buffer pays a memset, which is nothing next to the +// cryptography that made it. +// +// This reads every module.ae under the given directories (the modules, not +// their test programs) and reports each +// call of bytes.free (`bytes.free(`), comments and string literals skipped. +// +// check [--expect N] DIR... +// +// Exits 1 on a call it reports; with --expect, unless exactly N were found +// (the self-test over tests/crypto_wipe/fixture). `make check-crypto-wipe` +// runs both. + +import std.fs +import std.os +import std.io +import std.string +import std.collections + +extern exit(code: int) + +say(s: string) { + _w = io.stdout_write("${s}\n") +} + +// The lines of a text, without their line breaks. +split_lines(text: string) -> ptr { + out = collections.string_list_new() + n = string.length(text) + start = 0 + while start < n { + cut = string.index_of_from(text, "\n", start) + stop = cut + if cut < 0 { + stop = n + } + line_end = stop + if line_end > start && string.char_at_n(text, n, line_end - 1) == 13 { + line_end = line_end - 1 + } + collections.string_list_add(out, string.substring(text, start, line_end)) + start = stop + 1 + } + return out +} + +// A line with its comment cut off and every string literal's contents +// blanked (the quotes stay), so neither is read as code. +code_of(line: string) -> string { + n = string.length(line) + out = "" + in_str = 0 + i = 0 + while i < n { + c = string.char_at_n(line, n, i) + if in_str == 1 { + if c == 92 { + i = i + 2 + continue + } + if c == 34 { + in_str = 0 + out = "${out}\"" + } + i = i + 1 + continue + } + if c == 34 { + in_str = 1 + out = "${out}\"" + i = i + 1 + continue + } + if c == 47 && i + 1 < n && string.char_at_n(line, n, i + 1) == 47 { + break + } + out = "${out}${string.from_char(c)}" + i = i + 1 + } + return out +} + +// How many times `bytes.free(` begins a call in `code`, not as the tail of +// a longer name (`mybytes.free(`). +calls_in(code: string) -> int { + found = 0 + n = string.length(code) + at = string.index_of_from(code, "bytes.free(", 0) + while at >= 0 { + before = 32 + if at > 0 { before = string.char_at_n(code, n, at - 1) } + named = (before >= 97 && before <= 122) || (before >= 65 && before <= 90) || (before >= 48 && before <= 57) || before == 95 || before == 46 + if !named { found = found + 1 } + at = string.index_of_from(code, "bytes.free(", at + 1) + } + return found +} + +main() { + dirs = collections.string_list_new() + expect = -1 + argc = os.args_count() + i = 1 + while i < argc { + a = os.args_get(i) + if a == "--expect" && i + 1 < argc { + i = i + 1 + expect = string.get_int(os.args_get(i)) + } else { + collections.string_list_add(dirs, string.replace_all(a, "\\", "/")) + } + i = i + 1 + } + if collections.string_list_size(dirs) == 0 { + say("usage: check [--expect N] DIR...") + exit(2) + } + + files = collections.string_list_new() + nd = collections.string_list_size(dirs) + i = 0 + while i < nd { + root = collections.string_list_get(dirs, i) + _n, werr = fs.walk(root) | path: string, kind: int, depth: int | { + p = string.replace_all(path, "\\", "/") + if kind == 1 && string.ends_with(p, "/module.ae") == 1 { + collections.string_list_add(files, p) + } + return 0 + } + if werr != "" { + say("crypto wipe: cannot walk ${root}: ${werr}") + exit(2) + } + i = i + 1 + } + + found = 0 + nf = collections.string_list_size(files) + i = 0 + while i < nf { + p = collections.string_list_get(files, i) + text, rerr = fs.read(p) + if rerr != "" { + say("crypto wipe: cannot read ${p}: ${rerr}") + exit(2) + } + lines = split_lines(text) + nl = collections.string_list_size(lines) + k = 0 + while k < nl { + c = calls_in(code_of(collections.string_list_get(lines, k))) + if c > 0 { + if expect < 0 { + say("${p}:${k + 1}: bytes.free in a cryptography module: free the buffer with bytes.wipe_free") + } + found = found + c + } + k = k + 1 + } + i = i + 1 + } + + if expect >= 0 { + if found != expect { + say("crypto wipe self-test: found ${found}, expected ${expect}") + exit(1) + } + say("crypto wipe self-test: ${found} found, as expected") + return + } + if found > 0 { + say("crypto wipe: ${found} plain bytes.free in std/cryptography (#3414)") + exit(1) + } + say("crypto wipe: every buffer the cryptography modules free is wiped first") +} diff --git a/tests/crypto_wipe/fixture/module.ae b/tests/crypto_wipe/fixture/module.ae new file mode 100644 index 000000000..2c035abc9 --- /dev/null +++ b/tests/crypto_wipe/fixture/module.ae @@ -0,0 +1,20 @@ +// The crypto wipe checker's self-test fixture (#3414): exactly 3 calls of +// bytes.free must be reported. Not a program; never run. + +import std.bytes + +fn reported() { + a = bytes.new(4) + bytes.free(a) + b = bytes.new(4) + c = bytes.new(4) + bytes.free(b); bytes.free(c) +} + +fn not_reported() { + // bytes.free(x) in a comment + s = "bytes.free(x) in a string" + d = bytes.new(4) + bytes.wipe_free(d) + println(s) +} diff --git a/tests/esp32/project/main/CMakeLists.txt b/tests/esp32/project/main/CMakeLists.txt index 33d36ceeb..7b9346722 100644 --- a/tests/esp32/project/main/CMakeLists.txt +++ b/tests/esp32/project/main/CMakeLists.txt @@ -6,7 +6,7 @@ file(STRINGS "${AE}/runtime/coop_runtime_sources.txt" AE_RUNTIME REGEX "^[^#]") list(TRANSFORM AE_RUNTIME PREPEND "${AE}/") idf_component_register( SRCS "/gen/${AE_PROG}.c" ${AE_RUNTIME} - INCLUDE_DIRS "${AE}/runtime" "${AE}/runtime/actors" "${AE}/runtime/scheduler" + INCLUDE_DIRS "${AE}/include" "${AE}/runtime" "${AE}/runtime/actors" "${AE}/runtime/scheduler" "${AE}/runtime/utils" "${AE}/runtime/memory" "${AE}/runtime/config" "${AE}/std" "${AE}/std/string" "${AE}/std/io" "${AE}/std/math" "${AE}/std/net" "${AE}/std/collections" "${AE}/std/json") diff --git a/tests/integration/abi_matrix/app.ae b/tests/integration/abi_matrix/app.ae new file mode 100644 index 000000000..42fb43344 --- /dev/null +++ b/tests/integration/abi_matrix/app.ae @@ -0,0 +1,18 @@ +// An Aether consumer of mlib in binary form across versions (#2957): this +// Aether's importer with the release before it's library, and that +// release's importer with this Aether's library. Only calls that succeed: +// a level-0 library has no outcomes, and an importer older than a +// library's catalog does not check them. +import mlib +import std.string + +main() { + println("typed: add ${mlib.add(2, 3)}, wide ${mlib.wide(7)}, even ${mlib.even(4)}") + i = 0 + total = 0 + while i < 2000 { + total = total + string.length(mlib.greet(i)) + i = i + 1 + } + println("strings: ${mlib.greet(9)}, 2000 more read: ${total} bytes") +} diff --git a/tests/integration/abi_matrix/host.c b/tests/integration/abi_matrix/host.c new file mode 100644 index 000000000..d7bfb4eda --- /dev/null +++ b/tests/integration/abi_matrix/host.c @@ -0,0 +1,186 @@ +/* A C host of mlib.ae across versions (#2957, docs/abi-policy.md). It is + * compiled against the libaether.h of this Aether (host ABI level 1 or + * more) or of the release before it, loads the library given (built by + * either) and reads everything by name, as a plugin host does. argv[2], + * when given, is the shared runtime a --shared-runtime library runs on, + * where the runtime's functions are; otherwise they are the library's own. + * It uses a level-1 function only when the header it was compiled against + * has it and the library's catalog records level 1 or more. What it prints + * depends on the levels alone, never on the versions: + * + * catalog: the schema, and the host ABI level the library records + * (unknown to a host built with level-0 headers) + * preflight: the library's _check, where it has one (level 1) + * typed: int, float, long and bool arguments and results + * strings: a string the host owns, given back through the library's + * release, and 20000 more: the library's runtime holds no + * byte more than before (its count of the memory it charges) + * errors: a panic ends the call, not the process (level 1) + * deadline: a deadline ends a loop (level 1; a level-0 host asks only + * whether the loop was cut) + * unload: the library unloads after all this + */ +#include +#include +#include +#include +#include "libaether.h" +#include "aether_lib_meta.h" +#ifdef _WIN32 +#include +static void* lib_open(const char* p) { return (void*)LoadLibraryA(p); } +static void* lib_open_global(const char* p) { return lib_open(p); } +static void* lib_sym(void* h, const char* n) { return (void*)GetProcAddress((HMODULE)h, n); } +static int lib_close(void* h) { return FreeLibrary((HMODULE)h) ? 0 : -1; } +#else +#include +static void* lib_open(const char* p) { return dlopen(p, RTLD_NOW | RTLD_LOCAL); } +static void* lib_open_global(const char* p) { return dlopen(p, RTLD_NOW | RTLD_GLOBAL); } +static void* lib_sym(void* h, const char* n) { return dlsym(h, n); } +static int lib_close(void* h) { return dlclose(h); } +#endif + +#ifdef AETHER_HOST_ABI +#define HEADER_LEVEL AETHER_HOST_ABI +#else +#define HEADER_LEVEL 0 +#endif + +typedef int (*add_fn)(int, int); +typedef double (*scale_fn)(double, double); +typedef int64_t (*wide_fn)(int64_t); +typedef int (*even_fn)(int); +typedef const void* (*greet_fn)(int); +typedef void (*release_fn)(const void*); +typedef const char* (*data_fn)(const void*); +typedef size_t (*length_fn)(const void*); +typedef int (*int_fn)(int); +typedef int64_t (*used_fn)(void); +typedef void (*deadline_fn)(int64_t); +typedef const AetherLibMeta* (*meta_fn)(void); +typedef int (*outcome_fn)(void); +typedef const char* (*outcome_text_fn)(void); +typedef int (*check_fn)(const char**); + +#define SPIN_N 200000000 + +static void* need(void* h, const char* n) { + void* p = lib_sym(h, n); + if (!p) { printf("missing %s\n", n); fflush(stdout); exit(2); } + return p; +} + +/* The library's recorded host ABI level: none (level 0) before 1.11. A + * host built with the headers of level 0 does not know the field (-1). */ +static int recorded_level(const AetherLibMeta* m) { +#if HEADER_LEVEL >= 1 + int minor = 0; + if (strncmp(m->schema_version, "1.", 2) == 0) minor = atoi(m->schema_version + 2); + return minor >= 11 ? m->host_abi : 0; +#else + (void)m; + return -1; +#endif +} + +/* What spin(SPIN_N) returns when nothing cuts it. */ +static int spin_full(void) { + uint64_t n = SPIN_N; + return (int)(((n * (n - 1)) / 2) % 1000003u); +} + +int main(int argc, char** argv) { + if (argc < 2) { printf("usage: host [its shared runtime]\n"); return 2; } + /* The shared runtime first, so the library binds to this one (a loaded + * image of its name), and the runtime's functions are in it; otherwise + * they are the library's own copy. */ + void* rt = argc > 2 ? lib_open_global(argv[2]) : NULL; + if (argc > 2 && !rt) { printf("cannot load the runtime %s\n", argv[2]); return 2; } + void* h = lib_open(argv[1]); + if (!h) { printf("cannot load the library\n"); return 2; } + if (!rt) rt = h; + + const AetherLibMeta* m = ((meta_fn)need(h, "aether_lib_meta"))(); + int level = recorded_level(m); + if (level >= 0) printf("catalog: schema %s, host ABI level %d\n", m->schema_version, level); + else printf("catalog: schema %s, read by a level-0 host\n", m->schema_version); + +#if HEADER_LEVEL >= 1 + if (level >= 1) { + check_fn check = (check_fn)need(h, "aether_mlib_check"); + const char* why = NULL; + if (check(&why) != 0) { + printf("preflight: refused, %s\n", why ? why : "(no reason)"); + fflush(stdout); + return 0; + } + printf("preflight: ok\n"); + } +#endif + + add_fn add = (add_fn)need(h, "aether_add"); + scale_fn scale = (scale_fn)need(h, "aether_scale"); + wide_fn wide = (wide_fn)need(h, "aether_wide"); + even_fn even = (even_fn)need(h, "aether_even"); + int a = add(2, 3); + double sc = scale(2.5, 3.0); + long long w = (long long)wide(3000); + int e1 = even(4), e2 = even(7); + printf("typed: add %d, scale %.2f, wide %lld, even %d %d\n", a, sc, w, e1, e2); + + greet_fn greet = (greet_fn)need(h, "aether_greet"); + release_fn release = (release_fn)need(h, m->string_release); + data_fn data = (data_fn)need(rt, "aether_string_data"); + length_fn length = (length_fn)need(rt, "aether_string_length"); + used_fn used = (used_fn)need(rt, "aether_caps_used_bytes"); + int64_t base = used(); + const void* s = greet(42); + int64_t holding = used(); + printf("strings: \"%.*s\", held %s", (int)length(s), data(s), holding > base ? "yes" : "no"); + release(s); + release(NULL); + for (int i = 0; i < 20000; i++) release(greet(i)); + printf(", 20000 more released, the runtime holds %lld bytes more\n", (long long)(used() - base)); + + int_fn checked = (int_fn)need(h, "aether_checked"); + int_fn spin = (int_fn)need(h, "aether_spin"); + if (level >= 1) { + outcome_fn outcome = (outcome_fn)need(rt, "aether_last_outcome"); + outcome_text_fn text = (outcome_text_fn)need(rt, "aether_last_outcome_text"); + int ok = checked(3); + int okc = outcome(); + int bad = checked(-1); + int badc = outcome(); + char why[64]; + snprintf(why, sizeof(why), "%s", text()); + int again = checked(5); + printf("errors: checked(3) %d outcome %d; checked(-1) %d outcome %d \"%s\"; then %d\n", + ok, okc, bad, badc, why, again); + deadline_fn set_deadline = (deadline_fn)need(rt, "aether_set_call_deadline"); + set_deadline(50); + int r = spin(SPIN_N); + int rc = outcome(); + set_deadline(0); + int after = spin(10); + printf("deadline: 50 ms ends a loop of 2e8: %d outcome %d, then %d\n", r, rc, after); + } else if (level == 0) { + printf("errors: level 0 has no outcomes; checked(3) %d\n", checked(3)); + printf("deadline: level 0, not asked; spin(10) %d\n", spin(10)); + } else { + printf("errors: this header has no outcomes; checked(3) %d\n", checked(3)); + deadline_fn set_deadline = (deadline_fn)lib_sym(rt, "aether_set_call_deadline"); + if (set_deadline) { + set_deadline(50); + int r = spin(SPIN_N); + set_deadline(0); + int after = spin(10); + printf("deadline: 50 ms cuts a loop of 2e8: %s, then %d\n", r != spin_full() ? "yes" : "no", after); + } else { + printf("deadline: the library exports no host API\n"); + } + } + + printf("unload: %d\n", lib_close(h)); + fflush(stdout); + return 0; +} diff --git a/tests/integration/abi_matrix/mlib.ae b/tests/integration/abi_matrix/mlib.ae new file mode 100644 index 000000000..7410c29c4 --- /dev/null +++ b/tests/integration/abi_matrix/mlib.ae @@ -0,0 +1,57 @@ +// The library of the cross-version matrix (#2957): built by this Aether and +// by the release before it, and called across the two. It uses only what +// both versions compile: typed values, a string handed to the caller, +// a panic, a loop a deadline cuts, and memory a cap or an account charges. +import std.string + +add(a: int, b: int) -> int { + return a + b +} + +scale(x: float, k: float) -> float { + return x * k +} + +wide(n: long) -> long { + return n * 1000000 +} + +even(n: int) -> bool { + return n % 2 == 0 +} + +// A string the caller owns and gives back through the library's release. +greet(n: int) -> string { + return string.concat("hello ", string.from_int(n)) +} + +// A panic for a negative n. +checked(n: int) -> int { + if n < 0 { + panic("negative") + } + return n * 2 +} + +// A loop a deadline or a cancellation cuts. +spin(n: int) -> int { + i = 0 + total = 0 + while i < n { + total = (total + i) % 1000003 + i = i + 1 + } + return total +} + +// The length of a string of n * 16 bytes, which the call frees: memory a +// cap or an account charges. +grow(n: int) -> int { + s = "" + i = 0 + while i < n { + s = string.concat(s, "0123456789abcdef") + i = i + 1 + } + return string.length(s) +} diff --git a/tests/integration/abi_matrix/test_abi_matrix.sh b/tests/integration/abi_matrix/test_abi_matrix.sh new file mode 100644 index 000000000..e87fb4512 --- /dev/null +++ b/tests/integration/abi_matrix/test_abi_matrix.sh @@ -0,0 +1,205 @@ +#!/bin/sh +# #2957: the cross-version matrix of docs/abi-policy.md. mlib.ae is built by +# this Aether and by a release before it (AETHER_ABI_OLD_HOME, an extracted +# release archive: CI and the release workflow download the latest +# published release), and each pair the policy supports is run: +# +# C hosts (host.c, plugin style, everything by name): +# this header, this library the level-1 baseline +# this header, the old library at the level it records +# the old header, this library a host at an older level +# two.c: the old and the new library in one process, each with its own +# runtime: calls interleaved, strings given back to their own library, +# a panic in one, an account of the new one suspended while the old one +# runs and resumed, both unloaded +# Aether consumers (app.ae, a binary import): +# this importer, the old library; the old importer, this library +# The shared runtime: +# this host, this library on this shared runtime the baseline +# this host, the old library on the old shared runtime +# this host, this library on the old shared runtime refused: at load +# (a level-0 runtime +# lacks what it +# calls) or by the +# library's preflight +# this importer, the old shared-runtime library refused at import +# +# What each prints depends on the host ABI levels alone (the old library's, +# read from its catalog, and the old header's), never on the versions, so +# the matrix holds against whichever release came before. +# +# Fixtures, not tests: mlib.ae is a library with no main and app.ae imports +# it; the driver builds both. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +AE="$ROOT/build/ae" +CC="${CC:-cc}" +export AETHER_STACK_TRACE=0 + +OLD_HOME="${AETHER_ABI_OLD_HOME:-}" +if [ -z "$OLD_HOME" ]; then + echo " [SKIP] abi_matrix: AETHER_ABI_OLD_HOME names no earlier release (CI downloads the latest one; docs/abi-policy.md)" + exit 0 +fi +[ -x "$AE" ] || { echo " [SKIP] abi_matrix: $AE not built"; exit 0; } + +case "$(uname -s 2>/dev/null)" in + MINGW*|MSYS*|CYGWIN*|Windows_NT) ext=dll; exe=.exe; dl="" ;; + Darwin) ext=dylib; exe=""; dl="" ;; + Linux) ext=so; exe=""; dl="-ldl" ;; + *) ext=so; exe=""; dl="" ;; +esac +OLD_AE="$OLD_HOME/bin/ae$exe" +[ -x "$OLD_AE" ] || { echo " [FAIL] abi_matrix: no ae in $OLD_HOME/bin"; exit 1; } +old_version="$(tr -d '\r\n' < "$OLD_HOME/VERSION" 2>/dev/null)" + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP" || true' EXIT +fail=0 + +# $1 = what failed, $2 = a log to show +bad() { + echo " [FAIL] abi_matrix: $1" + [ -n "$2" ] && [ -f "$2" ] && tail -8 "$2" | sed 's/^/ /' + fail=1 +} + +# $1 = label, $2 = what it printed, $3 = what it should +expect() { + if [ "$2" != "$3" ]; then + echo " [FAIL] abi_matrix: $1:" + echo " got:"; printf '%s\n' "$2" | sed 's/^/ /' + echo " want:"; printf '%s\n' "$3" | sed 's/^/ /' + fail=1 + fi +} + +# $1 = dir, $2 = toolchain home, $3 = its ae, $4 = extra flags +build_lib() { + mkdir -p "$1" + # shellcheck disable=SC2086 + (cd "$1" && AETHER_HOME="$2" "$3" build --emit=lib $4 "$SCRIPT_DIR/mlib.ae" -o "$1/libmlib.$ext" \ + > "$1/build.log" 2>&1) || { bad "mlib.ae did not build in $1" "$1/build.log"; return 1; } +} + +build_lib "$TMP/new" "$ROOT" "$AE" "" || exit 1 +build_lib "$TMP/old" "$OLD_HOME" "$OLD_AE" "" || exit 1 + +# shellcheck disable=SC2086 +"$CC" -O1 -I"$ROOT/include" -I"$ROOT/runtime" "$SCRIPT_DIR/host.c" -o "$TMP/host_new" $dl \ + > "$TMP/cc_host_new.log" 2>&1 || { bad "host.c did not build with this header" "$TMP/cc_host_new.log"; exit 1; } +# shellcheck disable=SC2086 +"$CC" -O1 -I"$OLD_HOME/include/aether" -I"$OLD_HOME/include/aether/runtime" "$SCRIPT_DIR/host.c" \ + -o "$TMP/host_old" $dl > "$TMP/cc_host_old.log" 2>&1 || + { bad "host.c did not build with the old header" "$TMP/cc_host_old.log"; exit 1; } +# shellcheck disable=SC2086 +"$CC" -O1 -I"$ROOT/include" -I"$ROOT/runtime" "$SCRIPT_DIR/two.c" -o "$TMP/two" $dl \ + > "$TMP/cc_two.log" 2>&1 || { bad "two.c did not build" "$TMP/cc_two.log"; exit 1; } + +# The schema minor is the library's; the rest is the level's. +norm() { tr -d '\r' | sed 's/schema 1\.[0-9]*/schema 1.x/'; } + +common_lines='typed: add 5, scale 7.50, wide 3000000000, even 1 0 +strings: "hello 42", held yes, 20000 more released, the runtime holds 0 bytes more' +level1='catalog: schema 1.x, host ABI level 1 +preflight: ok +'"$common_lines"' +errors: checked(3) 6 outcome 0; checked(-1) 0 outcome 1 "negative"; then 10 +deadline: 50 ms ends a loop of 2e8: 0 outcome 2, then 45 +unload: 0' +level0='catalog: schema 1.x, host ABI level 0 +'"$common_lines"' +errors: level 0 has no outcomes; checked(3) 6 +deadline: level 0, not asked; spin(10) 45 +unload: 0' +old_host='catalog: schema 1.x, read by a level-0 host +'"$common_lines"' +errors: this header has no outcomes; checked(3) 6 +deadline: 50 ms cuts a loop of 2e8: yes, then 45 +unload: 0' + +expect "this header, this library" "$("$TMP/host_new" "$TMP/new/libmlib.$ext" 2>"$TMP/nn.err" | norm)" "$level1" + +got="$("$TMP/host_new" "$TMP/old/libmlib.$ext" 2>"$TMP/no.err" | norm)" +old_level="$(printf '%s\n' "$got" | sed -n 's/^catalog: schema 1\.x, host ABI level \([0-9]*\)$/\1/p')" +case "$old_level" in + 0) expect "this header, the $old_version library (level 0)" "$got" "$level0" ;; + 1) expect "this header, the $old_version library (level 1)" "$got" "$level1" ;; + *) bad "the $old_version library records no level this matrix knows: $(printf '%s\n' "$got" | head -1)" ;; +esac + +if grep -q '^#define AETHER_HOST_ABI ' "$OLD_HOME/include/aether/libaether.h"; then + want_old_host="$level1" +else + want_old_host="$old_host" +fi +expect "the $old_version header, this library" "$("$TMP/host_old" "$TMP/new/libmlib.$ext" 2>"$TMP/on.err" | norm)" "$want_old_host" + +cp "$TMP/old/libmlib.$ext" "$TMP/libmlib_old.$ext" +cp "$TMP/new/libmlib.$ext" "$TMP/libmlib_new.$ext" +expect "two libraries in one process" "$("$TMP/two" "$TMP/libmlib_old.$ext" "$TMP/libmlib_new.$ext" 2>"$TMP/two.err" | tr -d '\r')" \ +'typed: old 3, new 7, old 11 +strings: hello 1, hello 2 +errors: new checked(-1) 0 outcome 1; then old 6, new 8 +account: holds bytes, the same while the old library ran (65536): yes; resumed, past its allowance 0 outcome 4, within it 64; at zero yes, freed +unload: new 0, old still works 15, old 0' + +# Aether consumers. $1 = label, $2 = library dir, $3 = toolchain home, $4 = its ae +app_want='typed: add 5, wide 7000000, even true +strings: hello 9, 2000 more read: 18890 bytes' +run_app() { + cp "$SCRIPT_DIR/app.ae" "$2/app.ae" + if ! (cd "$2" && AETHER_HOME="$3" "$4" build app.ae -o app > app_build.log 2>&1); then + bad "$1: app.ae did not build" "$2/app_build.log" + return + fi + expect "$1" "$(cd "$2" && PATH="$2:$PATH" LD_LIBRARY_PATH="$2${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \ + DYLD_LIBRARY_PATH="$2${DYLD_LIBRARY_PATH:+:$DYLD_LIBRARY_PATH}" ./app$exe 2>"$2/app.err" | tr -d '\r')" "$app_want" +} +run_app "this importer, the $old_version library" "$TMP/old" "$ROOT" "$AE" +run_app "the $old_version importer, this library" "$TMP/new" "$OLD_HOME" "$OLD_AE" + +# The shared runtime. $1 = case dir, $2 = library dir, $3 = runtime dir: +# the runtime's files and the host in one directory, as an application +# ships them, the runtime loaded first. +rt_file() { ls "$1/"aether.dll "$1/"libaether.dylib "$1/"libaether.so 2>/dev/null | head -1; } +run_shared() { + mkdir -p "$1" + cp "$3/"* "$1/" 2>/dev/null + cp "$2/libmlib.$ext" "$TMP/host_new$exe" "$1/" + (cd "$1" && PATH="$1:$PATH" LD_LIBRARY_PATH="$1${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \ + DYLD_LIBRARY_PATH="$1${DYLD_LIBRARY_PATH:+:$DYLD_LIBRARY_PATH}" \ + "./host_new$exe" "$1/libmlib.$ext" "$(rt_file "$1")" 2>"$1/err" | norm) +} +if [ -n "$(rt_file "$ROOT/build/shared")" ] && [ -n "$(rt_file "$OLD_HOME/lib/shared")" ]; then + build_lib "$TMP/newsh" "$ROOT" "$AE" "--shared-runtime" && + build_lib "$TMP/oldsh" "$OLD_HOME" "$OLD_AE" "--shared-runtime" && { + expect "this library on this shared runtime" "$(run_shared "$TMP/sh_nn" "$TMP/newsh" "$ROOT/build/shared")" "$level1" + got="$(run_shared "$TMP/sh_oo" "$TMP/oldsh" "$OLD_HOME/lib/shared")" + if [ "$old_level" = 0 ]; then want="$level0"; else want="$level1"; fi + expect "the $old_version library on its shared runtime" "$got" "$want" + got="$(run_shared "$TMP/sh_no" "$TMP/newsh" "$OLD_HOME/lib/shared")" + case "$got" in + "cannot load the library") [ "$old_level" = 0 ] || + bad "this library on the $old_version shared runtime (level $old_level): refused at load, not by its preflight" ;; + "catalog: schema 1.x, host ABI level 1 +preflight: refused, the library was made for Aether runtime "*) ;; + *) bad "this library on the $old_version shared runtime is not refused:" + printf '%s\n' "$got" | sed 's/^/ /' ;; + esac + if (cd "$TMP/oldsh" && cp "$SCRIPT_DIR/app.ae" . && AETHER_HOME="$ROOT" "$AE" build app.ae -o app > app_build.log 2>&1); then + bad "this importer took the $old_version shared-runtime library" + elif ! grep -q "host ABI level 0\|was made for the Aether runtime" "$TMP/oldsh/app_build.log"; then + bad "this importer's refusal of the $old_version shared-runtime library says why" "$TMP/oldsh/app_build.log" + fi + shared_note=", and on the shared runtimes" + } +else + shared_note=" (a shared runtime missing: those legs skipped)" +fi + +if [ "$fail" = 0 ]; then + echo " [PASS] abi_matrix: against $old_version (host ABI level $old_level): C hosts of either header and level, two libraries with a runtime each in one process (an account suspended and resumed beside the old one), and Aether consumers both ways run typed calls, strings given back with no byte kept, errors and deadlines at their levels$shared_note; an unsupported pair is refused" +fi +exit $fail diff --git a/tests/integration/abi_matrix/two.c b/tests/integration/abi_matrix/two.c new file mode 100644 index 000000000..f682af314 --- /dev/null +++ b/tests/integration/abi_matrix/two.c @@ -0,0 +1,126 @@ +/* Two libraries in one process across versions (#2957): mlib.ae built by + * the release before this Aether (argv[1], loaded first) and by this one + * (argv[2]), each with a runtime of its own. The old copy has no execution + * accounts and no shared facilities; the new one's work beside it. + * + * typed: calls into both, interleaved + * strings: each string given back to the library that made it + * errors: a panic in the new library ends its call; both go on + * account: the new library's account charges what it holds, keeps it + * while suspended (the old library runs in between, charging + * nothing to it), refuses past its allowance when resumed, and + * is freed at zero + * unload: both unload, the new one first + */ +#include +#include +#include +#include +#include "libaether.h" +#include "aether_lib_meta.h" +#ifdef _WIN32 +#include +static void* lib_open(const char* p) { return (void*)LoadLibraryA(p); } +static void* lib_sym(void* h, const char* n) { return (void*)GetProcAddress((HMODULE)h, n); } +static int lib_close(void* h) { return FreeLibrary((HMODULE)h) ? 0 : -1; } +#else +#include +static void* lib_open(const char* p) { return dlopen(p, RTLD_NOW | RTLD_LOCAL); } +static void* lib_sym(void* h, const char* n) { return dlsym(h, n); } +static int lib_close(void* h) { return dlclose(h); } +#endif + +typedef int (*add_fn)(int, int); +typedef const void* (*greet_fn)(int); +typedef void (*release_fn)(const void*); +typedef const char* (*data_fn)(const void*); +typedef int (*int_fn)(int); +typedef int (*outcome_fn)(void); +typedef const AetherLibMeta* (*meta_fn)(void); +typedef AetherExecAccount* (*new_fn)(const AetherExecLimits*); +typedef void (*free_fn)(AetherExecAccount*); +typedef int (*use_fn)(AetherExecAccount*, AetherExecAccount**); +typedef uint64_t (*used_fn)(const AetherExecAccount*); + +typedef struct { + void* h; + add_fn add; + greet_fn greet; + release_fn release; + data_fn data; + int_fn checked, grow; +} Lib; + +static void* need(void* h, const char* n) { + void* p = lib_sym(h, n); + if (!p) { printf("missing %s\n", n); fflush(stdout); exit(2); } + return p; +} + +static Lib load(const char* path) { + Lib l; + l.h = lib_open(path); + if (!l.h) { printf("cannot load %s\n", path); fflush(stdout); exit(2); } + l.add = (add_fn)need(l.h, "aether_add"); + l.greet = (greet_fn)need(l.h, "aether_greet"); + l.release = (release_fn)need(l.h, ((meta_fn)need(l.h, "aether_lib_meta"))()->string_release); + l.data = (data_fn)need(l.h, "aether_string_data"); + l.checked = (int_fn)need(l.h, "aether_checked"); + l.grow = (int_fn)need(l.h, "aether_grow"); + return l; +} + +int main(int argc, char** argv) { + if (argc < 3) { printf("usage: two \n"); return 2; } + Lib o = load(argv[1]); + Lib n = load(argv[2]); + + printf("typed: old %d, new %d, old %d\n", o.add(1, 2), n.add(3, 4), o.add(5, 6)); + + const void* so = o.greet(1); + const void* sn = n.greet(2); + printf("strings: %s, %s\n", o.data(so), n.data(sn)); + o.release(so); + n.release(sn); + + outcome_fn outcome = (outcome_fn)need(n.h, "aether_last_outcome"); + int bad = n.checked(-1); + int badc = outcome(); + printf("errors: new checked(-1) %d outcome %d; then old %d, new %d\n", bad, badc, o.checked(3), n.checked(4)); + + new_fn account_new = (new_fn)need(n.h, "aether_exec_account_new"); + free_fn account_free = (free_fn)need(n.h, "aether_exec_account_free"); + use_fn use = (use_fn)need(n.h, "aether_exec_use"); + used_fn used = (used_fn)need(n.h, "aether_exec_account_used"); + AetherExecLimits lim = { 1 << 20, 0, AETHER_DEADLINE_ELAPSED, NULL }; + AetherExecAccount* a = account_new(&lim); + AetherExecAccount* prev = NULL; + use(a, &prev); + const void* kept = n.greet(3); + uint64_t held = used(a); + use(prev, NULL); + /* Suspended: the old library runs, charging nothing to the account. */ + const void* other = o.greet(4); + int og = o.grow(4096); + o.release(other); + uint64_t while_out = used(a); + use(a, &prev); + int big = n.grow(1 << 17); + int bigc = outcome(); + int small = n.grow(4); + n.release(kept); + uint64_t after = used(a); + use(prev, NULL); + account_free(a); + printf("account: holds %s, the same while the old library ran (%d): %s; " + "resumed, past its allowance %d outcome %d, within it %d; at zero %s, freed\n", + held > 0 ? "bytes" : "nothing", og, while_out == held ? "yes" : "no", big, bigc, small, + after == 0 ? "yes" : "no"); + + int un = lib_close(n.h); + int still = o.add(7, 8); + int uo = lib_close(o.h); + printf("unload: new %d, old still works %d, old %d\n", un, still, uo); + fflush(stdout); + return 0; +} diff --git a/tests/integration/abi_policy/abi_lib.ae b/tests/integration/abi_policy/abi_lib.ae new file mode 100644 index 000000000..da5bf9891 --- /dev/null +++ b/tests/integration/abi_policy/abi_lib.ae @@ -0,0 +1,14 @@ +// A library whose generated C a C host compiles for another runtime ABI +// than the runtime it links (#2957): none of its code may run. Each export +// tells the host it ran. +extern host_ran() + +bump(n: int) -> int { + host_ran() + return n + 1 +} + +greet() -> string { + host_ran() + return "hello" +} diff --git a/tests/integration/abi_policy/app.ae b/tests/integration/abi_policy/app.ae new file mode 100644 index 000000000..f51dc861e --- /dev/null +++ b/tests/integration/abi_policy/app.ae @@ -0,0 +1,7 @@ +// The importer of the hand-made catalog (#2957): it builds only when the +// importer accepts the catalog. +import fake + +main() { + println("add: ${fake.add(2, 3)}") +} diff --git a/tests/integration/abi_policy/fake_catalog.c b/tests/integration/abi_policy/fake_catalog.c new file mode 100644 index 000000000..54596da07 --- /dev/null +++ b/tests/integration/abi_policy/fake_catalog.c @@ -0,0 +1,36 @@ +/* A library with a hand-made catalog (#2957), so the importer's checks can + * be shown on catalogs no Aether of this version writes: another major, a + * duty this importer does not know, a newer minor with nothing new to do, + * and shared-runtime libraries of another runtime ABI or of none recorded. + * Built with -DSCHEMA="..." -DREQUIRES="..." (NULL for none) and, for a + * shared-runtime one, -DSHARED -DRUNTIME_ABI="..." (NULL for none). */ +#include +#include "aether_lib_meta.h" + +#ifndef REQUIRES +#define REQUIRES NULL +#endif +#ifndef RUNTIME_ABI +#define RUNTIME_ABI NULL +#endif +#ifdef SHARED +#define RUNTIME "shared" +#else +#define RUNTIME NULL +#endif + +int aether_add(int a, int b) { return a + b; } +int aether_fake_outcome(void) { return 0; } +const char* aether_fake_outcome_text(void) { return ""; } + +static const AetherLibFunction fns[] = { + { "add", "aether_add", "(int, int) -> int", "fake.ae", 1 }, +}; + +static const AetherLibMeta meta = { + SCHEMA, "9.9.9", "fake.ae", 1, fns, 0, NULL, 0, NULL, 0, NULL, + NULL, NULL, NULL, RUNTIME, NULL, 0, NULL, NULL, "aether_fake_outcome", + 1, RUNTIME_ABI, REQUIRES, +}; + +const AetherLibMeta* aether_lib_meta(void) { return &meta; } diff --git a/tests/integration/abi_policy/foreign_sbx.c b/tests/integration/abi_policy/foreign_sbx.c new file mode 100644 index 000000000..8e2184cd5 --- /dev/null +++ b/tests/integration/abi_policy/foreign_sbx.c @@ -0,0 +1,58 @@ +/* A library with a sandbox rendezvous of another layout (#2957), with + * working members, which its record names as a copy of another Aether names + * its own (aether_rv_mark.h, #3445): by default the + * version-1 one, which had no size; with -DRV_VERSION=n a later one, with a + * size and a member more. A copy that finds it first refuses to share + * levels with it, says so once, and reads nothing of it but its version + * and size: its level count is never called. */ +#include +#include +#include "aether_rv_mark.h" +#if defined(_WIN32) +#define EXPORT __declspec(dllexport) +#else +#define EXPORT __attribute__((visibility("default"))) +#endif + +#ifndef RV_VERSION +#define RV_VERSION 1 +#endif + +static atomic_int g_threads; +static atomic_int g_calls; +static _Thread_local int t_levels; + +static int levels(int delta) { + atomic_fetch_add(&g_calls, 1); + int before = t_levels; + t_levels += delta; + if (before == 0 && t_levels > 0) atomic_fetch_add(&g_threads, 1); + else if (before > 0 && t_levels == 0) atomic_fetch_sub(&g_threads, 1); + return t_levels; +} + +#if RV_VERSION == 1 +static const struct { int version; atomic_int* threads; int (*levels)(int); } g_rv = { + 1, &g_threads, levels +}; +#else +typedef struct { + uint32_t version; + uint32_t size; + atomic_int* threads; + int (*levels)(int); + void* more; +} Later; +static const Later g_rv = { RV_VERSION, (uint32_t)sizeof(Later), &g_threads, levels, 0 }; +#endif + +AETHER_RV_FN const void* foreign_sandbox_rendezvous(void) { + return &g_rv; +} + +AETHER_RV_MARK(foreign_sandbox_mark, AETHER_RV_KIND_SANDBOX, foreign_sandbox_rendezvous); + +/* How many times a copy called this rendezvous's level count. */ +EXPORT int foreign_sbx_calls(void) { + return atomic_load(&g_calls); +} diff --git a/tests/integration/abi_policy/host_abi.c b/tests/integration/abi_policy/host_abi.c new file mode 100644 index 000000000..b0396a1b7 --- /dev/null +++ b/tests/integration/abi_policy/host_abi.c @@ -0,0 +1,46 @@ +/* A C host of abi_lib.ae (#2957). Built twice: with the library's C compiled + * as the runtime it links was (the same runtime ABI), and with it compiled + * -DAETHER_DEBUG_MAILBOX, a flag that changes a layout generated C reads, + * against a runtime built without it. Then every call returns its zero + * value with AETHER_INCOMPATIBLE (6), none of the library's code runs, and + * the preflight and the outcome's text name both runtime ABIs. + * + * the same runtime: check 0 / bump: 42 outcome 0 / greet: a string + * outcome 0 / ran 2 + * another runtime ABI: check -1, names both: yes / bump: 0 outcome 6, + * names both: yes / greet: (null) outcome 6 / ran 0 + */ +#include +#include +#include +#include "libaether.h" + +extern int32_t aether_bump(int32_t n); +extern const void* aether_greet(void); +extern int aether_abi_lib_check(const char** why); +extern const char* aether_runtime_abi(void); +extern void aether_abi_lib_string_release(const void* s); + +static int g_ran; +void host_ran(void) { g_ran++; } + +static int names_both(const char* text) { + return text && strstr(text, aether_runtime_abi()) && strstr(text, "+debug-mailbox"); +} + +int main(void) { + const char* why = NULL; + int c = aether_abi_lib_check(&why); + if (c == 0) printf("check 0\n"); + else printf("check %d, names both: %s\n", c, names_both(why) ? "yes" : "no"); + int32_t b = aether_bump(41); + int o = aether_last_outcome(); + if (o == AETHER_OK) printf("bump: %d outcome 0\n", b); + else printf("bump: %d outcome %d, names both: %s\n", b, o, names_both(aether_last_outcome_text()) ? "yes" : "no"); + const void* g = aether_greet(); + printf("greet: %s outcome %d\n", g ? "a string" : "(null)", aether_last_outcome()); + if (g) aether_abi_lib_string_release(g); + printf("ran %d\n", g_ran); + fflush(stdout); + return 0; +} diff --git a/tests/integration/abi_policy/sbx_copy.c b/tests/integration/abi_policy/sbx_copy.c new file mode 100644 index 000000000..80e0c8d19 --- /dev/null +++ b/tests/integration/abi_policy/sbx_copy.c @@ -0,0 +1,34 @@ +/* A runtime copy in a library (#2957, #3437), which sbx_driver.c loads after + * a library exporting a sandbox rendezvous of another layout. Its copy + * refuses to share levels with it, says so once on stderr, enforces its own, + * and never calls the foreign one. + * + * In a library, not in the host program: where executables export their + * symbols (macOS), a program that links the runtime is the first image a + * copy looks in, so its own rendezvous was found and the foreign one never + * read. + * + * before: 1 / in a deny-all level: 0 / after: 1 / foreign levels called: 0 + */ +#include +#include "aether_sandbox.h" +#if defined(_WIN32) +#define EXPORT __declspec(dllexport) +#else +#define EXPORT __attribute__((visibility("default"))) +#endif + +extern void* list_new(void); +extern void list_free(void* l); + +EXPORT void sbx_run(int (*calls)(void)) { + printf("before: %d\n", aether_sandbox_check("env", "PATH")); + void* nothing = list_new(); + aether_sandbox_push(nothing); + printf("in a deny-all level: %d\n", aether_sandbox_check("env", "PATH")); + aether_sandbox_pop(); + list_free(nothing); + printf("after: %d\n", aether_sandbox_check("env", "PATH")); + printf("foreign levels called: %d\n", calls()); + fflush(stdout); +} diff --git a/tests/integration/abi_policy/sbx_driver.c b/tests/integration/abi_policy/sbx_driver.c new file mode 100644 index 000000000..d9e247c43 --- /dev/null +++ b/tests/integration/abi_policy/sbx_driver.c @@ -0,0 +1,41 @@ +/* A host with no runtime of its own (#3437): loads argv[1], a library + * with a sandbox rendezvous of another layout (foreign_sbx.c, by its record), global, + * then argv[2], sbx_copy.c's library with a runtime copy, local, and runs + * that copy's checks. The foreign rendezvous is then the first a copy finds + * on every platform. */ +#include +#include +#ifdef _WIN32 +#include +static void* load_global(const char* p) { return (void*)LoadLibraryA(p); } +static void* load_local(const char* p) { return (void*)LoadLibraryA(p); } +static void* sym(void* h, const char* n) { + FARPROC f = GetProcAddress((HMODULE)h, n); + void* v; + memcpy(&v, &f, sizeof(v)); + return v; +} +#else +#include +static void* load_global(const char* p) { return dlopen(p, RTLD_NOW | RTLD_GLOBAL); } +static void* load_local(const char* p) { return dlopen(p, RTLD_NOW | RTLD_LOCAL); } +static void* sym(void* h, const char* n) { return dlsym(h, n); } +#endif + +typedef int (*calls_fn)(void); +typedef void (*run_fn)(calls_fn); + +int main(int argc, char** argv) { + if (argc < 3) { printf("usage: sbx_driver \n"); return 2; } + void* f = load_global(argv[1]); + void* c = f ? load_local(argv[2]) : NULL; + void* pc = f ? sym(f, "foreign_sbx_calls") : NULL; + void* pr = c ? sym(c, "sbx_run") : NULL; + if (!pc || !pr) { printf("cannot load the libraries\n"); return 2; } + calls_fn calls; + run_fn run; + memcpy(&calls, &pc, sizeof(pc)); + memcpy(&run, &pr, sizeof(pr)); + run(calls); + return 0; +} diff --git a/tests/integration/abi_policy/test_abi_policy.sh b/tests/integration/abi_policy/test_abi_policy.sh new file mode 100644 index 000000000..1a472978c --- /dev/null +++ b/tests/integration/abi_policy/test_abi_policy.sh @@ -0,0 +1,185 @@ +#!/bin/sh +# #2957: what a shipped library promises, checked at each boundary the +# policy (docs/abi-policy.md) names, before any incompatible call: +# - the importer: a catalog of another major, one that requires a duty this +# importer does not know (refused, naming it, both versions and the fix), +# one of a newer minor with nothing new to do (read), and shared-runtime +# libraries of another runtime ABI or of none recorded (refused); +# - the first call: a library compiled for another runtime ABI than the +# runtime it links returns zero values with AETHER_INCOMPATIBLE, none of +# its code run, and its preflight says why; +# - compile time: generated C compiled against another runtime's headers; +# - the rendezvous: a sandbox rendezvous of another layout loaded first. +# +# Fixtures, not tests: app.ae imports the hand-made catalog (fake_catalog.c) +# and abi_lib.ae is a library for the C host; the driver builds them. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +AE="$ROOT/build/ae" +AETHERC="$ROOT/build/aetherc" +CC="${CC:-cc}" +export AETHER_HOME="$ROOT" +export AETHER_STACK_TRACE=0 + +[ -x "$AE" ] || { echo " [SKIP] abi_policy: $AE not built"; exit 0; } + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP" || true' EXIT +fail=0 +case "$(uname -s 2>/dev/null)" in + MINGW*|MSYS*|CYGWIN*|Windows_NT) ext=dll; exe=.exe; dl="" ;; + Darwin) ext=dylib; exe=""; dl="" ;; + Linux) ext=so; exe=""; dl="-ldl" ;; + *) ext=so; exe=""; dl="" ;; +esac + +# $1 = what failed, $2 = a log to show +bad() { + echo " [FAIL] abi_policy: $1" + [ -n "$2" ] && sed 's/^/ /' "$2" | head -8 + fail=1 +} + +# $1 = case dir, the rest = -D flags for the hand-made catalog +fake() { + d="$TMP/$1"; shift + mkdir -p "$d" + if ! "$CC" -shared -fPIC -I"$ROOT/runtime" "$@" "$SCRIPT_DIR/fake_catalog.c" -o "$d/libfake.$ext" \ + > "$d/cc.log" 2>&1; then + bad "the hand-made catalog did not build" "$d/cc.log" + return 1 + fi + cp "$SCRIPT_DIR/app.ae" "$d/app.ae" +} + +# $1 = case dir: 0 when the importer built the program +import_it() { + (cd "$TMP/$1" && "$AE" build app.ae -o app > build.log 2>&1) +} + +# $1 = case dir, $2 = a fixed string its build log must hold, $3 = label +refused() { + if import_it "$1"; then + bad "$3: imported" + elif ! grep -qF "$2" "$TMP/$1/build.log"; then + bad "$3: the refusal does not say \"$2\"" "$TMP/$1/build.log" + fi +} + +if fake major -DSCHEMA='"2.0"' -DREQUIRES='"outcome_check"'; then + refused major "reads schema 1 only" "a schema 2 catalog" +fi + +if fake duty -DSCHEMA='"1.11"' -DREQUIRES='"outcome_check,warp_drive"'; then + refused duty "requires \`warp_drive\` of its importer" "an unknown duty" + grep -qF "built by Aether 9.9.9" "$TMP/duty/build.log" || bad "the duty refusal names the library's version" "$TMP/duty/build.log" + grep -qF "or rebuild the library" "$TMP/duty/build.log" || bad "the duty refusal names the fix" "$TMP/duty/build.log" +fi + +if fake newer -DSCHEMA='"1.12"' -DREQUIRES='"outcome_check"'; then + if import_it newer; then + got="$(cd "$TMP/newer" && PATH="$TMP/newer:$PATH" LD_LIBRARY_PATH="$TMP/newer" \ + DYLD_LIBRARY_PATH="$TMP/newer" ./app$exe 2>&1 | tr -d '\r')" + [ "$got" = "add: 5" ] || bad "a newer minor with known duties runs: got \"$got\"" + else + bad "a newer minor with only known duties was refused" "$TMP/newer/build.log" + fi +fi + +if fake otherabi -DSHARED -DSCHEMA='"1.11"' -DREQUIRES='"outcome_check,shared_runtime,runtime_abi"' \ + -DRUNTIME_ABI='"0.0.1/abi9"'; then + refused otherabi "was made for the Aether runtime 0.0.1/abi9" "a shared runtime of another ABI" +fi + +if fake legacy -DSHARED -DSCHEMA='"1.5"'; then + refused legacy "host ABI level 0" "a legacy shared-runtime library" +fi + +# The first call: the same library compiled as its runtime was, and for +# another runtime ABI. +got_same="" +got_other="" +if ! "$AETHERC" --emit=lib --with=extern "$SCRIPT_DIR/abi_lib.ae" "$TMP/abi_lib.c" > "$TMP/abi_lib.log" 2>&1; then + bad "aetherc --emit=lib failed" "$TMP/abi_lib.log" +else + for v in same other; do + flag="" + [ $v = other ] && flag="-DAETHER_DEBUG_MAILBOX" + # shellcheck disable=SC2046,SC2086 + if ! "$CC" -O1 $flag -I"$ROOT/include" -I"$ROOT/runtime" $("$AE" cflags --cflags) \ + "$TMP/abi_lib.c" "$SCRIPT_DIR/host_abi.c" -o "$TMP/host_$v" \ + $("$AE" cflags --libs) > "$TMP/host_$v.log" 2>&1; then + bad "the $v-runtime host did not build" "$TMP/host_$v.log" + continue + fi + out="$("$TMP/host_$v" 2>"$TMP/host_$v.err" | tr -d '\r')" + if [ $v = same ]; then got_same="$out"; else got_other="$out"; fi + done + want_same='check 0 +bump: 42 outcome 0 +greet: a string outcome 0 +ran 2' + want_other='check -1, names both: yes +bump: 0 outcome 6, names both: yes +greet: (null) outcome 6 +ran 0' + if [ "$got_same" != "$want_same" ]; then + bad "the same runtime ABI:" + printf '%s\n' "$got_same" | sed 's/^/ /' + fi + if [ "$got_other" != "$want_other" ]; then + bad "another runtime ABI:" + printf '%s\n' "$got_other" | sed 's/^/ /' + fi + [ "$(grep -c "was made for Aether runtime" "$TMP/host_other.err" 2>/dev/null)" = 1 ] || + bad "the refusal is reported once" "$TMP/host_other.err" +fi + +# Compile time: the generated C against headers of another runtime ABI. +mkdir -p "$TMP/inc" +sed 's/^#define AETHER_RUNTIME_ABI_REV .*/#define AETHER_RUNTIME_ABI_REV 99/' "$ROOT/runtime/aether_abi.h" > "$TMP/inc/aether_abi.h" +cp "$ROOT/runtime/aether_version.h" "$TMP/inc/" +# shellcheck disable=SC2046 +if "$CC" -c -I"$TMP/inc" -I"$ROOT/include" -I"$ROOT/runtime" $("$AE" cflags --cflags) \ + "$TMP/abi_lib.c" -o "$TMP/abi_lib.o" > "$TMP/compile_other.log" 2>&1; then + bad "generated C compiled against another runtime's headers" +elif ! grep -qF "generated by aetherc" "$TMP/compile_other.log"; then + bad "the compile-time refusal says why" "$TMP/compile_other.log" +fi + +# The rendezvous: a sandbox rendezvous of another layout loaded first, the +# one before this runtime's (version 1, no size) and a later one, then a +# library with a runtime copy (sbx_copy.c), from a host with no runtime of +# its own (sbx_driver.c), so the foreign one is first on every platform +# (#3437). +# shellcheck disable=SC2046 +if "$CC" -shared -fPIC -O1 -DAETHER_HAS_SANDBOX -I"$ROOT/include" -I"$ROOT/runtime" $("$AE" cflags --cflags) \ + "$SCRIPT_DIR/sbx_copy.c" -o "$TMP/sbx_copy.$ext" $("$AE" cflags --libs) $dl > "$TMP/sbx_cc.log" 2>&1 && + "$CC" -O1 "$SCRIPT_DIR/sbx_driver.c" -o "$TMP/sbx_driver" $dl >> "$TMP/sbx_cc.log" 2>&1; then + for rvv in 1 9; do + if ! "$CC" -shared -fPIC -I"$ROOT/runtime" -DRV_VERSION=$rvv "$SCRIPT_DIR/foreign_sbx.c" -o "$TMP/foreign_sbx$rvv.$ext" \ + > "$TMP/sbx_cc$rvv.log" 2>&1; then + bad "the version-$rvv rendezvous did not build" "$TMP/sbx_cc$rvv.log" + continue + fi + got="$("$TMP/sbx_driver" "$TMP/foreign_sbx$rvv.$ext" "$TMP/sbx_copy.$ext" 2>"$TMP/sbx$rvv.err" | tr -d '\r')" + want='before: 1 +in a deny-all level: 0 +after: 1 +foreign levels called: 0' + if [ "$got" != "$want" ]; then + bad "a version-$rvv sandbox rendezvous: the copy enforces its own levels, and never calls it" + printf '%s\n' "$got" | sed 's/^/ /' + fi + [ "$(grep -c "cannot share sandbox levels" "$TMP/sbx$rvv.err")" = 1 ] || + bad "a version-$rvv sandbox rendezvous is reported once" "$TMP/sbx$rvv.err" + done +else + bad "the sandbox copy or its driver did not build" "$TMP/sbx_cc.log" +fi + +if [ "$fail" = 0 ]; then + echo " [PASS] abi_policy: the importer refuses another major, an unknown duty and a shared runtime of another or no ABI, and reads a newer minor; a library compiled for another runtime returns AETHER_INCOMPATIBLE and runs nothing; generated C against other headers does not compile; a sandbox rendezvous of another layout is refused once" +fi +exit $fail diff --git a/tests/integration/actor_panic_step_lock/test_actor_panic_step_lock.sh b/tests/integration/actor_panic_step_lock/test_actor_panic_step_lock.sh index e45c68f15..93cf6b4a8 100644 --- a/tests/integration/actor_panic_step_lock/test_actor_panic_step_lock.sh +++ b/tests/integration/actor_panic_step_lock/test_actor_panic_step_lock.sh @@ -33,18 +33,21 @@ trap 'rm -rf "$tmp" || true' EXIT fail=0 # --- the invariant, read from the source ----------------------------------- +# The barrier both schedulers step inside lives in its own header since +# #2974; the callers that take and clear the lock stay in the scheduler. src="$ROOT/runtime/scheduler/multicore_scheduler.c" -body="$(awk '/^static inline void aether_step_safe/,/^}/' "$src")" +barrier="$ROOT/runtime/actors/aether_step_barrier.h" +body="$(awk '/^static inline void aether_step_barrier/,/^}/' "$barrier")" # An invariant read out of a source file has to prove it read the right # thing first: if the function is renamed and this extracts nothing, the # check below would pass forever while guarding nothing at all. if ! printf '%s\n' "$body" | grep -q 'aether_fire_death_hook'; then - echo " [FAIL] actor_panic_step_lock: could not find aether_step_safe's panic path in $src" + echo " [FAIL] actor_panic_step_lock: could not find aether_step_barrier's panic path in $barrier" echo " (the function was renamed or moved -- this check is guarding nothing until it is repointed)" fail=1 fi if printf '%s\n' "$body" | grep -q 'atomic_flag_clear_explicit(&actor->step_lock'; then - echo " [FAIL] actor_panic_step_lock: aether_step_safe releases a step_lock it never acquired" + echo " [FAIL] actor_panic_step_lock: aether_step_barrier releases a step_lock it never acquired" echo " every call site takes the lock before calling and clears it after;" echo " releasing it here hands it away mid-step (#2163)" fail=1 diff --git a/tests/integration/actor_ref_field_reject/bare_field.ae b/tests/integration/actor_ref_field_reject/bare_field.ae new file mode 100644 index 000000000..8bae33c6c --- /dev/null +++ b/tests/integration/actor_ref_field_reject/bare_field.ae @@ -0,0 +1,22 @@ +// Part of: test_actor_ref_field_reject.sh. A field read through a bare +// actor_ref, which names no actor (#3333). + +message Bump {} + +actor Counter { + state n = 0 + receive { + Bump() -> { n = n + 1 } + } +} + +peek(w: actor_ref) -> int { + return w.n +} + +main() { + c = spawn(Counter()) + c ! Bump {} + wait_for_idle() + println("${peek(c)}") +} diff --git a/tests/integration/actor_ref_field_reject/test_actor_ref_field_reject.sh b/tests/integration/actor_ref_field_reject/test_actor_ref_field_reject.sh new file mode 100644 index 000000000..b8431e294 --- /dev/null +++ b/tests/integration/actor_ref_field_reject/test_actor_ref_field_reject.sh @@ -0,0 +1,38 @@ +#!/bin/sh +# #3333: a field read through a bare `actor_ref` passed the type checker +# and failed in the C compiler (`request for member 'n' in something not a +# structure or union`): a bare reference is a `void*` and names no actor. +# The checker now refuses it with a hint to type the reference, and refuses +# a typed reference naming a state its actor does not have, which also +# reached C. A typed reference to a real state is accepted and runs. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +AETHERC="$ROOT/build/aetherc" +AE="$ROOT/build/ae" +[ -n "${EXE_EXT:-}" ] && AE="$AE$EXE_EXT" && AETHERC="$AETHERC$EXE_EXT" +NAME=actor_ref_field_reject + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT +fail() { echo " [FAIL] $NAME: $1"; [ -f "$2" ] && sed 's/^/ /' "$2" | head -12; exit 1; } + +# refused FILE PATTERN: the checker refuses FILE with an error containing +# PATTERN, and writes no C. +refused() { + if "$AETHERC" "$SCRIPT_DIR/$1" "$TMPDIR/out.c" >"$TMPDIR/err.log" 2>&1; then + fail "aetherc accepted $1" "$TMPDIR/err.log" + fi + grep -q "$2" "$TMPDIR/err.log" || fail "$1: no error containing '$2'" "$TMPDIR/err.log" + if grep -q "request for member\|not a structure or union" "$TMPDIR/err.log"; then + fail "$1 reached the C compiler" "$TMPDIR/err.log" + fi +} +refused bare_field.ae "a bare \`actor_ref\` has no field 'n'" +grep -q "actor_ref\[ActorName\]" "$TMPDIR/err.log" || fail "the hint does not say to type the reference" "$TMPDIR/err.log" +refused unknown_state.ae "actor 'Counter' has no state field 'count'" + +out=$("$AE" run "$SCRIPT_DIR/typed_field.ae" 2>"$TMPDIR/run.log") || fail "typed_field.ae did not run" "$TMPDIR/run.log" +[ "$out" = "1 1" ] || { echo " got: $out"; fail "typed_field.ae printed the wrong state"; } + +echo " [PASS] $NAME: a bare reference's field and an unknown state are refused; a typed one runs" diff --git a/tests/integration/actor_ref_field_reject/typed_field.ae b/tests/integration/actor_ref_field_reject/typed_field.ae new file mode 100644 index 000000000..48b96f2c5 --- /dev/null +++ b/tests/integration/actor_ref_field_reject/typed_field.ae @@ -0,0 +1,22 @@ +// Part of: test_actor_ref_field_reject.sh. The typed reference reaches the +// state, through a parameter and a spawn result (#3333): accepted. + +message Bump {} + +actor Counter { + state n = 0 + receive { + Bump() -> { n = n + 1 } + } +} + +peek(w: actor_ref[Counter]) -> int { + return w.n +} + +main() { + c = spawn(Counter()) + c ! Bump {} + wait_for_idle() + println("${peek(c)} ${c.n}") +} diff --git a/tests/integration/actor_ref_field_reject/unknown_state.ae b/tests/integration/actor_ref_field_reject/unknown_state.ae new file mode 100644 index 000000000..068ea62c1 --- /dev/null +++ b/tests/integration/actor_ref_field_reject/unknown_state.ae @@ -0,0 +1,18 @@ +// Part of: test_actor_ref_field_reject.sh. A typed reference naming a state +// its actor does not have (#3333). + +message Bump {} + +actor Counter { + state n = 0 + receive { + Bump() -> { n = n + 1 } + } +} + +main() { + c = spawn(Counter()) + c ! Bump {} + wait_for_idle() + println("${c.count}") +} diff --git a/tests/integration/binary_import_actors/test_binary_import_actors.sh b/tests/integration/binary_import_actors/test_binary_import_actors.sh index b093d1c70..8aa1134ec 100644 --- a/tests/integration/binary_import_actors/test_binary_import_actors.sh +++ b/tests/integration/binary_import_actors/test_binary_import_actors.sh @@ -56,7 +56,8 @@ run_pass() { for lib in crowd relay; do INFO="$(AETHER_HOME="$ROOT" "$AE" lib-info "./lib$lib$SO_EXT" 2>&1)" || { echo "$INFO"; fail "$label: ae lib-info lib$lib"; } - echo "$INFO" | grep -q "Schema:[[:space:]]*1\.7" || { echo "$INFO"; fail "$label: lib$lib's schema is not 1.7"; } + # 1.11: every catalog has the outcome slot (#2956) and the host ABI, the runtime ABI and the duties (#2957); `actors` (1.7) is in it. + echo "$INFO" | grep -q "Schema:[[:space:]]*1\.11" || { echo "$INFO"; fail "$label: lib$lib's schema is not 1.11"; } echo "$INFO" | grep -q "Actors:[[:space:]]*yes" || { echo "$INFO"; fail "$label: lib$lib's catalog does not say it runs actors"; } done diff --git a/tests/integration/binary_import_package/test_binary_import_package.sh b/tests/integration/binary_import_package/test_binary_import_package.sh index 0a21d1bd6..5b2c53e6b 100644 --- a/tests/integration/binary_import_package/test_binary_import_package.sh +++ b/tests/integration/binary_import_package/test_binary_import_package.sh @@ -36,10 +36,10 @@ AETHER_HOME="$ROOT" "$AE" build --emit=lib --package gamekit -o "libgamekit$SO_E [ -f "libgamekit$SO_EXT" ] || fail "libgamekit$SO_EXT not produced" INFO="$(AETHER_HOME="$ROOT" "$AE" lib-info "./libgamekit$SO_EXT" 2>&1)" || fail "ae lib-info" -# 1.9: an export returns a string (#2931), the highest feature the catalog -# has; core.label never using its config (1.8, #2860) and the package's -# module records (1.6) are there too. -echo "$INFO" | grep -q "Schema:[[:space:]]*1\.9" || { echo "$INFO"; fail "schema is not 1.9 (a package library returning a string)"; } +# 1.11: every catalog has the outcome slot (#2956) and the host ABI, the runtime ABI and the duties (#2957); an export returning a +# string (1.9, #2931), core.label never using its config (1.8, #2860) and +# the package's module records (1.6) are there too. +echo "$INFO" | grep -q "Schema:[[:space:]]*1\.11" || { echo "$INFO"; fail "schema is not 1.11"; } echo "$INFO" | grep -q "String release:[[:space:]]*aether_gamekit_string_release" || { echo "$INFO"; fail "no string release named for gamekit"; } echo "$INFO" | grep -q "config: never used" || { echo "$INFO"; fail "core.label is not recorded as never using its config"; } echo "$INFO" | grep -q "c_symbol: aether_gamekit_core__create" \ diff --git a/tests/integration/binary_import_structs/test_binary_import_structs.sh b/tests/integration/binary_import_structs/test_binary_import_structs.sh index dc61f8057..e082d45ea 100644 --- a/tests/integration/binary_import_structs/test_binary_import_structs.sh +++ b/tests/integration/binary_import_structs/test_binary_import_structs.sh @@ -113,7 +113,8 @@ if [ -d "$ROOT/build/shared" ]; then run_pass "$WORK/shared" "--shared-runtime" "shared runtime" cd "$WORK/shared" INFO="$(AETHER_HOME="$ROOT" "$AE" lib-info "./libengine$SO_EXT" 2>&1)" - echo "$INFO" | grep -q "Schema:[[:space:]]*1\.5" || { echo "$INFO"; fail "shared runtime: schema is not 1.5"; } + # 1.11: every catalog has the outcome slot (#2956) and the host ABI, the runtime ABI and the duties (#2957); `runtime` (1.5) is in it. + echo "$INFO" | grep -q "Schema:[[:space:]]*1\.11" || { echo "$INFO"; fail "shared runtime: schema is not 1.11"; } echo "$INFO" | grep -q "Runtime:[[:space:]]*shared" || { echo "$INFO"; fail "shared runtime: the catalog does not say so"; } cd "$WORK" panic_check "$WORK/shared" "shared runtime" diff --git a/tests/integration/closure_local_env_free/env_count.h b/tests/integration/closure_local_env_free/env_count.h index 6863d6a65..6b7490a0a 100644 --- a/tests/integration/closure_local_env_free/env_count.h +++ b/tests/integration/closure_local_env_free/env_count.h @@ -2,9 +2,10 @@ * * Counts closure environments and promoted capture cells by hooking the * allocator calls the generated code makes: an env is the - * `malloc(sizeof(_closure_env_N))` of a closure literal and a cell the - * `calloc(1, sizeof(_AeCellHeader) + size)` of a promoted capture, both - * recognised by the spelling of their size argument. Every free() in the + * `_aether_alloc(sizeof(_closure_env_N), ...)` of a closure literal and a + * cell the `_aether_alloc(sizeof(_AeCellHeader) + size, ...)` of a promoted + * capture (a malloc and a calloc before #3377), both recognised by the + * spelling of their size argument. Every free() in the * generated code (the env destructors and the cell releases are generated * code) is checked against those blocks. * @@ -111,8 +112,19 @@ static void ec_free(void* p) { free(p); } +/* The generated code allocates an env or a cell through its checked helper + * (#3377), whose definition spells its name in parentheses so this macro + * wraps only the calls: counted by the spelling of the size argument, as the + * bare malloc and calloc calls were. */ +static void* ec_track_alloc(void* p, size_t n, const char* spelled) { + if (strstr(spelled, "_closure_env_")) return ec_track(p, n, 1); + if (strstr(spelled, "_AeCellHeader")) return ec_track(p, n, 2); + return p; +} + #define malloc(n) ec_malloc((n), #n) #define calloc(k, n) ec_calloc((k), (n), #n) #define free(p) ec_free((void*)(p)) +#define _aether_alloc(n, zeroed, what) ec_track_alloc((_aether_alloc)((n), (zeroed), (what)), (n), #n) #endif diff --git a/tests/integration/crypto_ct_valgrind/aether.toml b/tests/integration/crypto_ct_valgrind/aether.toml new file mode 100644 index 000000000..ce408571e --- /dev/null +++ b/tests/integration/crypto_ct_valgrind/aether.toml @@ -0,0 +1,4 @@ +[[bin]] +name = "ct_probe" +path = "ct_probe.ae" +extra_sources = ["ct_shim.c"] diff --git a/tests/integration/crypto_ct_valgrind/ct_probe.ae b/tests/integration/crypto_ct_valgrind/ct_probe.ae new file mode 100644 index 000000000..7a2781aac --- /dev/null +++ b/tests/integration/crypto_ct_valgrind/ct_probe.ae @@ -0,0 +1,467 @@ +// Constant-time probe: every secret input below is marked undefined for +// Valgrind's memcheck before the operation runs (ct_shim.c), and every +// output is marked defined again before it is compared. Run under +// valgrind by test_crypto_ct_valgrind.sh, memcheck reports any branch or +// memory index that depends on a secret; run without it, the probe is a +// known-answer test of the same operations. + +import std.bytes +import std.string +import std.cryptography.ct +import std.cryptography.p256 +import std.cryptography.p384 +import std.cryptography.p521 +import std.cryptography.secp256k1 +import std.cryptography.rsa +import std.cryptography.ed25519 +import std.cryptography.ed448 +import std.cryptography.x25519 +import std.cryptography.x448 +import std.cryptography.aes +import std.cryptography.chacha20poly1305 +import std.cryptography.hmac +import std.cryptography.hkdf +import std.cryptography.sha2 + +extern ct_shim_available() -> int +extern ct_shim_secret(p: ptr, n: int) +extern ct_shim_public(p: ptr, n: int) +extern ct_shim_declassify_hook() -> ptr +extern string_char_at_n(str: string, known_length: int, index: int) -> int +extern exit(code: int) + +fn hv(c: int) -> int { + if c >= 48 && c <= 57 { return c - 48 } + if c >= 97 && c <= 102 { return c - 87 } + if c >= 65 && c <= 70 { return c - 55 } + return 0 +} + +fn hb(h: string) -> ptr { + n = string.length(h) + b = bytes.new(n / 2) + i = 0 + while i < n { + bytes.set(b, i / 2, hv(string_char_at_n(h, n, i)) * 16 + hv(string_char_at_n(h, n, i + 1))) + i = i + 2 + } + return b +} + +fn bh(b: ptr) -> string { + if b == null { + return "(null)" + } + d = "0123456789abcdef" + o = "" + n = bytes.length(b) + i = 0 + while i < n { + v = bytes.get(b, i) + o = string.concat(o, string.concat(string.substring(d, (v / 16) & 15, ((v / 16) & 15) + 1), string.substring(d, v & 15, (v & 15) + 1))) + i = i + 1 + } + return o +} + +fn ascii(s: string) -> ptr { + n = string.length(s) + b = bytes.new(n) + i = 0 + while i < n { + bytes.set(b, i, string_char_at_n(s, n, i)) + i = i + 1 + } + return b +} + +fn digest(algo: string, m: ptr) -> ptr { + ctx, _e = sha2.new(algo) + sha2.update(ctx, bytes.view(m)) + return sha2.final_bytes(ctx) +} + +fn sha256(m: ptr) -> ptr { + return digest("sha256", m) +} + +// Mark a std.bytes buffer's contents secret / public. +fn secret(b: ptr) { + ct_shim_secret(bytes.data(b), bytes.length(b)) +} + +fn public(b: ptr) { + if b != null { + ct_shim_public(bytes.data(b), bytes.length(b)) + } +} + +// Compare an output (made public first) with the expected hex; 1 on a miss. +fn check(label: string, got: ptr, want: string) -> int { + public(got) + g = bh(got) + if g == string.to_lower(want) { + println("ok ${label}") + return 0 + } + println("FAIL ${label}") + println(" got: ${g}") + println(" want: ${string.to_lower(want)}") + return 1 +} + +// ---- P-256 ---- + +fn p256_cases() -> int { + fails = 0 + // RFC 6979 A.2.5, SHA-256, message "sample": the key and nonce secret. + d = hb("C9AFA9D845BA75166B5C215767B1D6934E50C3DB36E89B127B8A622B120F6721") + secret(d) + m = ascii("sample") + h = sha256(m) + r, s = p256.ecdsa_sign(d, h) + fails = fails + check("p256 ecdsa_sign r", r, "EFD48B2AACB6A8FD1140DD9CD45E81D69D2C877B56AAF991C34D0EA84EAF3716") + fails = fails + check("p256 ecdsa_sign s", s, "F7CB1C942D657C41D436C7A1B6E29F65F3E900DBB9AFF4064DC4AB2F843ACDA8") + // Public key from the secret scalar. + px, py = p256.scalar_mult_base(d) + fails = fails + check("p256 scalar_mult_base x", px, "60FED4BA255A9D31C961EB74C6356D68C049B8923B61FA6CE669622E60F29FB6") + fails = fails + check("p256 scalar_mult_base y", py, "7903FE1008B8BC99A41AE9E95628BC64F2F1B20C2D7E9F5177A3C294D4462299") + // The hedged nonce (#3376): the key and the extra bytes secret. No + // vector exists for it, so the signature must verify. + z = hb("0101010101010101010101010101010101010101010101010101010101010101") + secret(z) + hr, hs = p256.ecdsa_sign_hedged(d, h, z) + public(hr) + public(hs) + if p256.ecdsa_verify(px, py, h, hr, hs) == 1 { + println("ok p256 ecdsa_sign_hedged verifies") + } else { + println("FAIL p256 ecdsa_sign_hedged does not verify") + fails = fails + 1 + } + // NIST CAVP ECDH vector: the private scalar secret. + priv = hb("7d7dc5f71eb29ddaf80d6214632eeae03d9058af1fb6d22ed80badb62bc1a534") + secret(priv) + qx = hb("700c48f77f56584c5cc632ca65640db91b6bacce3a4df6b42ce7cc838833d287") + qy = hb("db71e509e3fd9b060ddb20ba5c51dcc5948d46fbf640dfe0441782cab85fa4ac") + z = p256.ecdh(priv, qx, qy) + fails = fails + check("p256 ecdh", z, "46fc62106420ff012e54a434fbdd2d25ccc5852060561e68040dd7778997bd7b") + bytes.free(d) + bytes.free(m) + bytes.free(h) + bytes.free(r) + bytes.free(s) + bytes.free(px) + bytes.free(py) + bytes.free(priv) + bytes.free(qx) + bytes.free(qy) + bytes.free(z) + return fails +} + +// ---- P-384, P-521, secp256k1: sign (RFC 6979) and ECDH on G ---- + +fn p384_cases() -> int { + fails = 0 + // RFC 6979 A.2.6, SHA-384, "sample". + d = hb("6B9D3DAD2E1B8C1C05B19875B6659F4DE23C3B667BF297BA9AA47740787137D896D5724E4C70A825F872C9EA60D2EDF5") + secret(d) + m = ascii("sample") + h = digest("sha384", m) + r, s = p384.ecdsa_sign(d, h) + fails = fails + check("p384 ecdsa_sign r", r, "94EDBB92A5ECB8AAD4736E56C691916B3F88140666CE9FA73D64C4EA95AD133C81A648152E44ACF96E36DD1E80FABE46") + fails = fails + check("p384 ecdsa_sign s", s, "99EF4AEB15F178CEA1FE40DB2603138F130E740A19624526203B6351D0A3A94FA329C145786E679E7B82C71A38628AC8") + // ECDH with G as the peer: the shared x is the public key's x. + gx = hb("aa87ca22be8b05378eb1c71ef320ad746e1d3b628ba79b9859f741e082542a385502f25dbf55296c3a545e3872760ab7") + gy = hb("3617de4a96262c6f5d9e98bf9292dc29f8f41dbd289a147ce9da3113b5f0b8c00a60b1ce1d7e819d7a431d7c90ea0e5f") + z = p384.ecdh(d, gx, gy) + fails = fails + check("p384 ecdh", z, "EC3A4E415B4E19A4568618029F427FA5DA9A8BC4AE92E02E06AAE5286B300C64DEF8F0EA9055866064A254515480BC13") + bytes.free(d); bytes.free(m); bytes.free(h); bytes.free(r); bytes.free(s) + bytes.free(gx); bytes.free(gy); bytes.free(z) + return fails +} + +fn p521_cases() -> int { + fails = 0 + // RFC 6979 A.2.7, SHA-512, "sample". + d = hb("00FAD06DAA62BA3B25D2FB40133DA757205DE67F5BB0018FEE8C86E1B68C7E75CAA896EB32F1F47C70855836A6D16FCC1466F6D8FBEC67DB89EC0C08B0E996B83538") + secret(d) + m = ascii("sample") + h = digest("sha512", m) + r, s = p521.ecdsa_sign(d, h) + fails = fails + check("p521 ecdsa_sign r", r, "00C328FAFCBD79DD77850370C46325D987CB525569FB63C5D3BC53950E6D4C5F174E25A1EE9017B5D450606ADD152B534931D7D4E8455CC91F9B15BF05EC36E377FA") + fails = fails + check("p521 ecdsa_sign s", s, "00617CCE7CF5064806C467F678D3B4080D6F1CC50AF26CA209417308281B68AF282623EAA63E5B5C0723D8B8C37FF0777B1A20F8CCB1DCCC43997F1EE0E44DA4A67A") + gx = hb("00c6858e06b70404e9cd9e3ecb662395b4429c648139053fb521f828af606b4d3dbaa14b5e77efe75928fe1dc127a2ffa8de3348b3c1856a429bf97e7e31c2e5bd66") + gy = hb("011839296a789a3bc0045c8a5fb42c7d1bd998f54449579b446817afbd17273e662c97ee72995ef42640c550b9013fad0761353c7086a272c24088be94769fd16650") + z = p521.ecdh(d, gx, gy) + fails = fails + check("p521 ecdh", z, "01894550D0785932E00EAA23B694F213F8C3121F86DC97A04E5A7167DB4E5BCD371123D46E45DB6B5D5370A7F20FB633155D38FFA16D2BD761DCAC474B9A2F5023A4") + bytes.free(d); bytes.free(m); bytes.free(h); bytes.free(r); bytes.free(s) + bytes.free(gx); bytes.free(gy); bytes.free(z) + return fails +} + +fn secp256k1_cases() -> int { + fails = 0 + // bitcoinjs-lib's RFC 6979 vector: r is fixed by the nonce (s is checked + // by verifying, since that vector gives s in the low-S form). + d = hb("69ec59eaa1f4f2e36b639716b7c30ca86d9a5375c7b38d8918bd9c0ebc80ba64") + secret(d) + m = ascii("Computer science is no more about computers than astronomy is about telescopes.") + h = sha256(m) + r, s = secp256k1.ecdsa_sign(d, h) + fails = fails + check("secp256k1 ecdsa_sign r", r, "7186363571d65e084e7f02b0b77c3ec44fb1b257dee26274c38c928986fea45d") + public(s) + px, py = secp256k1.scalar_mult_base(d) + public(px) + public(py) + if secp256k1.ecdsa_verify(px, py, h, r, s) == 1 { + println("ok secp256k1 ecdsa_sign s verifies") + } else { + println("FAIL secp256k1 ecdsa_sign s does not verify") + fails = fails + 1 + } + // ECDH: 2 * G. + two = hb("0000000000000000000000000000000000000000000000000000000000000002") + secret(two) + gx = hb("79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798") + gy = hb("483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8") + z = secp256k1.ecdh(two, gx, gy) + fails = fails + check("secp256k1 ecdh", z, "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5") + bytes.free(d); bytes.free(m); bytes.free(h); bytes.free(r); bytes.free(s); bytes.free(px); bytes.free(py) + bytes.free(two); bytes.free(gx); bytes.free(gy); bytes.free(z) + return fails +} + +// ---- RSA private-key operations: the private exponent secret ---- + +fn rsa_cases() -> int { + fails = 0 + // The RSA-1024 key of tests/integration/crypto_rsa_oaep_pss, d secret. + n = hb("cfc733d3d62af11a935cbba777e3bf08262629284d84095aecf7dfc63efe38dd0680a6972d677ff04cc3d2e7c84cb4463c528ebc87680623db37792f0315447b2a5da8d229afa229af95b5249eba3c4b3ea726f54989b76cca9002ee10a383de28efe84ebc6f5b74e9f201fd1b9543679e4ef022bf728270b9687beb10599d55") + e = hb("010001") + d = hb("7f6fea75cf31df573172fb15a80ee3a817dde7f87432bb9a2d258af688c45c14145cebd06564c9af9525b641bb34708d738a1f49bab32eb0e74f8ae02ebfe893976a220e1b8df88445fb1e9c2fe4f11fd8c6cd42fef697c22bf3c2dd9dab6333aef9b1cb4dc7dd4c371015f103d36561a5ce65f4ac5055403301a94c920f1401") + secret(d) + k = rsa.key_from_bytes(bytes.view(n), bytes.view(e), bytes.view(d)) + // OAEP decryption of a known ciphertext. + c = hb("b07ad49540cd08de66247b1a8c28070ed013a6bfee2750bb76b28bb575af5fa6af3f88c5366f20c63da1990cd72263acf635a90cc77b1a8445d66ada0374c8b6b0401f940174df0194d883ec29bc93aecc99c0c17ea259d270b864d5b6dde614e6466bb32dbd6901f8fa6e6816b7ee6d8cf02a8a3fc63501a5a25e2d0c07fb5e") + pt, oerr = rsa.decrypt_oaep(k, c) + if oerr != "" { + println("FAIL rsa decrypt_oaep: ${oerr}") + fails = fails + 1 + } else { + fails = fails + check("rsa decrypt_oaep", pt, "68656c6c6f206f616570") + bytes.free(pt) + } + // PSS signature with a fixed salt. + mh = hb("5d1961d33b1b26035f275173addb3ed49f4f1912bf9b8ea2bcb6df88480c5057") + salt = hb("404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f") + sig = rsa.sign_pss(k, mh, bytes.view(salt)) + fails = fails + check("rsa sign_pss", sig, "cad326f8644bb81fdf7696a1100e0ea9b7f630d24a724874eb98048294d6e182c6bffd71600de9a58679ad027bf76429818d3376323743aedb9c46d0bc914866c36ae5f57d4aa9ad52f1adb2e076179ec16d66a9c5de12ab195c27c1afcee8ca1deb8785dfc5d722a7abe5c014be04c247b0d1f53ce026cf3dffdbc0e9051ca1") + // PKCS#1 v1.5: decrypt what encrypt (public, randomized) produced. + msg = hb("6b6e6f776e20706c61696e74657874") + c2, eerr = rsa.encrypt_pkcs1(k, msg) + if eerr != "" { + println("FAIL rsa encrypt_pkcs1: ${eerr}") + fails = fails + 1 + } else { + p2, derr = rsa.decrypt_pkcs1(k, c2) + if derr != "" { + println("FAIL rsa decrypt_pkcs1: ${derr}") + fails = fails + 1 + } else { + fails = fails + check("rsa decrypt_pkcs1", p2, "6b6e6f776e20706c61696e74657874") + bytes.free(p2) + } + bytes.free(c2) + } + // PKCS#1 v1.5 signature over a SHA-256 DigestInfo: deterministic. + di = hb("3031300d0609608648016503040201050004205d1961d33b1b26035f275173addb3ed49f4f1912bf9b8ea2bcb6df88480c5057") + s1, serr = rsa.sign_pkcs1(k, di) + public(s1) + if serr != "" || rsa.verify_pkcs1(k, di, bytes.view(s1)) != 1 { + println("FAIL rsa sign_pkcs1") + fails = fails + 1 + } else { + println("ok rsa sign_pkcs1") + } + bytes.free(s1) + bytes.free(n); bytes.free(e); bytes.free(d); bytes.free(c); bytes.free(mh); bytes.free(salt) + bytes.free(sig); bytes.free(msg); bytes.free(di) + rsa.key_free(k) + return fails +} + +// ---- EdDSA signing, X25519 and X448: the seed or scalar secret ---- + +fn eddsa_xdh_cases() -> int { + fails = 0 + // RFC 8032 7.1 TEST 2: Ed25519 public key and signature of 0x72. + s1 = hb("4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb") + secret(s1) + m1 = hb("72") + sig1 = ed25519.sign(s1, bytes.view(m1)) + fails = fails + check("ed25519 sign", sig1, "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00") + p1 = ed25519.publickey(s1) + fails = fails + check("ed25519 publickey", p1, "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c") + // The hedged r (#3376): the seed and the extra bytes secret; the + // signature must verify. + z1 = hb("0202020202020202020202020202020202020202020202020202020202020202") + secret(z1) + hsig = ed25519.sign_hedged(s1, bytes.view(m1), z1) + public(hsig) + if ed25519.verify(p1, bytes.view(m1), bytes.view(hsig)) == 1 { + println("ok ed25519 sign_hedged verifies") + } else { + println("FAIL ed25519 sign_hedged does not verify") + fails = fails + 1 + } + // RFC 8032 7.4 "Blank": Ed448 signature of the empty message. + s2 = hb("6c82a562cb808d10d632be89c8513ebf6c929f34ddfa8c9f63c9960ef6e348a3528c8a3fcc2f044e39a3fc5b94492f8f032e7549a20098f95b") + secret(s2) + e0 = bytes.new(0) + sig2 = ed448.sign(s2, bytes.view(e0)[0..0]) + fails = fails + check("ed448 sign", sig2, "533a37f6bbe457251f023c0d88f976ae2dfb504a843e34d2074fd823d41a591f2b233f034f628281f2fd7a22ddd47d7828c59bd0a21bfd3980ff0d2028d4b18a9df63e006c5d1c2d345b925d8dc00b4104852db99ac5c7cdda8530a113a0f4dbb61149f05a7363268c71d95808ff2e652600") + // RFC 7748 6.1: X25519 public key and shared secret, Alice's scalar secret. + ak = hb("77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a") + secret(ak) + apub = x25519.base_point(ak) + fails = fails + check("x25519 base_point", apub, "8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a") + bpub = hb("de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f") + sh1, xerr = x25519.agree(ak, bpub) + if xerr != "" { + println("FAIL x25519 agree: ${xerr}") + fails = fails + 1 + } else { + fails = fails + check("x25519 agree", sh1, "4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742") + bytes.free(sh1) + } + // RFC 7748 6.2: X448 shared secret, Alice's scalar secret. + xk = hb("9a8f4925d1519f5775cf46b04b5800d4ee9ee8bae8bc5565d498c28dd9c9baf574a9419744897391006382a6f127ab1d9ac2d8c0a598726b") + secret(xk) + xpub = hb("3eb7a829b0cd20f5bcfc0b599b6feccf6da4627107bdb0d4f345b43027d8b972fc3e34fb4232a13ca706dcb57aec3dae07bdc1c67bf33609") + sh2, yerr = x448.agree(xk, xpub) + if yerr != "" { + println("FAIL x448 agree: ${yerr}") + fails = fails + 1 + } else { + fails = fails + check("x448 agree", sh2, "07fff4181ac6cc95ec1c16a94a0f74d12da232ce40a77552281d282bb60c0b56fd2464c335543936521c24403085d59a449a5037514a879d") + bytes.free(sh2) + } + bytes.free(s1); bytes.free(m1); bytes.free(sig1); bytes.free(p1) + bytes.free(s2); bytes.free(e0); bytes.free(sig2) + bytes.free(ak); bytes.free(apub); bytes.free(bpub); bytes.free(xk); bytes.free(xpub) + return fails +} + +// ---- record ciphers, MACs and the key schedule: the key secret ---- + +fn symmetric_cases() -> int { + fails = 0 + // FIPS-197 C.1 and C.3: AES-128 and AES-256, encrypt and decrypt. + k128 = hb("000102030405060708090a0b0c0d0e0f") + secret(k128) + pt = hb("00112233445566778899aabbccddeeff") + e128 = aes.new_encryptor(bytes.view(k128)) + c128 = aes.process_block(e128, pt) + fails = fails + check("aes-128 encrypt", c128, "69c4e0d86a7b0430d8cdb78070b4c55a") + d128 = aes.new_decryptor(bytes.view(k128)) + p128 = aes.process_block(d128, c128) + fails = fails + check("aes-128 decrypt", p128, "00112233445566778899aabbccddeeff") + k256 = hb("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f") + secret(k256) + e256 = aes.new_encryptor(bytes.view(k256)) + c256 = aes.process_block(e256, pt) + fails = fails + check("aes-256 encrypt", c256, "8ea2b7ca516745bfeafc49904b496089") + // AES-GCM, the GCM specification's test case 4: seal and open. + kg = hb("feffe9928665731c6d6a8f9467308308") + secret(kg) + iv = hb("cafebabefacedbaddecaf888") + aad = hb("feedfacedeadbeeffeedfacedeadbeefabaddad2") + pg = hb("d9313225f88406e5a55909c5aff5269a86a7a9531534f7da2e4c303d8a318a721c3c0c95956809532fcf0e2449a6b525b16aedf5aa0de657ba637b39") + eg = aes.new_encryptor(bytes.view(kg)) + sealed = aes.gcm_seal(eg, iv, bytes.view(aad), bytes.view(pg)) + fails = fails + check("aes-gcm seal", sealed, "42831ec2217774244b7221b784d0d49ce3aa212f2c02a4e035c17e2329aca12e21d514b25466931c7d8f6a5aac84aa051ba30b396a0aac973d58e0915bc94fbc3221a5db94fae95ae7121a47") + opened, gerr = aes.gcm_open(eg, iv, bytes.view(aad), bytes.view(sealed)) + if gerr != "" { + println("FAIL aes-gcm open: ${gerr}") + fails = fails + 1 + } else { + fails = fails + check("aes-gcm open", opened, "d9313225f88406e5a55909c5aff5269a86a7a9531534f7da2e4c303d8a318a721c3c0c95956809532fcf0e2449a6b525b16aedf5aa0de657ba637b39") + bytes.free(opened) + } + // RFC 8439 2.8.2: ChaCha20-Poly1305 seal and open. + kc = hb("808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f") + secret(kc) + nc = hb("070000004041424344454647") + ac = hb("50515253c0c1c2c3c4c5c6c7") + pc = ascii("Ladies and Gentlemen of the class of '99: If I could offer you only one tip for the future, sunscreen would be it.") + sc = chacha20poly1305.aead_seal(kc, nc, bytes.view(ac), bytes.view(pc)) + fails = fails + check("chacha20poly1305 seal", sc, "d31a8d34648e60db7b86afbc53ef7ec2a4aded51296e08fea9e2b5a736ee62d63dbea45e8ca9671282fafb69da92728b1a71de0a9e060b2905d6a5b67ecd3b3692ddbd7f2d778b8c9803aee328091b58fab324e4fad675945585808b4831d7bc3ff4def08e4b7a9de576d26586cec64b61161ae10b594f09e26a7e902ecbd0600691") + oc, cerr = chacha20poly1305.aead_open(kc, nc, bytes.view(ac), bytes.view(sc)) + if cerr != "" { + println("FAIL chacha20poly1305 open: ${cerr}") + fails = fails + 1 + } else { + public(oc) + if bh(oc) == bh(pc) { + println("ok chacha20poly1305 open") + } else { + println("FAIL chacha20poly1305 open") + fails = fails + 1 + } + bytes.free(oc) + } + // RFC 8439 2.5.2: Poly1305 with a secret one-time key. + kp = hb("85d6be7857556d337f4452fe42d506a80103808afb0db2fd4abff6af4149f51b") + secret(kp) + mp = ascii("Cryptographic Forum Research Group") + tp = chacha20poly1305.poly1305_mac(kp, bytes.view(mp)) + fails = fails + check("poly1305", tp, "a8061dc1305136c6c22b8baf0c0127a9") + // RFC 4231 test case 2: HMAC-SHA-256 with key "Jefe". + kh = ascii("Jefe") + secret(kh) + mh = ascii("what do ya want for nothing?") + th = hmac.hmac_sha256(bytes.view(kh), bytes.view(mh)) + fails = fails + check("hmac-sha256", th, "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843") + // RFC 5869 test case 1: HKDF-SHA-256 with secret input keying material. + ikm = hb("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b") + secret(ikm) + salt = hb("000102030405060708090a0b0c") + info = hb("f0f1f2f3f4f5f6f7f8f9") + okm = hkdf.hkdf("sha256", bytes.view(salt), bytes.view(ikm), bytes.view(info), 42) + fails = fails + check("hkdf-sha256", okm, "3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865") + // SHA-256 of a secret message ("abc"). + ms = ascii("abc") + secret(ms) + ds = sha256(ms) + fails = fails + check("sha-256", ds, "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad") + aes.free(e128); aes.free(d128); aes.free(e256); aes.free(eg) + bytes.free(k128); bytes.free(pt); bytes.free(c128); bytes.free(p128); bytes.free(k256); bytes.free(c256) + bytes.free(kg); bytes.free(iv); bytes.free(aad); bytes.free(pg); bytes.free(sealed) + bytes.free(kc); bytes.free(nc); bytes.free(ac); bytes.free(pc); bytes.free(sc) + bytes.free(kp); bytes.free(mp); bytes.free(tp); bytes.free(kh); bytes.free(mh); bytes.free(th) + bytes.free(ikm); bytes.free(salt); bytes.free(info); bytes.free(okm); bytes.free(ms); bytes.free(ds) + return fails +} +main() { + ct.set_declassify_hook(ct_shim_declassify_hook() as DeclassifyHook) + if ct_shim_available() == 1 { + println("ct_probe: under valgrind, secrets marked undefined") + } else { + println("ct_probe: no valgrind, known-answer run only") + } + fails = 0 + fails = fails + p256_cases() + fails = fails + p384_cases() + fails = fails + p521_cases() + fails = fails + secp256k1_cases() + fails = fails + rsa_cases() + fails = fails + eddsa_xdh_cases() + fails = fails + symmetric_cases() + if fails > 0 { + println("${fails} FAILED") + exit(1) + } + println("ALL PASS") +} diff --git a/tests/integration/crypto_ct_valgrind/ct_shim.c b/tests/integration/crypto_ct_valgrind/ct_shim.c new file mode 100644 index 000000000..b08214d77 --- /dev/null +++ b/tests/integration/crypto_ct_valgrind/ct_shim.c @@ -0,0 +1,57 @@ +/* Constant-time harness shim (ctgrind style; see test_crypto_ct_valgrind.sh). + * + * ct_shim_secret marks a buffer's bytes undefined for Valgrind's memcheck. + * memcheck then reports every conditional jump, and every memory address, + * computed from those bytes: exactly the secret-dependent branches and + * table indexes a constant-time implementation must not have. A value the + * code is entitled to branch on (a signature's retry decision, an AEAD tag + * verdict) goes through std.cryptography.ct.declassify, whose hook, + * ct_shim_declassify, marks it defined again. + * + * Without Valgrind's headers (any platform but Linux with valgrind + * installed) every call is a no-op and ct_shim_available() is 0, so the probe + * still runs as a functional test. */ +#if defined(__linux__) && defined(__has_include) +# if __has_include() +# include +# define CT_SHIM_VALGRIND 1 +# endif +#endif + +int ct_shim_available(void) { +#ifdef CT_SHIM_VALGRIND + return RUNNING_ON_VALGRIND ? 1 : 0; +#else + return 0; +#endif +} + +void ct_shim_secret(void *p, int n) { +#ifdef CT_SHIM_VALGRIND + if (p && n > 0) VALGRIND_MAKE_MEM_UNDEFINED(p, (unsigned long)n); +#else + (void)p; (void)n; +#endif +} + +void ct_shim_public(void *p, int n) { +#ifdef CT_SHIM_VALGRIND + if (p && n > 0) VALGRIND_MAKE_MEM_DEFINED(p, (unsigned long)n); +#else + (void)p; (void)n; +#endif +} + +/* The declassify hook: v's stack slot is marked defined, and the value is + * read back from it (the client request's memory clobber makes the compiler + * store v before and reload it after). */ +static int ct_shim_declassify(int v) { +#ifdef CT_SHIM_VALGRIND + VALGRIND_MAKE_MEM_DEFINED(&v, sizeof v); +#endif + return v; +} + +void *ct_shim_declassify_hook(void) { + return (void *)ct_shim_declassify; +} diff --git a/tests/integration/crypto_ct_valgrind/test_crypto_ct_valgrind.sh b/tests/integration/crypto_ct_valgrind/test_crypto_ct_valgrind.sh new file mode 100644 index 000000000..0043accfb --- /dev/null +++ b/tests/integration/crypto_ct_valgrind/test_crypto_ct_valgrind.sh @@ -0,0 +1,91 @@ +#!/bin/sh +# Constant-time check of std.cryptography's secret-key operations, in the +# style of ctgrind and BoringSSL's constant-time validation. +# +# ct_probe.ae runs each operation (signing, key agreement, private-key +# decryption, the record ciphers and MACs) on a known-answer vector with its +# secret inputs marked undefined for Valgrind's memcheck (ct_shim.c). memcheck +# then reports every conditional jump or move, and every memory address, +# computed from a secret: a branch or a table index that makes the time or +# the cache footprint depend on the key. Values the code is entitled to act +# on go through std.cryptography.ct.declassify, which the probe hooks. +# +# The verdict: no memcheck error whose uninitialised value was created by the +# shim's client request (a secret), and every known answer right. Other +# memcheck errors are not this test's to judge (the memory-check workflow +# owns them); they are listed, not counted. +# +# The probe is built with the default `ae build`, the -O2 code a program +# ships, since that is the code whose branches matter. +# +# Without valgrind (Windows, macOS, a Linux box without it) the probe still +# runs as a known-answer test and the test reports a skip. CT_REQUIRE_VALGRIND=1 +# (the CI job that installs valgrind) turns a missing valgrind into a failure. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +AE="$ROOT/build/ae" +[ -n "${EXE_EXT:-}" ] && AE="$AE$EXE_EXT" +[ -x "$AE" ] || { echo " [FAIL] crypto_ct_valgrind: build/ae missing (run make)"; exit 1; } + +tmpdir="$(mktemp -d)" +trap 'rm -rf "$tmpdir"' EXIT + +cd "$SCRIPT_DIR" || exit 1 +if ! "$AE" build ct_probe.ae -o "$tmpdir/ct_probe" >"$tmpdir/build.log" 2>&1; then + echo " [FAIL] crypto_ct_valgrind: probe build failed" + sed 's/^/ /' "$tmpdir/build.log" | tail -30 + exit 1 +fi +probe="$tmpdir/ct_probe" +[ -x "$probe" ] || probe="$probe.exe" + +have_vg=0 +if [ "$(uname -s)" = "Linux" ] && command -v valgrind >/dev/null 2>&1; then + have_vg=1 +fi + +if [ "$have_vg" -eq 0 ]; then + if ! "$probe" >"$tmpdir/run.log" 2>&1 || ! grep -q "^ALL PASS" "$tmpdir/run.log"; then + echo " [FAIL] crypto_ct_valgrind: known-answer run failed" + sed 's/^/ /' "$tmpdir/run.log" | tail -30 + exit 1 + fi + if [ "${CT_REQUIRE_VALGRIND:-0}" = "1" ]; then + echo " [FAIL] crypto_ct_valgrind: valgrind is required (CT_REQUIRE_VALGRIND=1) and not installed" + exit 1 + fi + echo " [SKIP] crypto_ct_valgrind: no valgrind on this host (the known-answer run passed)" + exit 0 +fi + +supp="" +[ -f "$ROOT/.valgrind-suppressions" ] && supp="--suppressions=$ROOT/.valgrind-suppressions" +# shellcheck disable=SC2086 +valgrind --tool=memcheck --track-origins=yes --error-exitcode=0 \ + --num-callers=24 $supp --log-file="$tmpdir/vg.log" \ + "$probe" >"$tmpdir/run.log" 2>&1 +rc=$? + +# One paragraph per memcheck error once the "==pid== " prefix is gone. +sed 's/^==[0-9]*== \{0,1\}//' "$tmpdir/vg.log" > "$tmpdir/vg.txt" +awk 'BEGIN { RS = "" } /created by a client request/ { print; print "" }' \ + "$tmpdir/vg.txt" > "$tmpdir/secret_errors.txt" +nsecret=$(grep -c -E '^(Conditional jump|Use of uninitialised|Syscall param)' "$tmpdir/secret_errors.txt") +nother=$(grep -E '^ERROR SUMMARY: ' "$tmpdir/vg.txt" | tail -1 | awk '{ print $3 }') + +fail=0 +if [ "$rc" -ne 0 ] || ! grep -q "^ALL PASS" "$tmpdir/run.log"; then + echo " [FAIL] crypto_ct_valgrind: the probe failed under valgrind (exit $rc)" + sed 's/^/ /' "$tmpdir/run.log" | tail -30 + fail=1 +fi +if [ "$nsecret" -gt 0 ]; then + echo " [FAIL] crypto_ct_valgrind: $nsecret branch(es) or memory index(es) depend on a secret:" + sed 's/^/ /' "$tmpdir/secret_errors.txt" | head -400 + fail=1 +fi +[ "$fail" -eq 0 ] || exit 1 +grep '^ok ' "$tmpdir/run.log" | sed 's/^ok */ [PASS] crypto_ct_valgrind: constant time: /' +echo " [PASS] crypto_ct_valgrind: no secret-dependent branch or index (memcheck errors in total: ${nother:-0})" +exit 0 diff --git a/tests/integration/crypto_curves2/test_crypto_curves2.ae b/tests/integration/crypto_curves2/test_crypto_curves2.ae index 590380a7c..5ff2f978d 100644 --- a/tests/integration/crypto_curves2/test_crypto_curves2.ae +++ b/tests/integration/crypto_curves2/test_crypto_curves2.ae @@ -1,8 +1,13 @@ -// Validate std.cryptography.secp256k1 (ECDSA/ECDH), x448 (RFC 7748), -// and ed448 (RFC 8032) against published test vectors. +// Validate std.cryptography.secp256k1 (ECDSA with RFC 6979 nonces, ECDH, +// the off-curve refusal), x448 (RFC 7748, and agree refusing an all-zero +// shared secret as x25519.agree does) and ed448 (RFC 8032) against +// published test vectors. import std.cryptography.secp256k1 import std.cryptography.x448 +import std.cryptography.x25519 import std.cryptography.ed448 +import std.cryptography.sha2 +import std.bignum import std.bytes import std.string import std.io @@ -12,6 +17,56 @@ hb(h: string) -> ptr { n = string.length(h); b = bytes.new(n / 2); i = 0; while bh(b: ptr) -> string { d = "0123456789abcdef"; o = ""; n = bytes.length(b); i = 0; while i < n { v = bytes.get(b, i); o = string.concat(o, string.concat(string.substring(d, (v / 16) & 15, ((v / 16) & 15) + 1), string.substring(d, v & 15, (v & 15) + 1))); i = i + 1 } return o } ck(lbl: string, got: string, want: string) -> int { if got == want { println("ok ${lbl}"); return 0 } println("FAIL ${lbl}"); println(" got: ${got}"); println(" want: ${want}"); return 1 } +fn sha256_str(msg: string) -> ptr { + n = string.length(msg) + m = bytes.new(n) + i = 0 + while i < n { + bytes.set(m, i, string_char_at_n(msg, n, i)) + i = i + 1 + } + ctx, _e = sha2.new("sha256") + sha2.update(ctx, bytes.view(m)) + bytes.free(m) + return sha2.final_bytes(ctx) +} + +// s in Bitcoin's low-S form: n - s when s > n/2. +fn low_s(s: ptr) -> string { + nb = hb("fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141") + n = bignum.from_bytes_unsigned(nb, 32) + bytes.free(nb) + half = bignum.shift_right(n, 1) + sn = bignum.from_bytes_unsigned(s, 32) + out = bh(s) + if bignum.compare(sn, half) > 0 { + t = bignum.subtract(n, sn) + tb = bignum.to_bytes_unsigned(t) + out = bh(tb) + while string.length(out) < 64 { out = string.concat("0", out) } + bytes.free(tb) + bignum.free(t) + } + bignum.free(n); bignum.free(half); bignum.free(sn) + return out +} + +// Sign SHA-256(msg) under d (hex, left-padded to 32 bytes); compare r and the +// low-S form of s, and verify. +fn k1_vector(d_hex: string, msg: string, want_r: string, want_s: string) -> int { + dh = d_hex + while string.length(dh) < 64 { dh = string.concat("0", dh) } + d = hb(dh) + h = sha256_str(msg) + r, s = secp256k1.ecdsa_sign(d, h) + px, py = secp256k1.scalar_mult_base(d) + fails = ck("secp256k1 rfc6979 d=${d_hex} r", bh(r), want_r) + fails = fails + ck("secp256k1 rfc6979 d=${d_hex} low-s", low_s(s), want_s) + if secp256k1.ecdsa_verify(px, py, h, r, s) != 1 { println("FAIL secp256k1 d=${d_hex} does not verify"); fails = fails + 1 } + bytes.free(d); bytes.free(h); bytes.free(r); bytes.free(s); bytes.free(px); bytes.free(py) + return fails +} + main() { fails = 0 @@ -21,19 +76,66 @@ main() { fails = fails + ck("secp256k1 2G x", bh(g2x), "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5") fails = fails + ck("secp256k1 2G y", bh(g2y), "1ae168fea63dc339a3c58419466ceaeef7f632653266d0e1236431a950cfe52a") - // ECDSA sign/verify round-trip + // ECDSA with RFC 6979 nonces: the deterministic secp256k1 vectors of + // bitcoinjs-lib v3.3.2 (test/fixtures/ecdsa.json, "valid.ecdsa"; MIT), + // whose signatures are in Bitcoin's low-S form. + fails = fails + k1_vector("01", "Everything should be made as simple as possible, but not simpler.", + "33a69cd2065432a30f3d1ce4eb0d59b8ab58c74f27c41a7fdb5696ad4e6108c9", "6f807982866f785d3f6418d24163ddae117b7db4d5fdf0071de069fa54342262") + fails = fails + k1_vector("fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364140", "Equations are more important to me, because politics is for the present, but an equation is something for eternity.", + "54c4a33c6423d689378f160a7ff8b61330444abb58fb470f96ea16d99d4a2fed", "07082304410efa6b2943111b6a4e0aaa7b7db55a07e9861d1fb3cb1f421044a5") + fails = fails + k1_vector("fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364140", "Not only is the Universe stranger than we think, it is stranger than we can think.", + "ff466a9f1b7b273e2f4c3ffe032eb2e814121ed18ef84665d0f515360dab3dd0", "6fc95f5132e5ecfdc8e5e6e616cc77151455d46ed48f5589b7db7771a332b283") + fails = fails + k1_vector("0000000000000000000000000000000000000000000000000000000000000001", "How wonderful that we have met with a paradox. Now we have some hope of making progress.", + "c0dafec8251f1d5010289d210232220b03202cba34ec11fec58b3e93a85b91d3", "75afdc06b7d6322a590955bf264e7aaa155847f614d80078a90292fe205064d3") + fails = fails + k1_vector("69ec59eaa1f4f2e36b639716b7c30ca86d9a5375c7b38d8918bd9c0ebc80ba64", "Computer science is no more about computers than astronomy is about telescopes.", + "7186363571d65e084e7f02b0b77c3ec44fb1b257dee26274c38c928986fea45d", "0de0b38e06807e46bda1f1e293f4f6323e854c86d58abdd00c46c16441085df6") + fails = fails + k1_vector("00000000000000000000000000007246174ab1e92e9149c6e446fe194d072637", "...if you aren't, at any given time, scandalized by code you wrote five or even three years ago, you're not learning anywhere near enough", + "fbfe5076a15860ba8ed00e75e9bd22e05d230f02a936b653eb55b61c99dda487", "0e68880ebb0050fe4312b1b1eb0899e1b82da89baa5b895f612619edf34cbd37") + fails = fails + k1_vector("000000000000000000000000000000000000000000056916d0f9b31dc9b637f3", "The question of whether computers can think is like the question of whether submarines can swim.", + "cde1302d83f8dd835d89aef803c74a119f561fbaef3eb9129e45f30de86abbf9", "06ce643f5049ee1f27890467b77a6a8e11ec4661cc38cd8badf90115fbd03cef") + + // Round trip, tamper, and the refusals (#3291). d = hb("c477f9f65c22cce20657faa5b2d1d8122336f851a508a1ed04e479c34985bf96") hash = hb("0102030405060708010203040506070801020304050607080102030405060708") - k = hb("7a1a7e52797fc8caaa435d2a4dace39158504bf204fbe19f14dbb427faee50ae") px, py = secp256k1.scalar_mult_base(d) - r, s = secp256k1.ecdsa_sign(d, hash, k) + r, s = secp256k1.ecdsa_sign(d, hash) if secp256k1.ecdsa_verify(px, py, hash, r, s) == 1 { println("ok secp256k1 sign/verify") } else { println("FAIL secp256k1 sign/verify"); fails = fails + 1 } + bytes.set(hash, 31, 9) + if secp256k1.ecdsa_verify(px, py, hash, r, s) == 0 { println("ok secp256k1 tamper rejected") } else { println("FAIL secp256k1 tamper accepted"); fails = fails + 1 } + offy = hb("1ae168fea63dc339a3c58419466ceaeef7f632653266d0e1236431a950cfe52b") + if secp256k1.ecdh(d, g2x, offy) == null && secp256k1.public_key_valid(g2x, offy) == 0 && secp256k1.public_key_valid(g2x, g2y) == 1 { + println("ok secp256k1 refuses an off-curve point") + } else { println("FAIL secp256k1 accepted an off-curve point"); fails = fails + 1 } + // ECDH both ways: d * 2G = 2 * (d G). + e1 = secp256k1.ecdh(d, g2x, g2y) + e2 = secp256k1.ecdh(two, px, py) + fails = fails + ck("secp256k1 ecdh agrees both ways", bh(e1), bh(e2)) + bytes.free(d); bytes.free(hash); bytes.free(px); bytes.free(py); bytes.free(r); bytes.free(s) + bytes.free(offy); bytes.free(e1); bytes.free(e2); bytes.free(two); bytes.free(g2x); bytes.free(g2y) // --- X448 RFC 7748 §6.2 Diffie-Hellman shared secret --- apriv = hb("9a8f4925d1519f5775cf46b04b5800d4ee9ee8bae8bc5565d498c28dd9c9baf574a9419744897391006382a6f127ab1d9ac2d8c0a598726b") bpub = hb("3eb7a829b0cd20f5bcfc0b599b6feccf6da4627107bdb0d4f345b43027d8b972fc3e34fb4232a13ca706dcb57aec3dae07bdc1c67bf33609") ss = x448.x448(apriv, bpub) fails = fails + ck("x448 dh", bh(ss), "07fff4181ac6cc95ec1c16a94a0f74d12da232ce40a77552281d282bb60c0b56fd2464c335543936521c24403085d59a449a5037514a879d") + // agree() runs the same check for both curves (#1298): the RFC 7748 6.2 + // shared secret comes back, and an all-zero one (u = 0, small order) is + // refused, where the raw functions return the zeros. + ss2, _aerr = x448.agree(apriv, bpub) + fails = fails + ck("x448 agree", bh(ss2), "07fff4181ac6cc95ec1c16a94a0f74d12da232ce40a77552281d282bb60c0b56fd2464c335543936521c24403085d59a449a5037514a879d") + z56 = hb("0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000") + z32 = hb("0000000000000000000000000000000000000000000000000000000000000000") + s32 = hb("a546e36bf0527c9d3b16154b82465edd62144c0ac1fc5a18506a2244ba449ac4") + raw448 = x448.x448(apriv, z56) + raw25519, _rerr = x25519.scalar_mult(s32, z32) + zs448, zerr448 = x448.agree(apriv, z56) + zs25519, zerr25519 = x25519.agree(s32, z32) + if zs448 == null && string.contains(zerr448, "all-zero") == 1 && zs25519 == null && string.contains(zerr25519, "all-zero") == 1 { + println("ok x448.agree and x25519.agree both refuse an all-zero shared secret") + } else { println("FAIL an all-zero shared secret was accepted"); fails = fails + 1 } + fails = fails + ck("x448 raw zero", bh(raw448), "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000") + fails = fails + ck("x25519 raw zero", bh(raw25519), "0000000000000000000000000000000000000000000000000000000000000000") + bytes.free(ss2); bytes.free(z56); bytes.free(z32); bytes.free(s32); bytes.free(raw448); bytes.free(raw25519) // --- Ed448 RFC 8032 §7.4 Blank (empty message) --- seed = hb("6c82a562cb808d10d632be89c8513ebf6c929f34ddfa8c9f63c9960ef6e348a3528c8a3fcc2f044e39a3fc5b94492f8f032e7549a20098f95b") diff --git a/tests/integration/crypto_ed25519/test_crypto_ed25519.ae b/tests/integration/crypto_ed25519/test_crypto_ed25519.ae index 7f3fd5808..dd2455dff 100644 --- a/tests/integration/crypto_ed25519/test_crypto_ed25519.ae +++ b/tests/integration/crypto_ed25519/test_crypto_ed25519.ae @@ -58,6 +58,30 @@ main() { } bytes.free(pk151); bytes.free(m151); bytes.free(sig151) + // Hedged r (#3376): fresh bytes give another signature of the same + // message, one that verifies; the same bytes give the same signature; + // none at all is RFC 8032's (Test 2's vector). + z1 = hb("0101010101010101010101010101010101010101010101010101010101010101") + z2 = hb("0202020202020202020202020202020202020202020202020202020202020202") + h1 = ed25519.sign_hedged(s2, bytes.view(m2)[0..1], z1) + h1b = ed25519.sign_hedged(s2, bytes.view(m2)[0..1], z1) + h2 = ed25519.sign_hedged(s2, bytes.view(m2)[0..1], z2) + hn = ed25519.sign_hedged(s2, bytes.view(m2)[0..1], null) + pub2 = ed25519.publickey(s2) + if ed25519.verify(pub2, bytes.view(m2)[0..1], bytes.view(h1)[0..64]) == 1 && ed25519.verify(pub2, bytes.view(m2)[0..1], bytes.view(h2)[0..64]) == 1 { + println("ok hedged signatures verify") + } else { + println("FAIL a hedged signature does not verify"); fails = fails + 1 + } + if bh(h1) != bh(sig2) && bh(h1) != bh(h2) && bh(h1) == bh(h1b) { + println("ok hedged r: new for new bytes, the same for the same bytes") + } else { + println("FAIL hedged r does not follow its bytes"); fails = fails + 1 + } + fails = fails + ck("hedged with none = RFC 8032 t2", bh(hn), + "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00") + bytes.free(z1); bytes.free(z2); bytes.free(h1); bytes.free(h1b); bytes.free(h2); bytes.free(hn); bytes.free(pub2) + if fails == 0 { println("ALL PASS"); return } println("FAILURES"); exit(1) } diff --git a/tests/integration/crypto_key_wipe/aether.toml b/tests/integration/crypto_key_wipe/aether.toml new file mode 100644 index 000000000..60542f763 --- /dev/null +++ b/tests/integration/crypto_key_wipe/aether.toml @@ -0,0 +1,4 @@ +[[bin]] +name = "probe" +path = "probe.ae" +extra_sources = ["probe_shim.c"] diff --git a/tests/integration/crypto_key_wipe/probe.ae b/tests/integration/crypto_key_wipe/probe.ae new file mode 100644 index 000000000..8b0516c8d --- /dev/null +++ b/tests/integration/crypto_key_wipe/probe.ae @@ -0,0 +1,349 @@ +// The freed-memory probe of test_key_wipe.sh (#3382, #3402). Each test key +// below (RSA-2048 as PKCS#8 and PKCS#1, P-256 as PKCS#8 and SEC1; made for this +// test only) is loaded with tls13_server.load_private_key and freed with +// free_private_key, which wipes the key itself. Then probe_shim.c asks the +// C allocator for blocks of every size the loading's buffers had, reads them +// as they come, and looks for 16 bytes from the middle of the private +// exponent or scalar: a buffer freed without a wipe hands its bytes to the +// next allocation of its size. +// +// A control comes first: a buffer of each of two sizes the loading uses +// (a key's SEQUENCE, an exponent), with a known pattern, freed without a +// wipe, must be found. Where it is not (an allocator that does not hand a +// freed block back as it was, or one the probe must not read under), the +// probe proves nothing and prints "SKIP: ". Otherwise one line per +// key, "ok " or "FAIL : copies", and exits 1 on a FAIL. +// +// Then the same after signing with P-256, Ed25519 and RSA, an HMAC, and a +// shield wrap and unwrap (#3402): the hash, MAC and cipher state those use +// held the secret, so it must be wiped too. + +import std.bytes +import std.string +import std.cryptography.tls13_server +import std.cryptography.tls13_cert +import std.cryptography.hmac +import std.cryptography.sha2 +import std.cryptography.rsa +import std.cryptography.pem +import std.cryptography.shield +import std.cryptography.x25519 +import std.cryptography.tls13_ks +import std.cryptography.tls13_kdf + +extern key_probe_supported() -> int +extern key_probe_scan(pattern: ptr, lo: int, hi: int, blocks: int) -> int +extern key_probe_scan_step(pattern: ptr, lo: int, hi: int, step: int, blocks: int) -> int +extern key_probe_free_raw(pattern: ptr, size: int) +extern exit(code: int) +extern string_char_at(str: string, index: int) -> int + +const LO = 16 +const HI = 4096 +const BLOCKS = 1024 + +// 1 when the shield leg could not check (its raw control was not found). +var shield_unchecked: int = 0 + +fn hexval(c: int) -> int { + if c >= 48 && c <= 57 { return c - 48 } + if c >= 97 && c <= 102 { return c - 87 } + return 0 +} + +fn window(hex: string) -> ptr { + w = bytes.new(16) + bytes.set(w, 15, 0) + i = 0 + while i < 16 { + bytes.set(w, i, hexval(string_char_at(hex, 2 * i)) * 16 + hexval(string_char_at(hex, 2 * i + 1))) + i = i + 1 + } + return w +} + +// A buffer of `size` bytes holding `w` in its middle, freed as it is. +fn control(w: ptr, size: int) -> int { + c = bytes.new(size) + bytes.set(c, size - 1, 0) + i = 0 + while i < 16 { + bytes.set(c, size / 2 + i, bytes.get(w, i)) + i = i + 1 + } + bytes.free(c) + return key_probe_scan(bytes.data(w), LO, HI, BLOCKS) +} + +fn check(name: string, pem_text: string, hex: string) -> int { + w = window(hex) + k, err = tls13_server.load_private_key(pem_text) + if k == null { + println("FAIL ${name}: does not load: ${err}") + bytes.wipe_free(w) + return 1 + } + tls13_server.free_private_key(k) + hits = key_probe_scan(bytes.data(w), LO, HI, BLOCKS) + // The window itself is a copy of the secret's bytes: wiped, or the next + // key's probe would find it. + bytes.wipe_free(w) + if hits != 0 { + println("FAIL ${name}: ${hits} copies of the key in freed memory") + return 1 + } + println("ok ${name}: no copy of the key in freed memory") + return 0 +} + +// `n` bytes from `hex`, a buffer of exactly that size. +fn hexbytes(hex: string, n: int) -> ptr { + b = bytes.new(n) + bytes.set(b, n - 1, 0) + i = 0 + while i < n { + bytes.set(b, i, hexval(string_char_at(hex, 2 * i)) * 16 + hexval(string_char_at(hex, 2 * i + 1))) + i = i + 1 + } + return b +} + +fn th32() -> ptr { + b = bytes.new(32) + i = 0 + while i < 32 { + bytes.set(b, i, i) + i = i + 1 + } + return b +} + +// Scan for the window `w` (consumed: wiped) after an operation (#3402). +fn scan_after(name: string, w: ptr) -> int { + hits = key_probe_scan(bytes.data(w), LO, HI, BLOCKS) + bytes.wipe_free(w) + if hits != 0 { + println("FAIL ${name}: ${hits} copies of the secret in freed memory") + return 1 + } + println("ok ${name}: no copy of the secret in freed memory") + return 0 +} + +// A P-256 CertificateVerify signature: the scalar goes through RFC 6979's +// HMAC-DRBG, so the hash and MAC state held it. +fn sign_p256() -> int { + w = window("a282997f3517fd44cd8e2c35c0ef513b") + scalar = tls13_server.parse_ec_private_key_pem(ec_pkcs8()) + th = th32() + sig = tls13_cert.sign_server_cert_verify_ecdsa(tls13_cert.KEY_P256, scalar, bytes.view(th)) + bytes.wipe_free(scalar) + bytes.free(th) + if sig == null { + println("FAIL P-256 signing: did not sign") + bytes.wipe_free(w) + return 1 + } + bytes.free(sig) + return scan_after("P-256 signing", w) +} + +// An Ed25519 CertificateVerify signature (RFC 8032 test 1's seed): the seed +// is hashed with SHA-512. +fn sign_ed25519() -> int { + w = window("ba844af492ec2cc44449c5697b326919") + seed = hexbytes("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60", 32) + th = th32() + sig = tls13_cert.sign_server_cert_verify_ed25519(seed, bytes.view(th)) + bytes.wipe_free(seed) + bytes.free(th) + if sig == null { + println("FAIL Ed25519 signing: did not sign") + bytes.wipe_free(w) + return 1 + } + bytes.free(sig) + return scan_after("Ed25519 signing", w) +} + +// An RSA-2048 rsa_pss_rsae_sha256 signature with the PKCS#1 test key. +fn sign_rsa() -> int { + w = window("26409a4310a03aecd9175eb011bab400") + der, _label, perr = pem.parse_at(rsa_pkcs1(), 0) + if der == null { + println("FAIL RSA signing: the key does not decode: ${perr}") + bytes.wipe_free(w) + return 1 + } + k, _kerr = rsa.key_from_pkcs1_der(der) + bytes.wipe_free(der) + th = th32() + sig = tls13_cert.sign_server_cert_verify_rsa_pss_scheme(k, tls13_cert.SCHEME_RSA_PSS_RSAE_SHA256, bytes.view(th)) + rsa.key_free(k) + bytes.free(th) + if sig == null { + println("FAIL RSA signing: did not sign") + bytes.wipe_free(w) + return 1 + } + bytes.free(sig) + return scan_after("RSA signing", w) +} + +// HMAC-SHA-256 with a 32-byte key: the key is padded into K0, then the pads. +fn mac() -> int { + w = window("0f1e2d3c4b5a69788796a5b4c3d2e1f0") + key = hexbytes("0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a69788796a5b4c3d2e1f0", 32) + m = th32() + out = hmac.bytes("sha256", bytes.view(key), bytes.view(m)) + bytes.wipe_free(key) + bytes.free(m) + bytes.free(out) + return scan_after("HMAC-SHA-256", w) +} + +// A shield wrap and unwrap: SHA-512 of the pre-key is the AEAD key, so its +// first 16 bytes must not be left anywhere. A SHA-2 context holds its digest +// as 64-bit words in the machine's order (little-endian on every target the +// tests run on), so the window is the first two words as they lie in a +// context: each 8 bytes reversed. +fn shielded() -> int { + h, herr = shield.wrap(hexbytes("00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", 32)) + if h == null { + println("FAIL shield: ${herr}") + return 1 + } + s = h as *shield.Shielded + kh = sha2.sha512_bytes(bytes.view(s.prekey)) + w = bytes.new(16) + bytes.set(w, 15, 0) + i = 0 + while i < 8 { + bytes.set(w, i, bytes.get(kh, 7 - i)) + bytes.set(w, 8 + i, bytes.get(kh, 15 - i)) + i = i + 1 + } + bytes.wipe_free(kh) + // The context is a struct the C allocator hands out at its own size, so + // this scan steps through the sizes a context can have, with a control + // of its own: a raw block of about a context's size, freed unwiped, must + // be found first. + cw = window("c0ffee00c0ffee00c0ffee00c0ffee00") + key_probe_free_raw(bytes.data(cw), 752) + ctl = key_probe_scan_step(bytes.data(cw), 512, 1024, 16, 64) + bytes.free(cw) + got, _uerr = shield.unwrap(h) + bytes.wipe_free(got) + shield.free(h) + if ctl == 0 { + bytes.wipe_free(w) + // Not a pass: the summary says the shield was not checked (#3414). + shield_unchecked = 1 + println("SKIP shield wrap and unwrap: not checked here (the raw control was not found)") + return 0 + } + hits = key_probe_scan_step(bytes.data(w), 512, 1024, 16, 64) + bytes.wipe_free(w) + if hits != 0 { + println("FAIL shield wrap and unwrap: ${hits} copies of the AEAD key in freed memory") + return 1 + } + println("ok shield wrap and unwrap: no copy of the AEAD key in freed memory") + return 0 +} + +// An X25519 exchange (RFC 7748 6.1: Alice's key, Bob's public key): the +// ladder works on a clamped copy of the private scalar, whose middle bytes +// are the key's (#3414). +fn exchange() -> int { + w = window("3c16c17251b26645df4c2f87ebc0992a") + alice = hexbytes("77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a", 32) + bob = hexbytes("de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f", 32) + shared, xerr = x25519.scalar_mult(alice, bob) + bytes.wipe_free(alice) + bytes.free(bob) + if shared == null { + println("FAIL X25519 exchange: ${xerr}") + bytes.wipe_free(w) + return 1 + } + bytes.wipe_free(shared) + return scan_after("X25519 exchange", w) +} + +// The TLS 1.3 key schedule (SHA-256): the master secret is HKDF-Extract +// over the salt Derive-Secret(handshake, "derived", ""), so that salt gives +// every application traffic key (#3414). Its first 16 bytes must not be left. +fn key_schedule() -> int { + early = tls13_ks.early_secret("sha256") + ecdhe = hexbytes("4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742", 32) + hs = tls13_ks.handshake_secret("sha256", early, bytes.view(ecdhe)) + bytes.wipe_free(ecdhe) + blank = bytes.new(1) + empty_th = sha2.sha256_bytes(bytes.view(blank)[0..0]) + bytes.free(blank) + salt = tls13_kdf.derive_secret("sha256", bytes.view(hs)[0..32], "derived", bytes.view(empty_th)[0..32]) + bytes.free(empty_th) + w = bytes.new(16) + bytes.copy_from_bytes(w, 0, salt, 0, 16) + bytes.wipe_free(salt) + ms = tls13_ks.master_secret("sha256", hs) + tls13_ks.free_secret(ms) + tls13_ks.free_secret(hs) + tls13_ks.free_secret(early) + return scan_after("TLS key schedule", w) +} + +main() { + if key_probe_supported() == 0 { + println("SKIP: freed memory must not be read here (a sanitizer or Valgrind)") + return + } + cw = window("5a1e5a1e00112233445566778899aabb") + c1 = control(cw, 1216) + c2 = control(cw, 264) + bytes.free(cw) + if c1 == 0 || c2 == 0 { + println("SKIP: the allocator does not hand freed blocks back as they were (control found ${c1}, ${c2})") + return + } + fails = 0 + fails = fails + check("RSA-2048, PKCS#8", rsa_pkcs8(), "26409a4310a03aecd9175eb011bab400") + fails = fails + check("RSA-2048, PKCS#1", rsa_pkcs1(), "26409a4310a03aecd9175eb011bab400") + fails = fails + check("P-256, PKCS#8", ec_pkcs8(), "a282997f3517fd44cd8e2c35c0ef513b") + fails = fails + check("P-256, SEC1", ec_sec1(), "a282997f3517fd44cd8e2c35c0ef513b") + // Signing, MACs and the shield (#3402). + fails = fails + sign_p256() + fails = fails + sign_ed25519() + fails = fails + sign_rsa() + fails = fails + mac() + fails = fails + shielded() + // The key exchange and the key schedule (#3414). + fails = fails + exchange() + fails = fails + key_schedule() + if fails != 0 { exit(1) } + if shield_unchecked == 1 { + println("key loading, signing, HMAC, the key exchange and schedule: no copy left in freed memory (control found ${c1}, ${c2}); the shield was not checked here") + return + } + println("key loading, signing, HMAC, the shield, the key exchange and schedule: no copy left in freed memory (control found ${c1}, ${c2})") +} + +// The test keys (made with openssl genpkey for this test only), and 16 +// bytes from the middle of each secret: the RSA private exponent's bytes +// 100 to 115, the P-256 scalar's bytes 8 to 23. +fn rsa_pkcs8() -> string { + return "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCKxqN/G2JHNlBP\nQplppCDxPCSE65Cb2Pg+mCyg0jRtb9Dlxygx7hZW864T9ce/HGygQmr5k39Fhgy+\nEQVXuu7+mmFTC48Q6nNbWAbc/5peT847YAyMEIEgFCPVPpMt9fvmYUVXzCJUvYF/\ngTZvHO6RKzl1FT4MxvAcOC8h1hQ8QjGip9ny6SluDHCfvCGrOylWCXK3Ml7clIis\nlbAVYqQerZXOXOj6krKUxKintA4lIqMB55efOp5yIs1/tg104xd1oVfgXj4Mf1fs\nijCEG9RepsD1PXeYWds/fxjXx9QlQZCL0976sy8MJwdbV+e7QdTL7wWSkO5ZEfKS\ndxUMu4IzAgMBAAECggEADHCpCFm5PMlQIq1wLimh2wN5V0GnSWkw5lTi2e8YobTJ\nJPKY66eRm217AzFgR1qcxt2dVE5rYB1CTX7IxqgBs5NoOmG0tfSmWZ+xoc73P2a9\nBdKNiA1qCH5LBgXMJhhrt9JWvSZAmkMQoDrs2RdesBG6tABZpcXm5p4HZOuZYxtU\nJ7KRmglkFgVxPFmO2n1Pn+hhSRRy/IR9mgqIaFv+3io2lB196oWZCygif6HewSbS\npFEyOWSOXcjwPVcMM6z7fVNN5f86+Ggxwl0CSY4wbeiJQn3F7l3tFZKwBVNA12OD\nCXUrZovEWLr7nNdspTxmAyIzLMejMQkZsUAboMXS/QKBgQC9bXc7OuZxnSwTX5bE\nYSjs6icSUFbLBYBXVONORQ7B29qb1G662/DfGMgOBgnX7+16tjvsPBmiERAlJAPY\nakgjPtartz561UVNgD1zoENEgmffmZ/vthtJDFdQSpIsBk2FiflMp+qbUXQHrF77\ncY8bgzE60UQeMqJXbngUimwm/wKBgQC7jB0bWRTNygbLScy4+lIOD9ZxZAtMGfnR\nJdcgOtLagTxLrHbYwvLAJ5g4euj3N9zF6PqKKgSXngd+crELGOtM5EaKjPcFTLAW\nWl8GnCwVLQszD6WeEn0rcsdTEiliBfCh/5YMNbJZ0xZWH6Di2MUQyj8TL6CcRAaS\nRa56uOe4zQKBgFHHIVNDwVrJNM8EVG1hWK63A7knT+ED9f0924fdzloo1P6zPj6T\nVzCpXSyYtMUxgn+YjtQUuig6X5L5MmPKtmvo4/pr+KBE1E96Do2NYZ0BA2EHzIFL\nSApINqWmG+a/rdX509OhJmp5RrNNWhAJXXPONoGi+FplF6rROSv8+mX3AoGAUmH1\n1zGGRe9clKfSWd77dFnYpO6BV+iBKDIrEWlFfd4PEl6rxgO8hlPeNdEnzJCpVfZF\nN7PFWKR+Pjk7M9i6Y4hSxEGvsJBDOU5xN4IaiyLCLKfO5GTSvucoT4FG1Fwvm7md\nu7dKpNJomWRDU+JMjleI5XCiaIIpMM+TGS1n/HECgYEAkhTYMv1VzER93fGw44cr\n3TTg/LGylMV/z2OXYkbEDe+/v7F4b+Ozr1PgKVBnVSvDx9jcdVKjSy/o9e4fd7H5\nBUVwSuftieum8TP9hoJ63x4TZzZHcLmeyZYV7cJe6hUwBNgl9xVvyB+Bf+4ducSn\nWKMnh0sn+d8QWGcE9PEReXw=\n-----END PRIVATE KEY-----\n" +} + +fn rsa_pkcs1() -> string { + return "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEAisajfxtiRzZQT0KZaaQg8TwkhOuQm9j4PpgsoNI0bW/Q5cco\nMe4WVvOuE/XHvxxsoEJq+ZN/RYYMvhEFV7ru/pphUwuPEOpzW1gG3P+aXk/OO2AM\njBCBIBQj1T6TLfX75mFFV8wiVL2Bf4E2bxzukSs5dRU+DMbwHDgvIdYUPEIxoqfZ\n8ukpbgxwn7whqzspVglytzJe3JSIrJWwFWKkHq2Vzlzo+pKylMSop7QOJSKjAeeX\nnzqeciLNf7YNdOMXdaFX4F4+DH9X7IowhBvUXqbA9T13mFnbP38Y18fUJUGQi9Pe\n+rMvDCcHW1fnu0HUy+8FkpDuWRHykncVDLuCMwIDAQABAoIBAAxwqQhZuTzJUCKt\ncC4podsDeVdBp0lpMOZU4tnvGKG0ySTymOunkZttewMxYEdanMbdnVROa2AdQk1+\nyMaoAbOTaDphtLX0plmfsaHO9z9mvQXSjYgNagh+SwYFzCYYa7fSVr0mQJpDEKA6\n7NkXXrARurQAWaXF5uaeB2TrmWMbVCeykZoJZBYFcTxZjtp9T5/oYUkUcvyEfZoK\niGhb/t4qNpQdfeqFmQsoIn+h3sEm0qRRMjlkjl3I8D1XDDOs+31TTeX/OvhoMcJd\nAkmOMG3oiUJ9xe5d7RWSsAVTQNdjgwl1K2aLxFi6+5zXbKU8ZgMiMyzHozEJGbFA\nG6DF0v0CgYEAvW13OzrmcZ0sE1+WxGEo7OonElBWywWAV1TjTkUOwdvam9Ruutvw\n3xjIDgYJ1+/terY77DwZohEQJSQD2GpIIz7Wq7c+etVFTYA9c6BDRIJn35mf77Yb\nSQxXUEqSLAZNhYn5TKfqm1F0B6xe+3GPG4MxOtFEHjKiV254FIpsJv8CgYEAu4wd\nG1kUzcoGy0nMuPpSDg/WcWQLTBn50SXXIDrS2oE8S6x22MLywCeYOHro9zfcxej6\niioEl54HfnKxCxjrTORGioz3BUywFlpfBpwsFS0LMw+lnhJ9K3LHUxIpYgXwof+W\nDDWyWdMWVh+g4tjFEMo/Ey+gnEQGkkWuerjnuM0CgYBRxyFTQ8FayTTPBFRtYViu\ntwO5J0/hA/X9PduH3c5aKNT+sz4+k1cwqV0smLTFMYJ/mI7UFLooOl+S+TJjyrZr\n6OP6a/igRNRPeg6NjWGdAQNhB8yBS0gKSDalphvmv63V+dPToSZqeUazTVoQCV1z\nzjaBovhaZReq0Tkr/Ppl9wKBgFJh9dcxhkXvXJSn0lne+3RZ2KTugVfogSgyKxFp\nRX3eDxJeq8YDvIZT3jXRJ8yQqVX2RTezxVikfj45OzPYumOIUsRBr7CQQzlOcTeC\nGosiwiynzuRk0r7nKE+BRtRcL5u5nbu3SqTSaJlkQ1PiTI5XiOVwomiCKTDPkxkt\nZ/xxAoGBAJIU2DL9VcxEfd3xsOOHK9004PyxspTFf89jl2JGxA3vv7+xeG/js69T\n4ClQZ1Urw8fY3HVSo0sv6PXuH3ex+QVFcErn7YnrpvEz/YaCet8eE2c2R3C5nsmW\nFe3CXuoVMATYJfcVb8gfgX/uHbnEp1ijJ4dLJ/nfEFhnBPTxEXl8\n-----END RSA PRIVATE KEY-----\n" +} + +fn ec_pkcs8() -> string { + return "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQghIAOPUifKACigpl/\nNRf9RM2OLDXA71E78lbJeL1jiW6hRANCAAQZrgzufBOfDgjViwVGF5QzJP9ccZVG\nPYKipK50RAfyjExBX8EIpl0x1E4NZw+XjbUElSo0kK4SrIqqNssyyYFi\n-----END PRIVATE KEY-----\n" +} + +fn ec_sec1() -> string { + return "-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIISADj1InygAooKZfzUX/UTNjiw1wO9RO/JWyXi9Y4luoAoGCCqGSM49\nAwEHoUQDQgAEGa4M7nwTnw4I1YsFRheUMyT/XHGVRj2CoqSudEQH8oxMQV/BCKZd\nMdRODWcPl421BJUqNJCuEqyKqjbLMsmBYg==\n-----END EC PRIVATE KEY-----\n" +} diff --git a/tests/integration/crypto_key_wipe/probe_shim.c b/tests/integration/crypto_key_wipe/probe_shim.c new file mode 100644 index 000000000..692c0236b --- /dev/null +++ b/tests/integration/crypto_key_wipe/probe_shim.c @@ -0,0 +1,105 @@ +/* The freed-memory probe of test_key_wipe.sh (#3382), in C: blocks from the + * C allocator as it hands them out, uninitialised, which is what a freed + * key copy would reach. (Aether's own allocations come back zeroed, so they + * cannot show what a block held before.) + * + * key_probe_supported() is 0 where reading such a block is itself an error + * or proves nothing: under Valgrind's memcheck, MemorySanitizer and + * AddressSanitizer (whose quarantine keeps freed blocks back). */ +#include +#include + +#if defined(__has_feature) +# if __has_feature(memory_sanitizer) || __has_feature(address_sanitizer) +# define KEY_PROBE_SANITIZER 1 +# endif +#endif +#if defined(__SANITIZE_ADDRESS__) +# define KEY_PROBE_SANITIZER 1 +#endif +#if defined(__linux__) && defined(__has_include) +# if __has_include() +# include +# define KEY_PROBE_VALGRIND 1 +# endif +#endif + +int key_probe_supported(void) { +#ifdef KEY_PROBE_SANITIZER + return 0; +#else +# ifdef KEY_PROBE_VALGRIND + if (RUNNING_ON_VALGRIND) return 0; +# endif + const char* pre = getenv("LD_PRELOAD"); + if (pre && strstr(pre, "vgpreload")) return 0; + return 1; +#endif +} + +/* Ask for `blocks` blocks of each power of two from `lo` to `hi` bytes (the + * sizes std.bytes gives its buffers, which double from 16), all held until + * the end so that none is handed out twice and the allocator's free blocks + * of those sizes run out, largest first so that small ones do not carve up + * a freed large block before it is looked at, look in each for the 16 bytes at `pattern`, then + * give them all back: how many held it. */ +int key_probe_scan(const unsigned char* pattern, int lo, int hi, int blocks) { + int hits = 0; + int sizes = 0; + for (int s = lo; s <= hi; s *= 2) sizes++; + size_t count = (size_t)sizes * (size_t)blocks; + void** held = (void**)calloc(count, sizeof(void*)); + if (!held) return -1; + size_t at = 0; + for (int size = hi; size >= lo; size /= 2) { + for (int k = 0; k < blocks; k++) { + unsigned char* p = (unsigned char*)malloc((size_t)size); + held[at++] = p; + if (!p) continue; + for (int i = 0; i + 16 <= size; i++) { + if (p[i] == pattern[0] && memcmp(p + i, pattern, 16) == 0) hits++; + } + } + } + for (size_t j = 0; j < at; j++) free(held[j]); + free(held); + return hits; +} + +/* The same over every size from `lo` to `hi` in steps of `step` bytes, for + * a block the program frees with the C allocator directly (a hash context + * is a struct of its own size, not a std.bytes buffer, #3402). */ +int key_probe_scan_step(const unsigned char* pattern, int lo, int hi, int step, int blocks) { + int hits = 0; + int sizes = (hi - lo) / step + 1; + size_t count = (size_t)sizes * (size_t)blocks; + void** held = (void**)calloc(count, sizeof(void*)); + if (!held) return -1; + size_t at = 0; + for (int size = hi; size >= lo; size -= step) { + for (int k = 0; k < blocks; k++) { + unsigned char* p = (unsigned char*)malloc((size_t)size); + held[at++] = p; + if (!p) continue; + for (int i = 0; i + 16 <= size; i++) { + if (p[i] == pattern[0] && memcmp(p + i, pattern, 16) == 0) hits++; + } + } + } + for (size_t j = 0; j < at; j++) free(held[j]); + free(held); + return hits; +} + +/* The control for that scan: `size` bytes from malloc with the 16 bytes at + * `pattern` in their middle, freed as they are. The writes go through a + * volatile pointer: before a free they are dead stores the compiler could + * drop, which would make the control prove nothing. */ +void key_probe_free_raw(const unsigned char* pattern, int size) { + unsigned char* p = (unsigned char*)malloc((size_t)size); + if (!p) return; + volatile unsigned char* v = p; + for (int i = 0; i < size; i++) v[i] = 0; + for (int i = 0; i < 16; i++) v[size / 2 + i] = pattern[i]; + free(p); +} diff --git a/tests/integration/crypto_key_wipe/test_key_wipe.sh b/tests/integration/crypto_key_wipe/test_key_wipe.sh new file mode 100644 index 000000000..f25db541e --- /dev/null +++ b/tests/integration/crypto_key_wipe/test_key_wipe.sh @@ -0,0 +1,33 @@ +#!/bin/sh +# The pure TLS server's key loading (#3382), and signing, HMAC and the shield +# (#3402), leave no copy of a secret in freed memory: probe.ae (with +# probe_shim.c, aether.toml) loads and frees four test keys, signs with three +# kinds, and looks for their secrets in the blocks the C allocator +# hands out next. Its control, a freed buffer that was not wiped, must be +# found first; where it is not, the probe proves nothing and the test is a +# skip. Windows (the UCRT heap) and glibc hand freed blocks back as they +# were; a sanitizer or Valgrind skips. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +AE="$ROOT/build/ae" +[ -n "${EXE_EXT:-}" ] && AE="$AE$EXE_EXT" +NAME=crypto_key_wipe +[ -x "$AE" ] || { echo " [SKIP] $NAME: ae not built"; exit 0; } + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +cd "$SCRIPT_DIR" || exit 1 +"$AE" build probe.ae -o "$TMP/probe" >"$TMP/build.log" 2>&1 \ + || { sed -n '1,12p' "$TMP/build.log"; echo " [FAIL] $NAME: probe.ae did not build"; exit 1; } +out=$("$TMP/probe" 2>&1) +rc=$? +case "$out" in + SKIP*) echo " [SKIP] $NAME: ${out#SKIP: }"; exit 0 ;; +esac +echo "$out" | sed 's/^/ /' +if [ "$rc" -ne 0 ]; then + echo " [FAIL] $NAME" + exit 1 +fi +echo " [PASS] $NAME" diff --git a/tests/integration/crypto_nist_curves/test_crypto_nist_curves.ae b/tests/integration/crypto_nist_curves/test_crypto_nist_curves.ae index 980ff14c3..383c66c63 100644 --- a/tests/integration/crypto_nist_curves/test_crypto_nist_curves.ae +++ b/tests/integration/crypto_nist_curves/test_crypto_nist_curves.ae @@ -1,16 +1,53 @@ // Validate std.cryptography.p384 + p521 (NIST P-384 / P-521 ECDH+ECDSA) -// against the published 2*G doubling vectors plus an ECDSA sign/verify -// round-trip with tamper rejection. +// against the published 2*G doubling vectors, RFC 6979 deterministic ECDSA +// (appendix A.2.6 and A.2.7, every SHA-2 digest of both messages), a +// sign/verify round trip with tamper rejection, and the input checks: +// private keys outside [1, n-1] and public points off the curve (#3291). import std.cryptography.p384 import std.cryptography.p521 +import std.cryptography.sha2 import std.bytes import std.string import std.io hv(c: int) -> int { if c >= 48 { if c <= 57 { return c - 48 } } if c >= 97 { if c <= 102 { return c - 87 } } if c >= 65 { if c <= 70 { return c - 55 } } return 0 } hb(h: string) -> ptr { n = string.length(h); b = bytes.new(n / 2); i = 0; while i < n { bytes.set(b, i / 2, hv(string_char_at_n(h, n, i)) * 16 + hv(string_char_at_n(h, n, i + 1))); i = i + 2 } return b } -bh(b: ptr) -> string { d = "0123456789abcdef"; o = ""; n = bytes.length(b); i = 0; while i < n { v = bytes.get(b, i); o = string.concat(o, string.concat(string.substring(d, (v / 16) & 15, ((v / 16) & 15) + 1), string.substring(d, v & 15, (v & 15) + 1))); i = i + 1 } return o } -ck(lbl: string, got: string, want: string) -> int { if got == want { println("ok ${lbl}"); return 0 } println("FAIL ${lbl}"); println(" got: ${got}"); println(" want: ${want}"); return 1 } +bh(b: ptr) -> string { if b == null { return "(null)" } d = "0123456789abcdef"; o = ""; n = bytes.length(b); i = 0; while i < n { v = bytes.get(b, i); o = string.concat(o, string.concat(string.substring(d, (v / 16) & 15, ((v / 16) & 15) + 1), string.substring(d, v & 15, (v & 15) + 1))); i = i + 1 } return o } +ck(lbl: string, got: string, want: string) -> int { if got == string.to_lower(want) { println("ok ${lbl}"); return 0 } println("FAIL ${lbl}"); println(" got: ${got}"); println(" want: ${want}"); return 1 } + +fn digest(algo: string, msg: string) -> ptr { + n = string.length(msg) + m = bytes.new(n) + i = 0 + while i < n { + bytes.set(m, i, string_char_at_n(msg, n, i)) + i = i + 1 + } + ctx, _e = sha2.new(algo) + sha2.update(ctx, bytes.view(m)) + bytes.free(m) + return sha2.final_bytes(ctx) +} + +fn rfc6979_p384(x: ptr, ux: ptr, uy: ptr, algo: string, msg: string, want_r: string, want_s: string) -> int { + h = digest(algo, msg) + r, s = p384.ecdsa_sign(x, h) + fails = ck("p384 rfc6979 ${algo} \"${msg}\" r", bh(r), want_r) + fails = fails + ck("p384 rfc6979 ${algo} \"${msg}\" s", bh(s), want_s) + if p384.ecdsa_verify(ux, uy, h, r, s) != 1 { println("FAIL p384 rfc6979 ${algo} \"${msg}\" does not verify"); fails = fails + 1 } + bytes.free(h); bytes.free(r); bytes.free(s) + return fails +} + +fn rfc6979_p521(x: ptr, ux: ptr, uy: ptr, algo: string, msg: string, want_r: string, want_s: string) -> int { + h = digest(algo, msg) + r, s = p521.ecdsa_sign(x, h) + fails = ck("p521 rfc6979 ${algo} \"${msg}\" r", bh(r), want_r) + fails = fails + ck("p521 rfc6979 ${algo} \"${msg}\" s", bh(s), want_s) + if p521.ecdsa_verify(ux, uy, h, r, s) != 1 { println("FAIL p521 rfc6979 ${algo} \"${msg}\" does not verify"); fails = fails + 1 } + bytes.free(h); bytes.free(r); bytes.free(s) + return fails +} main() { fails = 0 @@ -20,24 +57,113 @@ main() { x384, y384 = p384.scalar_mult_base(two384) fails = fails + ck("p384 2G x", bh(x384), "08d999057ba3d2d969260045c55b97f089025959a6f434d651d207d19fb96e9e4fe0e86ebe0e64f85b96a9c75295df61") fails = fails + ck("p384 2G y", bh(y384), "8e80f1fa5b1b3cedb7bfe8dffd6dba74b275d875bc6cc43e904e505f256ab4255ffd43e94d39e22d61501e700a940e80") + + // --- P-384: RFC 6979 A.2.6 --- + x6 = hb("6B9D3DAD2E1B8C1C05B19875B6659F4DE23C3B667BF297BA9AA47740787137D896D5724E4C70A825F872C9EA60D2EDF5") + ux6, uy6 = p384.scalar_mult_base(x6) + fails = fails + ck("p384 rfc6979 Ux", bh(ux6), "EC3A4E415B4E19A4568618029F427FA5DA9A8BC4AE92E02E06AAE5286B300C64DEF8F0EA9055866064A254515480BC13") + fails = fails + ck("p384 rfc6979 Uy", bh(uy6), "8015D9B72D7D57244EA8EF9AC0C621896708A59367F9DFB9F54CA84B3F1C9DB1288B231C3AE0D4FE7344FD2533264720") + fails = fails + rfc6979_p384(x6, ux6, uy6, "sha224", "sample", + "42356E76B55A6D9B4631C865445DBE54E056D3B3431766D0509244793C3F9366450F76EE3DE43F5A125333A6BE060122", + "9DA0C81787064021E78DF658F2FBB0B042BF304665DB721F077A4298B095E4834C082C03D83028EFBF93A3C23940CA8D") + fails = fails + rfc6979_p384(x6, ux6, uy6, "sha256", "sample", + "21B13D1E013C7FA1392D03C5F99AF8B30C570C6F98D4EA8E354B63A21D3DAA33BDE1E888E63355D92FA2B3C36D8FB2CD", + "F3AA443FB107745BF4BD77CB3891674632068A10CA67E3D45DB2266FA7D1FEEBEFDC63ECCD1AC42EC0CB8668A4FA0AB0") + fails = fails + rfc6979_p384(x6, ux6, uy6, "sha384", "sample", + "94EDBB92A5ECB8AAD4736E56C691916B3F88140666CE9FA73D64C4EA95AD133C81A648152E44ACF96E36DD1E80FABE46", + "99EF4AEB15F178CEA1FE40DB2603138F130E740A19624526203B6351D0A3A94FA329C145786E679E7B82C71A38628AC8") + fails = fails + rfc6979_p384(x6, ux6, uy6, "sha512", "sample", + "ED0959D5880AB2D869AE7F6C2915C6D60F96507F9CB3E047C0046861DA4A799CFE30F35CC900056D7C99CD7882433709", + "512C8CCEEE3890A84058CE1E22DBC2198F42323CE8ACA9135329F03C068E5112DC7CC3EF3446DEFCEB01A45C2667FDD5") + fails = fails + rfc6979_p384(x6, ux6, uy6, "sha224", "test", + "E8C9D0B6EA72A0E7837FEA1D14A1A9557F29FAA45D3E7EE888FC5BF954B5E62464A9A817C47FF78B8C11066B24080E72", + "07041D4A7A0379AC7232FF72E6F77B6DDB8F09B16CCE0EC3286B2BD43FA8C6141C53EA5ABEF0D8231077A04540A96B66") + fails = fails + rfc6979_p384(x6, ux6, uy6, "sha256", "test", + "6D6DEFAC9AB64DABAFE36C6BF510352A4CC27001263638E5B16D9BB51D451559F918EEDAF2293BE5B475CC8F0188636B", + "2D46F3BECBCC523D5F1A1256BF0C9B024D879BA9E838144C8BA6BAEB4B53B47D51AB373F9845C0514EEFB14024787265") + fails = fails + rfc6979_p384(x6, ux6, uy6, "sha384", "test", + "8203B63D3C853E8D77227FB377BCF7B7B772E97892A80F36AB775D509D7A5FEB0542A7F0812998DA8F1DD3CA3CF023DB", + "DDD0760448D42D8A43AF45AF836FCE4DE8BE06B485E9B61B827C2F13173923E06A739F040649A667BF3B828246BAA5A5") + fails = fails + rfc6979_p384(x6, ux6, uy6, "sha512", "test", + "A0D5D090C9980FAF3C2CE57B7AE951D31977DD11C775D314AF55F76C676447D06FB6495CD21B4B6E340FC236584FB277", + "976984E59B4C77B0E8E4460DCA3D9F20E07B9BB1F63BEEFAF576F6B2E8B224634A2092CD3792E0159AD9CEE37659C736") + + // --- P-384 round trip, tamper, refusals --- d384 = hb("c838b85253ef8dc7394fa5808a5183981c7deef5a69ba8f4f2117ffea39cfcd90e95f6cbc854abacab701d50c1f3cf24") h384 = hb("0102030405060708010203040506070801020304050607080102030405060708010203040506070801020304050607aa") - k384 = hb("dc6b44036989a196e39d1cdac000812f4bdd8b2db41bb33af51372585ebd1db63f0ce8275aa1fd45e2d2a735f8749359") px384, py384 = p384.scalar_mult_base(d384) - r384, s384 = p384.ecdsa_sign(d384, h384, k384) + r384, s384 = p384.ecdsa_sign(d384, h384) if p384.ecdsa_verify(px384, py384, h384, r384, s384) == 1 { println("ok p384 sign/verify") } else { println("FAIL p384 sign/verify"); fails = fails + 1 } + bytes.set(h384, 0, 0) + if p384.ecdsa_verify(px384, py384, h384, r384, s384) == 0 { println("ok p384 tamper rejected") } else { println("FAIL p384 tamper accepted"); fails = fails + 1 } + off384 = hb("8e80f1fa5b1b3cedb7bfe8dffd6dba74b275d875bc6cc43e904e505f256ab4255ffd43e94d39e22d61501e700a940e81") + if p384.ecdh(d384, x384, off384) == null && p384.public_key_valid(x384, off384) == 0 && p384.public_key_valid(x384, y384) == 1 { + println("ok p384 refuses an off-curve point") + } else { println("FAIL p384 accepted an off-curve point"); fails = fails + 1 } + z384 = hb("000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000") + zr, zs = p384.ecdsa_sign(z384, h384) + if zr == null && zs == null && p384.private_key_valid(z384) == 0 && p384.private_key_valid(d384) == 1 { + println("ok p384 refuses a zero key") + } else { println("FAIL p384 signed with a zero key"); fails = fails + 1 } // --- P-521: priv=2 -> 2G --- two521 = hb("000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002") x521, y521 = p521.scalar_mult_base(two521) fails = fails + ck("p521 2G x", bh(x521), "00433c219024277e7e682fcb288148c282747403279b1ccc06352c6e5505d769be97b3b204da6ef55507aa104a3a35c5af41cf2fa364d60fd967f43e3933ba6d783d") fails = fails + ck("p521 2G y", bh(y521), "00f4bb8cc7f86db26700a7f3eceeeed3f0b5c6b5107c4da97740ab21a29906c42dbbb3e377de9f251f6b93937fa99a3248f4eafcbe95edc0f4f71be356d661f41b02") + + // --- P-521: RFC 6979 A.2.7 --- + x7 = hb("00FAD06DAA62BA3B25D2FB40133DA757205DE67F5BB0018FEE8C86E1B68C7E75CAA896EB32F1F47C70855836A6D16FCC1466F6D8FBEC67DB89EC0C08B0E996B83538") + ux7, uy7 = p521.scalar_mult_base(x7) + fails = fails + ck("p521 rfc6979 Ux", bh(ux7), "01894550D0785932E00EAA23B694F213F8C3121F86DC97A04E5A7167DB4E5BCD371123D46E45DB6B5D5370A7F20FB633155D38FFA16D2BD761DCAC474B9A2F5023A4") + fails = fails + ck("p521 rfc6979 Uy", bh(uy7), "00493101C962CD4D2FDDF782285E64584139C2F91B47F87FF82354D6630F746A28A0DB25741B5B34A828008B22ACC23F924FAAFBD4D33F81EA66956DFEAA2BFDFCF5") + fails = fails + rfc6979_p521(x7, ux7, uy7, "sha224", "sample", + "01776331CFCDF927D666E032E00CF776187BC9FDD8E69D0DABB4109FFE1B5E2A30715F4CC923A4A5E94D2503E9ACFED92857B7F31D7152E0F8C00C15FF3D87E2ED2E", + "0050CB5265417FE2320BBB5A122B8E1A32BD699089851128E360E620A30C7E17BA41A666AF126CE100E5799B153B60528D5300D08489CA9178FB610A2006C254B41F") + fails = fails + rfc6979_p521(x7, ux7, uy7, "sha256", "sample", + "01511BB4D675114FE266FC4372B87682BAECC01D3CC62CF2303C92B3526012659D16876E25C7C1E57648F23B73564D67F61C6F14D527D54972810421E7D87589E1A7", + "004A171143A83163D6DF460AAF61522695F207A58B95C0644D87E52AA1A347916E4F7A72930B1BC06DBE22CE3F58264AFD23704CBB63B29B931F7DE6C9D949A7ECFC") + fails = fails + rfc6979_p521(x7, ux7, uy7, "sha384", "sample", + "01EA842A0E17D2DE4F92C15315C63DDF72685C18195C2BB95E572B9C5136CA4B4B576AD712A52BE9730627D16054BA40CC0B8D3FF035B12AE75168397F5D50C67451", + "01F21A3CEE066E1961025FB048BD5FE2B7924D0CD797BABE0A83B66F1E35EEAF5FDE143FA85DC394A7DEE766523393784484BDF3E00114A1C857CDE1AA203DB65D61") + fails = fails + rfc6979_p521(x7, ux7, uy7, "sha512", "sample", + "00C328FAFCBD79DD77850370C46325D987CB525569FB63C5D3BC53950E6D4C5F174E25A1EE9017B5D450606ADD152B534931D7D4E8455CC91F9B15BF05EC36E377FA", + "00617CCE7CF5064806C467F678D3B4080D6F1CC50AF26CA209417308281B68AF282623EAA63E5B5C0723D8B8C37FF0777B1A20F8CCB1DCCC43997F1EE0E44DA4A67A") + fails = fails + rfc6979_p521(x7, ux7, uy7, "sha224", "test", + "01C7ED902E123E6815546065A2C4AF977B22AA8EADDB68B2C1110E7EA44D42086BFE4A34B67DDC0E17E96536E358219B23A706C6A6E16BA77B65E1C595D43CAE17FB", + "0177336676304FCB343CE028B38E7B4FBA76C1C1B277DA18CAD2A8478B2A9A9F5BEC0F3BA04F35DB3E4263569EC6AADE8C92746E4C82F8299AE1B8F1739F8FD519A4") + fails = fails + rfc6979_p521(x7, ux7, uy7, "sha256", "test", + "000E871C4A14F993C6C7369501900C4BC1E9C7B0B4BA44E04868B30B41D8071042EB28C4C250411D0CE08CD197E4188EA4876F279F90B3D8D74A3C76E6F1E4656AA8", + "00CD52DBAA33B063C3A6CD8058A1FB0A46A4754B034FCC644766CA14DA8CA5CA9FDE00E88C1AD60CCBA759025299079D7A427EC3CC5B619BFBC828E7769BCD694E86") + fails = fails + rfc6979_p521(x7, ux7, uy7, "sha384", "test", + "014BEE21A18B6D8B3C93FAB08D43E739707953244FDBE924FA926D76669E7AC8C89DF62ED8975C2D8397A65A49DCC09F6B0AC62272741924D479354D74FF6075578C", + "0133330865C067A0EAF72362A65E2D7BC4E461E8C8995C3B6226A21BD1AA78F0ED94FE536A0DCA35534F0CD1510C41525D163FE9D74D134881E35141ED5E8E95B979") + fails = fails + rfc6979_p521(x7, ux7, uy7, "sha512", "test", + "013E99020ABF5CEE7525D16B69B229652AB6BDF2AFFCAEF38773B4B7D08725F10CDB93482FDCC54EDCEE91ECA4166B2A7C6265EF0CE2BD7051B7CEF945BABD47EE6D", + "01FBD0013C674AA79CB39849527916CE301C66EA7CE8B80682786AD60F98F7E78A19CA69EFF5C57400E3B3A0AD66CE0978214D13BAF4E9AC60752F7B155E2DE4DCE3") + + // --- P-521 round trip (a 64-byte SHA-512 digest, as it is), refusals --- d521 = hb("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123") - h521 = hb("0102030405060708010203040506070801020304050607080102030405060708010203040506070801020304050607080102030405060708010203040506070801020a") - k521 = hb("00abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789ab01") + h521 = digest("sha512", "P-521 round trip") px521, py521 = p521.scalar_mult_base(d521) - r521, s521 = p521.ecdsa_sign(d521, h521, k521) + r521, s521 = p521.ecdsa_sign(d521, h521) if p521.ecdsa_verify(px521, py521, h521, r521, s521) == 1 { println("ok p521 sign/verify") } else { println("FAIL p521 sign/verify"); fails = fails + 1 } + bytes.set(h521, 0, bytes.get(h521, 0) ^ 1) + if p521.ecdsa_verify(px521, py521, h521, r521, s521) == 0 { println("ok p521 tamper rejected") } else { println("FAIL p521 tamper accepted"); fails = fails + 1 } + off521 = hb("00f4bb8cc7f86db26700a7f3eceeeed3f0b5c6b5107c4da97740ab21a29906c42dbbb3e377de9f251f6b93937fa99a3248f4eafcbe95edc0f4f71be356d661f41b03") + if p521.ecdh(d521, x521, off521) == null && p521.public_key_valid(x521, off521) == 0 && p521.public_key_valid(x521, y521) == 1 { + println("ok p521 refuses an off-curve point") + } else { println("FAIL p521 accepted an off-curve point"); fails = fails + 1 } + // A 66-byte scalar with bit 521 set is past n. + big521 = hb("0200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001") + if p521.private_key_valid(big521) == 0 && p521.ecdh(big521, x521, y521) == null { println("ok p521 refuses a key past n") } else { println("FAIL p521 accepted a key past n"); fails = fails + 1 } + + bytes.free(two384); bytes.free(x384); bytes.free(y384); bytes.free(x6); bytes.free(ux6); bytes.free(uy6) + bytes.free(d384); bytes.free(h384); bytes.free(px384); bytes.free(py384); bytes.free(r384); bytes.free(s384) + bytes.free(off384); bytes.free(z384) + bytes.free(two521); bytes.free(x521); bytes.free(y521); bytes.free(x7); bytes.free(ux7); bytes.free(uy7) + bytes.free(d521); bytes.free(h521); bytes.free(px521); bytes.free(py521); bytes.free(r521); bytes.free(s521) + bytes.free(off521); bytes.free(big521) if fails == 0 { println("ALL PASS"); return } println("FAILURES"); exit(1) diff --git a/tests/integration/crypto_p256/test_crypto_p256.ae b/tests/integration/crypto_p256/test_crypto_p256.ae index e731792dd..1ebd3f284 100644 --- a/tests/integration/crypto_p256/test_crypto_p256.ae +++ b/tests/integration/crypto_p256/test_crypto_p256.ae @@ -1,14 +1,47 @@ // Validate std.cryptography.p256: known base-point multiples, a NIST -// ECDH CAVP vector, and an ECDSA sign/verify round-trip (+ tamper). +// ECDH CAVP vector, RFC 6979 deterministic ECDSA (appendix A.2.5, every SHA-2 +// digest), a sign/verify round trip with tamper rejection, and the input +// checks: private keys outside [1, n-1], and public points off the curve or +// with a coordinate >= p (the invalid-curve attack, #3291). import std.cryptography.p256 +import std.cryptography.sha2 import std.bytes import std.string import std.io -hv(c: int) -> int { if c >= 48 { if c <= 57 { return c - 48 } } if c >= 97 { if c <= 102 { return c - 87 } } return 0 } +hv(c: int) -> int { if c >= 48 { if c <= 57 { return c - 48 } } if c >= 97 { if c <= 102 { return c - 87 } } if c >= 65 { if c <= 70 { return c - 55 } } return 0 } hb(h: string) -> ptr { n = string.length(h); b = bytes.new(n / 2); i = 0; while i < n { bytes.set(b, i / 2, hv(string_char_at_n(h, n, i)) * 16 + hv(string_char_at_n(h, n, i + 1))); i = i + 2 } return b } -bh(b: ptr) -> string { d = "0123456789abcdef"; o = ""; n = bytes.length(b); i = 0; while i < n { v = bytes.get(b, i); o = string.concat(o, string.concat(string.substring(d, (v / 16) & 15, ((v / 16) & 15) + 1), string.substring(d, v & 15, (v & 15) + 1))); i = i + 1 } return o } -ck(lbl: string, got: string, want: string) -> int { if got == want { println("ok ${lbl}"); return 0 } println("FAIL ${lbl}\n ${got}\n ${want}"); return 1 } +bh(b: ptr) -> string { if b == null { return "(null)" } d = "0123456789abcdef"; o = ""; n = bytes.length(b); i = 0; while i < n { v = bytes.get(b, i); o = string.concat(o, string.concat(string.substring(d, (v / 16) & 15, ((v / 16) & 15) + 1), string.substring(d, v & 15, (v & 15) + 1))); i = i + 1 } return o } +ck(lbl: string, got: string, want: string) -> int { if got == string.to_lower(want) { println("ok ${lbl}"); return 0 } println("FAIL ${lbl}\n ${got}\n ${want}"); return 1 } + +fn digest(algo: string, msg: string) -> ptr { + n = string.length(msg) + m = bytes.new(n) + i = 0 + while i < n { + bytes.set(m, i, string_char_at_n(msg, n, i)) + i = i + 1 + } + ctx, _e = sha2.new(algo) + sha2.update(ctx, bytes.view(m)) + bytes.free(m) + return sha2.final_bytes(ctx) +} + +// RFC 6979 A.2.5: sign `msg` hashed with `algo` under the appendix's key and +// compare (r, s); the signature must also verify. +fn rfc6979(priv: ptr, px: ptr, py: ptr, algo: string, msg: string, want_r: string, want_s: string) -> int { + h = digest(algo, msg) + r, s = p256.ecdsa_sign(priv, h) + fails = ck("rfc6979 ${algo} \"${msg}\" r", bh(r), want_r) + fails = fails + ck("rfc6979 ${algo} \"${msg}\" s", bh(s), want_s) + if p256.ecdsa_verify(px, py, h, r, s) != 1 { + println("FAIL rfc6979 ${algo} \"${msg}\" does not verify") + fails = fails + 1 + } + bytes.free(h); bytes.free(r); bytes.free(s) + return fails +} main() { fails = 0 @@ -18,24 +51,94 @@ main() { g2x, g2y = p256.scalar_mult_base(two) fails = fails + ck("2G x", bh(g2x), "7cf27b188d034f7e8a52380304b51ac3c08969e277f21b35a60b48fc47669978") fails = fails + ck("2G y", bh(g2y), "07775510db8ed040293d9ac69f7430dbba7dade63ce982299e04b79d227873d1") + // (n-1)*G = -G: the scalar multiplication across the whole width. + nm1 = hb("ffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632550") + mgx, mgy = p256.scalar_mult_base(nm1) + fails = fails + ck("(n-1)G x", bh(mgx), "6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296") + fails = fails + ck("(n-1)G y", bh(mgy), "b01cbd1c01e58065711814b583f061e9d431cca994cea1313449bf97c840ae0a") + // n*G is the point at infinity: no affine coordinates. + nn = hb("ffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551") + ix, iy = p256.scalar_mult_base(nn) + if ix == null && iy == null { println("ok nG is the identity") } else { println("FAIL nG returned a point"); fails = fails + 1 } // NIST ECDH CAVP vector. priv = hb("7d7dc5f71eb29ddaf80d6214632eeae03d9058af1fb6d22ed80badb62bc1a534") pbx = hb("700c48f77f56584c5cc632ca65640db91b6bacce3a4df6b42ce7cc838833d287") pby = hb("db71e509e3fd9b060ddb20ba5c51dcc5948d46fbf640dfe0441782cab85fa4ac") - fails = fails + ck("ecdh", bh(p256.ecdh(priv, pbx, pby)), - "46fc62106420ff012e54a434fbdd2d25ccc5852060561e68040dd7778997bd7b") + z = p256.ecdh(priv, pbx, pby) + fails = fails + ck("ecdh", bh(z), "46fc62106420ff012e54a434fbdd2d25ccc5852060561e68040dd7778997bd7b") + + // RFC 6979 A.2.5: the key pair, then every SHA-2 digest of both messages. + x = hb("C9AFA9D845BA75166B5C215767B1D6934E50C3DB36E89B127B8A622B120F6721") + ux, uy = p256.scalar_mult_base(x) + fails = fails + ck("rfc6979 Ux", bh(ux), "60FED4BA255A9D31C961EB74C6356D68C049B8923B61FA6CE669622E60F29FB6") + fails = fails + ck("rfc6979 Uy", bh(uy), "7903FE1008B8BC99A41AE9E95628BC64F2F1B20C2D7E9F5177A3C294D4462299") + fails = fails + rfc6979(x, ux, uy, "sha224", "sample", + "53B2FFF5D1752B2C689DF257C04C40A587FABABB3F6FC2702F1343AF7CA9AA3F", + "B9AFB64FDC03DC1A131C7D2386D11E349F070AA432A4ACC918BEA988BF75C74C") + fails = fails + rfc6979(x, ux, uy, "sha256", "sample", + "EFD48B2AACB6A8FD1140DD9CD45E81D69D2C877B56AAF991C34D0EA84EAF3716", + "F7CB1C942D657C41D436C7A1B6E29F65F3E900DBB9AFF4064DC4AB2F843ACDA8") + fails = fails + rfc6979(x, ux, uy, "sha384", "sample", + "0EAFEA039B20E9B42309FB1D89E213057CBF973DC0CFC8F129EDDDC800EF7719", + "4861F0491E6998B9455193E34E7B0D284DDD7149A74B95B9261F13ABDE940954") + fails = fails + rfc6979(x, ux, uy, "sha512", "sample", + "8496A60B5E9B47C825488827E0495B0E3FA109EC4568FD3F8D1097678EB97F00", + "2362AB1ADBE2B8ADF9CB9EDAB740EA6049C028114F2460F96554F61FAE3302FE") + fails = fails + rfc6979(x, ux, uy, "sha224", "test", + "C37EDB6F0AE79D47C3C27E962FA269BB4F441770357E114EE511F662EC34A692", + "C820053A05791E521FCAAD6042D40AEA1D6B1A540138558F47D0719800E18F2D") + fails = fails + rfc6979(x, ux, uy, "sha256", "test", + "F1ABB023518351CD71D881567B1EA663ED3EFCF6C5132B354F28D3B0B7D38367", + "019F4113742A2B14BD25926B49C649155F267E60D3814B4C0CC84250E46F0083") + fails = fails + rfc6979(x, ux, uy, "sha384", "test", + "83910E8B48BB0C74244EBDF7F07A1C5413D61472BD941EF3920E623FBCCEBEB6", + "8DDBEC54CF8CD5874883841D712142A56A8D0F218F5003CB0296B6B509619F2C") + fails = fails + rfc6979(x, ux, uy, "sha512", "test", + "461D93F31B6540894788FD206C07CFA0CC35F46FA3C91816FFF1040AD1581A04", + "39AF9F15DE0DB8D97E72719C74820D304CE5226E32DEDAE67519E840D1194E55") - // ECDSA sign/verify round-trip. + // Round trip with an arbitrary key and digest, then tamper. d = hb("c477f9f65c22cce20657faa5b2d1d8122336f851a508a1ed04e479c34985bf96") hash = hb("0102030405060708010203040506070801020304050607080102030405060708") - k = hb("7a1a7e52797fc8caaa435d2a4dace39158504bf204fbe19f14dbb427faee50ae") - r, s = p256.ecdsa_sign(d, hash, k) + r, s = p256.ecdsa_sign(d, hash) + r2, s2 = p256.ecdsa_sign(d, hash) + if bh(r) == bh(r2) && bh(s) == bh(s2) { println("ok signing is deterministic") } else { println("FAIL two signatures of one digest differ"); fails = fails + 1 } px, py = p256.scalar_mult_base(d) if p256.ecdsa_verify(px, py, hash, r, s) != 1 { println("FAIL ecdsa verify"); fails = fails + 1 } else { println("ok ecdsa sign/verify") } bytes.set(hash, 0, bytes.get(hash, 0) ^ 1) if p256.ecdsa_verify(px, py, hash, r, s) != 0 { println("FAIL ecdsa tamper not rejected"); fails = fails + 1 } else { println("ok ecdsa tamper rejected") } + // Private keys outside [1, n-1] are refused by sign and ecdh. + zero = hb("0000000000000000000000000000000000000000000000000000000000000000") + zr, zs = p256.ecdsa_sign(zero, hash) + nr, ns = p256.ecdsa_sign(nn, hash) + if zr == null && zs == null && nr == null && ns == null { println("ok sign refuses 0 and n") } else { println("FAIL sign accepted a key outside [1, n-1]"); fails = fails + 1 } + if p256.ecdh(zero, pbx, pby) == null && p256.ecdh(nn, pbx, pby) == null { println("ok ecdh refuses 0 and n") } else { println("FAIL ecdh accepted a key outside [1, n-1]"); fails = fails + 1 } + if p256.private_key_valid(zero) == 0 && p256.private_key_valid(nn) == 0 && p256.private_key_valid(nm1) == 1 && p256.private_key_valid(d) == 1 { + println("ok private_key_valid") + } else { println("FAIL private_key_valid"); fails = fails + 1 } + + // Invalid-curve attack (#3291): a point off the curve (y + 1), and one + // with x = p, are refused by ecdh, scalar_mult and verify. + offy = hb("db71e509e3fd9b060ddb20ba5c51dcc5948d46fbf640dfe0441782cab85fa4ad") + xp = hb("ffffffff00000001000000000000000000000000ffffffffffffffffffffffff") + if p256.ecdh(priv, pbx, offy) == null { println("ok ecdh refuses an off-curve point") } else { println("FAIL ecdh accepted an off-curve point"); fails = fails + 1 } + if p256.ecdh(priv, xp, pby) == null { println("ok ecdh refuses x = p") } else { println("FAIL ecdh accepted x = p"); fails = fails + 1 } + sx, sy = p256.scalar_mult(priv, pbx, offy) + if sx == null && sy == null { println("ok scalar_mult refuses an off-curve point") } else { println("FAIL scalar_mult accepted an off-curve point"); fails = fails + 1 } + if p256.public_key_valid(pbx, pby) == 1 && p256.public_key_valid(pbx, offy) == 0 && p256.public_key_valid(xp, pby) == 0 { + println("ok public_key_valid") + } else { println("FAIL public_key_valid"); fails = fails + 1 } + bytes.set(hash, 0, bytes.get(hash, 0) ^ 1) + if p256.ecdsa_verify(px, offy, hash, r, s) == 0 { println("ok verify refuses an off-curve key") } else { println("FAIL verify accepted an off-curve key"); fails = fails + 1 } + + bytes.free(two); bytes.free(g2x); bytes.free(g2y); bytes.free(nm1); bytes.free(mgx); bytes.free(mgy); bytes.free(nn) + bytes.free(priv); bytes.free(pbx); bytes.free(pby); bytes.free(z) + bytes.free(x); bytes.free(ux); bytes.free(uy) + bytes.free(d); bytes.free(hash); bytes.free(r); bytes.free(s); bytes.free(r2); bytes.free(s2); bytes.free(px); bytes.free(py) + bytes.free(zero); bytes.free(offy); bytes.free(xp) + if fails == 0 { println("ALL PASS"); return } println("FAILURES"); exit(1) } diff --git a/tests/integration/crypto_random_buffer/test_random_buffer.ae b/tests/integration/crypto_random_buffer/test_random_buffer.ae new file mode 100644 index 000000000..245460029 --- /dev/null +++ b/tests/integration/crypto_random_buffer/test_random_buffer.ae @@ -0,0 +1,45 @@ +// cryptography.random_buffer: n CSPRNG bytes in a std.bytes buffer, or an +// error, never a buffer of zeros (#3293, #2600). +import std.bytes +import std.cryptography + +extern exit(code: int) + +fn ck(cond: bool, msg: string) -> int { + if cond { + println("ok ${msg}") + return 0 + } + println("FAIL ${msg}") + return 1 +} + +main() { + fails = 0 + a, aerr = cryptography.random_buffer(32) + b, berr = cryptography.random_buffer(32) + fails = fails + ck(aerr == "" && berr == "" && a != null && b != null, "two 32-byte draws succeed") + fails = fails + ck(bytes.length(a) == 32 && bytes.length(b) == 32, "each is 32 bytes long") + same = 1 + nonzero = 0 + i = 0 + while i < 32 { + if bytes.get(a, i) != bytes.get(b, i) { same = 0 } + if bytes.get(a, i) != 0 { nonzero = 1 } + i = i + 1 + } + fails = fails + ck(same == 0, "two draws differ") + fails = fails + ck(nonzero == 1, "a draw is not all zeros") + z, zerr = cryptography.random_buffer(0) + fails = fails + ck(zerr == "" && z != null && bytes.length(z) == 0, "n = 0 gives an empty buffer") + n, nerr = cryptography.random_buffer(-1) + fails = fails + ck(n == null && nerr != "", "a negative n is an error") + bytes.free(a) + bytes.free(b) + bytes.free(z) + if fails > 0 { + println("${fails} FAILED") + exit(1) + } + println("ALL PASS") +} diff --git a/tests/integration/crypto_random_failure/aether.toml b/tests/integration/crypto_random_failure/aether.toml new file mode 100644 index 000000000..af1baed1c --- /dev/null +++ b/tests/integration/crypto_random_failure/aether.toml @@ -0,0 +1,4 @@ +[[bin]] +name = "rngfail" +path = "rngfail.ae" +extra_sources = ["rng_fail_shim.c"] diff --git a/tests/integration/crypto_random_failure/listener.ae b/tests/integration/crypto_random_failure/listener.ae new file mode 100644 index 000000000..ca8fbab0a --- /dev/null +++ b/tests/integration/crypto_random_failure/listener.ae @@ -0,0 +1,66 @@ +// The other end for rngfail.ae (#3293): accepts connections on 127.0.0.1 +// (its port to PORT_OUT) until none comes for three seconds, and prints +// for each what the client sent before it closes it: "NOTHING", or +// "CH random_zero=<0|1> sid_zero=<0|1>", whether the ClientHello's random +// and legacy_session_id are all zero bytes. + +import std.os +import std.fs +import std.tcp +import std.bytes + +extern exit(code: int) + +fn all_zero(b: ptr, off: int, n: int) -> int { + i = 0 + while i < n { + if (bytes.get(b, off + i) & 0xFF) != 0 { return 0 } + i = i + 1 + } + return 1 +} + +main() { + srv, lerr = tcp.listen_on("127.0.0.1", 0) + if lerr != "" { + println("FAIL: ${lerr}") + exit(1) + } + _w = fs.write(os.getenv("PORT_OUT"), "${tcp.server_port(srv)}") + buf = bytes.new(8192) + bytes.set(buf, 8191, 0) + while tcp.server_poll(srv, 3000) > 0 { + sock, aerr = tcp.accept(srv) + if aerr == "" { + total = 0 + reading = 1 + while reading == 1 && total < 8192 { + ready, perr = tcp.poll(sock, 2000) + if perr != "" || ready == false { + reading = 0 + } else { + got, rerr = tcp.read_into(sock, bytes.view(buf)[total..8192]) + if rerr != "" || got <= 0 { + reading = 0 + } else { + total = total + got + // A whole first record is enough. + if total >= 5 && total >= 5 + (((bytes.get(buf, 3) & 0xFF) << 8) | (bytes.get(buf, 4) & 0xFF)) { reading = 0 } + } + } + } + // record header 5, handshake header 4, legacy_version 2, then + // the random (32), the session id length and the session id. + if total == 0 { + println("NOTHING") + } else if total < 5 + 4 + 2 + 32 + 1 + 32 { + println("SHORT ${total}") + } else { + println("CH random_zero=${all_zero(buf, 11, 32)} sid_zero=${all_zero(buf, 44, 32)}") + } + tcp.close(sock) + } + } + bytes.free(buf) + _ = tcp.server_close(srv) +} diff --git a/tests/integration/crypto_random_failure/rng_fail_shim.c b/tests/integration/crypto_random_failure/rng_fail_shim.c new file mode 100644 index 000000000..d355680f1 --- /dev/null +++ b/tests/integration/crypto_random_failure/rng_fail_shim.c @@ -0,0 +1,65 @@ +/* The OS CSPRNG, failing on request (#3293; see test_random_failure.sh). + * + * std.cryptography draws its random bytes from the OS: BCryptGenRandom on + * Windows, getrandom(2) on Linux. This file, linked into rngfail.ae + * (aether.toml), puts itself in front of that call: rng_fail_after(n) + * lets n more calls through and fails every one after, as a CSPRNG that + * stops working would; rng_fail_after(-1) never fails. On Windows the + * library calls BCryptGenRandom through its import pointer, and this file + * defines both (so the linker takes them before bcrypt's); on Linux + * this getrandom comes before libc's. Elsewhere (macOS and the BSDs use + * arc4random_buf, which cannot fail) nothing is replaced and + * rng_fail_supported() is 0. */ +#if defined(__linux__) +#define _GNU_SOURCE /* syscall(2) in unistd.h */ +#endif +#include + +static int g_allow = -1; /* calls left before failing; -1: never fail */ +static int g_calls = 0; /* calls seen, failed or not */ + +void rng_fail_after(int n) { g_allow = n; } +int rng_fail_calls(void) { return g_calls; } + +/* 1 when this call must fail. */ +static int rng_fail_now(void) { + g_calls++; + if (g_allow < 0) return 0; + if (g_allow == 0) return 1; + g_allow--; + return 0; +} + +#if defined(_WIN32) +#include +typedef long (WINAPI *gen_fn)(void *alg, unsigned char *buf, unsigned long n, unsigned long flags); + +long WINAPI BCryptGenRandom(void *alg, unsigned char *buf, unsigned long n, unsigned long flags) { + static gen_fn real = NULL; + if (rng_fail_now()) return (long)0xC0000001L; /* STATUS_UNSUCCESSFUL */ + if (!real) real = (gen_fn)(void *)GetProcAddress(LoadLibraryA("bcrypt.dll"), "BCryptGenRandom"); + return real ? real(alg, buf, n, flags) : (long)0xC0000001L; +} + +/* Both names, so the linker needs nothing from bcrypt's import library: the + * thunk for a direct call, the pointer for a dllimport one. */ +gen_fn __imp_BCryptGenRandom = BCryptGenRandom; + +int rng_fail_supported(void) { return 1; } +#elif defined(__linux__) +#include +#include +#include + +ssize_t getrandom(void *buf, size_t n, unsigned int flags) { + if (rng_fail_now()) { + errno = EIO; + return -1; + } + return (ssize_t)syscall(SYS_getrandom, buf, n, flags); +} + +int rng_fail_supported(void) { return 1; } +#else +int rng_fail_supported(void) { return 0; } +#endif diff --git a/tests/integration/crypto_random_failure/rngfail.ae b/tests/integration/crypto_random_failure/rngfail.ae new file mode 100644 index 000000000..a0ca1107d --- /dev/null +++ b/tests/integration/crypto_random_failure/rngfail.ae @@ -0,0 +1,56 @@ +// tls13_client.connect with the OS CSPRNG failing from its Nth draw on +// (#3293; rng_fail_shim.c, test_random_failure.sh). The first connect +// draws freely and counts its draws, D; then one connect for each N in +// 0..D-1 with only N draws let through. Each connects to listener.ae at +// 127.0.0.1 PEER_PORT, which reports what the client sent; this prints +// one line per connect: "N= ERR: " or "N= OK". +// +// Without a failing CSPRNG to stand in (rng_fail_supported() 0: macOS and +// the BSDs, whose arc4random_buf cannot fail) it prints "SKIP". + +import std.os +import std.bytes +import std.string +import std.cryptography.tls13_client + +extern rng_fail_after(n: int) +extern rng_fail_calls() -> int +extern rng_fail_supported() -> int + +fn attempt(port: int, priv: ptr, n: int) { + rng_fail_after(n) + conn, err = tls13_client.connect("127.0.0.1", port, priv) + rng_fail_after(-1) + if conn != null { + tls13_client.close_conn(conn) + println("N=${n} OK") + } else { + println("N=${n} ERR: ${err}") + } +} + +main() { + if rng_fail_supported() == 0 { + println("SKIP: no CSPRNG to fail on this platform") + return + } + port, _perr = string.to_int(os.getenv("PEER_PORT")) + // The caller's own X25519 scalar, fixed: only the draws connect makes + // itself are under test. + priv = bytes.new(32) + i = 0 + while i < 32 { + bytes.set(priv, i, 0x40 + i) + i = i + 1 + } + before = rng_fail_calls() + attempt(port, priv, -1) + draws = rng_fail_calls() - before + println("D=${draws}") + n = 0 + while n < draws { + attempt(port, priv, n) + n = n + 1 + } + bytes.free(priv) +} diff --git a/tests/integration/crypto_random_failure/test_random_failure.sh b/tests/integration/crypto_random_failure/test_random_failure.sh new file mode 100644 index 000000000..91f4157ac --- /dev/null +++ b/tests/integration/crypto_random_failure/test_random_failure.sh @@ -0,0 +1,67 @@ +#!/bin/sh +# The pure TLS client with a CSPRNG that fails (#3293): a failed draw must +# fail the handshake, never leave a key share, the client random or the +# session id as the zeros of a buffer the generator did not fill. +# +# rngfail.ae links rng_fail_shim.c (aether.toml), which stands in front of +# the OS CSPRNG and fails every draw after the first N. It connects once +# with every draw let through, counting them (D), then once for each N in +# 0..D-1; listener.ae reports what each connect sent. The verdict: no +# ClientHello with an all-zero random or session id, at least one connect +# refused for want of randomness, and the free one a real ClientHello. +# +# Where the CSPRNG cannot fail (macOS and the BSDs: arc4random_buf) the +# test reports a skip. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +. "$ROOT/tests/lib/server_jobs.sh" +AE="$ROOT/build/ae" +[ -n "${EXE_EXT:-}" ] && AE="$AE$EXE_EXT" +NAME=crypto_random_failure +[ -x "$AE" ] || { echo " [SKIP] $NAME: ae not built"; exit 0; } + +TMP="$(mktemp -d)" +LPID="" +cleanup() { + [ -n "$LPID" ] && stop_server "$LPID" + rm -rf "$TMP" || : + return 0 +} +trap cleanup EXIT +fail() { echo " [FAIL] $NAME: $1"; exit 1; } + +cd "$SCRIPT_DIR" || exit 1 +"$AE" build listener.ae -o "$TMP/listener" >"$TMP/b1.log" 2>&1 || { sed -n '1,12p' "$TMP/b1.log"; fail "listener.ae did not build"; } +"$AE" build rngfail.ae -o "$TMP/rngfail" >"$TMP/b2.log" 2>&1 || { sed -n '1,12p' "$TMP/b2.log"; fail "rngfail.ae did not build"; } + +PORT_OUT="$TMP/port" "$TMP/listener" >"$TMP/listener.out" 2>&1 & +LPID=$! +i=0 +while [ "$i" -lt 100 ] && [ ! -s "$TMP/port" ]; do sleep 0.1; i=$((i + 1)); done +[ -s "$TMP/port" ] || { cat "$TMP/listener.out"; fail "the listener never listened"; } + +PEER_PORT="$(cat "$TMP/port")" "$TMP/rngfail" >"$TMP/client.out" 2>&1 +case "$(cat "$TMP/client.out")" in + SKIP*) echo " [SKIP] $NAME: $(cat "$TMP/client.out")"; exit 0 ;; +esac +n=0 +while server_alive "$LPID" && [ "$n" -lt 100 ]; do sleep 0.1; n=$((n + 1)); done +stop_server "$LPID" +LPID="" + +seen="$(cat "$TMP/listener.out")" +said="$(cat "$TMP/client.out")" +ok=1 +case "$seen" in *"random_zero=1"*|*"sid_zero=1"*) ok=0 ;; esac +case "$said" in *"randomness"*) ;; *) ok=0 ;; esac +case "$(echo "$seen" | head -1)" in "CH random_zero=0 sid_zero=0") ;; *) ok=0 ;; esac +[ -n "${RNG_VERBOSE:-}" ] && { echo "$said"; echo "$seen"; } +if [ "$ok" = 1 ]; then + echo " ok: no ClientHello made from undrawn bytes ($(echo "$said" | grep -c '^N=') connects)" + echo " [PASS] $NAME" + exit 0 +fi +echo " client: $(echo "$said" | tr '\n' '|')" +echo " listener: $(echo "$seen" | tr '\n' '|')" +fail "a ClientHello was made from bytes the CSPRNG did not fill, or no connect was refused" diff --git a/tests/integration/crypto_rsa_oaep_pss/test_crypto_rsa_oaep_pss.ae b/tests/integration/crypto_rsa_oaep_pss/test_crypto_rsa_oaep_pss.ae index 980929480..54c98810b 100644 --- a/tests/integration/crypto_rsa_oaep_pss/test_crypto_rsa_oaep_pss.ae +++ b/tests/integration/crypto_rsa_oaep_pss/test_crypto_rsa_oaep_pss.ae @@ -58,6 +58,28 @@ main() { rsig = rsa.sign_pss(k, mh, bytes.view(salt)[0..32]) if rsa.verify_pss(k, mh, bytes.view(rsig), 32) != 1 { println("FAIL pss round-trip"); fails = fails + 1 } else { println("ok pss round-trip") } + // key_from_bytes: the same key from big-endian bytes signs the same, and + // a public-only key (empty d) signs and decrypts nothing (#2600). + nb = hb("cfc733d3d62af11a935cbba777e3bf08262629284d84095aecf7dfc63efe38dd0680a6972d677ff04cc3d2e7c84cb4463c528ebc87680623db37792f0315447b2a5da8d229afa229af95b5249eba3c4b3ea726f54989b76cca9002ee10a383de28efe84ebc6f5b74e9f201fd1b9543679e4ef022bf728270b9687beb10599d55") + eb = hb("010001") + db = hb("007f6fea75cf31df573172fb15a80ee3a817dde7f87432bb9a2d258af688c45c14145cebd06564c9af9525b641bb34708d738a1f49bab32eb0e74f8ae02ebfe893976a220e1b8df88445fb1e9c2fe4f11fd8c6cd42fef697c22bf3c2dd9dab6333aef9b1cb4dc7dd4c371015f103d36561a5ce65f4ac5055403301a94c920f1401") + kb = rsa.key_from_bytes(bytes.view(nb), bytes.view(eb), bytes.view(db)) + sigb = rsa.sign_pss(kb, mh, bytes.view(salt)) + fails = fails + ck("key_from_bytes pss sign", bh(sigb), "cad326f8644bb81fdf7696a1100e0ea9b7f630d24a724874eb98048294d6e182c6bffd71600de9a58679ad027bf76429818d3376323743aedb9c46d0bc914866c36ae5f57d4aa9ad52f1adb2e076179ec16d66a9c5de12ab195c27c1afcee8ca1deb8785dfc5d722a7abe5c014be04c247b0d1f53ce026cf3dffdbc0e9051ca1") + empty = bytes.new(0) + kp = rsa.key_from_bytes(bytes.view(nb), bytes.view(eb), bytes.view(empty)[0..0]) + if rsa.sign_pss(kp, mh, bytes.view(salt)) == null && rsa.raw_decrypt(kp, rtct) == null { + println("ok a public key signs and decrypts nothing") + } else { println("FAIL a public key signed or decrypted"); fails = fails + 1 } + // A d longer than n with a nonzero leading byte is refused. + bytes.set(db, 0, 1) + if rsa.key_from_bytes(bytes.view(nb), bytes.view(eb), bytes.view(db)) == null { + println("ok key_from_bytes refuses a d longer than n") + } else { println("FAIL key_from_bytes took a d longer than n"); fails = fails + 1 } + rsa.key_free(kb) + rsa.key_free(kp) + bytes.free(nb); bytes.free(eb); bytes.free(db); bytes.free(empty); bytes.free(sigb) + if fails == 0 { println("ALL PASS"); return } println("FAILURES"); exit(1) } diff --git a/tests/integration/crypto_tls13_cert/test_tls13_cert.ae b/tests/integration/crypto_tls13_cert/test_tls13_cert.ae index 51390f8ef..b58571931 100644 --- a/tests/integration/crypto_tls13_cert/test_tls13_cert.ae +++ b/tests/integration/crypto_tls13_cert/test_tls13_cert.ae @@ -59,14 +59,17 @@ fn set_path_fixture_validity(c: *tls13_cert.LeafCert) { c.not_after_len = 13 } -// Parse a DER certificate and return its pathLenConstraint, or -999 when the +// Parse a DER certificate and return its pathLenConstraint; -1 when its +// basicConstraints is malformed (a negative pathLenConstraint is: the +// certificate is then refused, not read as a looser limit); -999 when the // parse fails or the certificate has none. fn parsed_path_len(hex: string) -> int { der = from_hex(hex) c = tls13_cert.LeafCert { tbs: null, tbs_len: 0, sig_alg_oid: null, signature: null, signature_len: 0, spki_algo_oid: null, spki_key: null, spki_key_len: 0, issuer: null, issuer_len: 0, subject: null, subject_len: 0, not_before: null, not_before_len: 0, not_after: null, not_after_len: 0, dns_names: null } err = tls13_cert.parse_certificate(bytes.view(der), &c) v = -999 - if string.equals(err, "") == 1 && c.has_path_len == 1 { v = c.path_len } + if string.equals(err, "") == 1 && (c.ext_bits & tls13_cert.EXT_PATHLEN) != 0 { v = c.path_len } + if string.equals(err, "") == 1 && (c.ext_bits & tls13_cert.EXT_MALFORMED) != 0 { v = -1 } tls13_cert.free_leaf(&c); bytes.free(der) return v } @@ -218,8 +221,7 @@ main() { c3, c3len = tls13_cert.cert_verify_content(bytes.view(th)[0..32]) digest = sha256b(c3, c3len) bytes.free(c3) - knonce = from_hex("7a1a7e52797fc8caaa435d2a4dace39158504bf204fbe19f14dbb427faee50ae") - r32, s32 = p256.ecdsa_sign(priv, digest, knonce) + r32, s32 = p256.ecdsa_sign(priv, digest) dsig, dlen = der_ecdsa_sig(r32, s32) if tls13_cert.verify_cert_verify(tls13_cert.SCHEME_ECDSA_P256_SHA256, px, py, bytes.view(th)[0..32], bytes.view(dsig)[0..dlen]) == 1 { println("ok ecdsa-p256 CertificateVerify accepts valid sig") @@ -237,7 +239,7 @@ main() { // --- mTLS: CLIENT CertificateVerify sign round-trip (RFC 8446 §4.4.3) --- // Sign the client content with the client key, then verify the DER sig is a // valid ECDSA-P256 signature over SHA-256(client content). - ccv_sig = tls13_cert.sign_client_cert_verify_ecdsa_p256(priv, bytes.view(th)[0..32], knonce) + ccv_sig = tls13_cert.sign_client_cert_verify_ecdsa_p256(priv, bytes.view(th)[0..32]) ccv_content, ccv_clen = tls13_cert.client_cert_verify_content(bytes.view(th)[0..32]) ccv_digest = sha256b(ccv_content, ccv_clen) // The client content differs from the server content (context string), so a @@ -252,11 +254,24 @@ main() { } else { println("FAIL client sig verified under server context"); total_ok = 0 } bytes.free(ccv_sig); bytes.free(ccv_content); bytes.free(ccv_digest) bytes.free(crr); bytes.free(css) + + // --- SERVER CertificateVerify sign (the pure server's path): the DER + // signature verifies as a server CertificateVerify, and a key outside + // [1, n-1] signs nothing. + scv_sig = tls13_cert.sign_server_cert_verify_ecdsa_p256(priv, bytes.view(th)[0..32]) + if tls13_cert.verify_cert_verify(tls13_cert.SCHEME_ECDSA_P256_SHA256, px, py, bytes.view(th)[0..32], bytes.view(scv_sig)) == 1 { + println("ok server CertificateVerify signs + verifies") + } else { println("FAIL server CertificateVerify sig"); total_ok = 0 } + bytes.free(scv_sig) + zkey = from_hex("0000000000000000000000000000000000000000000000000000000000000000") + if tls13_cert.sign_server_cert_verify_ecdsa_p256(zkey, bytes.view(th)[0..32]) == null { + println("ok server CertificateVerify refuses a zero key") + } else { println("FAIL server CertificateVerify signed with a zero key"); total_ok = 0 } + bytes.free(zkey) bytes.free(priv) bytes.free(px) bytes.free(py) bytes.free(digest) - bytes.free(knonce) bytes.free(r32) bytes.free(s32) bytes.free(dsig) @@ -295,6 +310,13 @@ main() { println("ok *.example.com rejects a.b.example.com") } else { println("FAIL wildcard spanned two labels"); total_ok = 0 } tls13_cert.free_leaf(&hw) + // A wildcard over a top-level domain matches nothing (x509-limbo's + // public-suffix-wildcard-san, #1298). + htld = mk_leaf_san("*.com") + if string.equals(tls13_cert.check_hostname(&htld, "example.com"), "") != 1 { + println("ok *.com matches nothing") + } else { println("FAIL *.com matched example.com"); total_ok = 0 } + tls13_cert.free_leaf(&htld) he = mk_leaf_san("api.example.com") if string.equals(tls13_cert.check_hostname(&he, "API.example.com"), "") == 1 { println("ok exact match is case-insensitive") @@ -352,9 +374,13 @@ main() { } else { println("FAIL RSA-PSS accepted tampered sig"); total_ok = 0 } bytes.free(rsa_spki); bytes.free(rsa_sig); bytes.free(rsa_th) - // --- intermediate-CA chain building (chain_verify_path) --- + // --- intermediate-CA chain building (validate_chain) --- // 3-level ECDSA PKI: leaf <- intermediate <- root. Only root is trusted; - // the intermediate is a server-sent CA the path must traverse. + // the intermediate is a server-sent CA the path must traverse. These + // fixtures were valid from 2026-07-28 to 2026-07-30, so the path is + // validated at a time inside that window (validate_chain checks every + // certificate's validity, as chain_verify_path does now, at the present). + at3 = "20260729120000" cp_root = from_hex("3082017e30820123a00302010202145c702d3cc21d8183652f2cfa02e9b7f6844b8da5300a06082a8648ce3d04030230143112301006035504030c095465737420526f6f74301e170d3236303732383232353133325a170d3236303733313232353133325a30143112301006035504030c095465737420526f6f743059301306072a8648ce3d020106082a8648ce3d030107034200041740254dfbb1c90c7ef083091d0f4c1622a202de87b55cedd2bb0f786b4f244c6bbde1bf3abb3ddb0e1644e8234a1aa3f867de35303edb156664c450800f2801a3533051301d0603551d0e04160414b27999d5d4667a7a87cc5357cb0a866c2236e699301f0603551d23041830168014b27999d5d4667a7a87cc5357cb0a866c2236e699300f0603551d130101ff040530030101ff300a06082a8648ce3d0403020349003046022100ac69c0f3a06e882a53f23af02c53f176ec0f3bafe20e89ec59343a733ccddcc4022100be41ede785a8c707b48917082ffaa0cf3ccc4f99744eb3f83d0bbcd1c4de84a2") cp_int = from_hex("3082018e30820135a00302010202144dabe3cd6d7512d3897ae34ff552226c0d16125a300a06082a8648ce3d04030230143112301006035504030c095465737420526f6f74301e170d3236303732383232353133335a170d3236303733313232353133335a301c311a301806035504030c115465737420496e7465726d6564696174653059301306072a8648ce3d020106082a8648ce3d030107034200041c75b3de45ad714bcbd377f4fc50a2f94b24e53632fa9478fe0ed1bd1fa4dbb403458519822dca095933ced4a4ec876661a6efa5682b55b8ecba92e15d045755a35d305b300c0603551d13040530030101ff300b0603551d0f040403020204301d0603551d0e041604140a74b333ea0cd034f90333f79cd1127feca903ab301f0603551d23041830168014b27999d5d4667a7a87cc5357cb0a866c2236e699300a06082a8648ce3d040302034700304402206fe0684b0bb4e4d3e85dae7d88267a3768a218c115e262f60ddd477e5535d08902204f1dd369a0e932cef441f6fdd923c4a430315aca27937f2cde1f8031e58be210") cp_leaf = from_hex("3082018b30820130a003020102021402b286e4bdc746e47304f6935a264a8f9606f2d9300a06082a8648ce3d040302301c311a301806035504030c115465737420496e7465726d656469617465301e170d3236303732383232353133335a170d3236303733303232353133335a30143112301006035504030c096c6f63616c686f73743059301306072a8648ce3d020106082a8648ce3d0301070342000410f1edfe0c66ec3ebe94f1689629f901d215e9592104da1e3f1dcfa8af65a8b882c14adb2d0379069c87545c81544b86a37ec3c8bbdd28d5bb799fdcb185ad66a358305630140603551d11040d300b82096c6f63616c686f7374301d0603551d0e04160414ef5c392fa331d2e00730805fc54a1083a45b1531301f0603551d230418301680140a74b333ea0cd034f90333f79cd1127feca903ab300a06082a8648ce3d0403020349003046022100f523de6be6de2fe0849888b1f5275e6ac437f64cbada051ba2c9529c0d66a301022100c86009498ab965939e733219e10175889c9ba1d501ba0febcaef880e96f430d9") @@ -368,15 +394,19 @@ main() { canchors = list_new(); _ = list.add(canchors, &croot) cinters = list_new(); _ = list.add(cinters, &cint) cempty = list_new() - if string.equals(tls13_cert.chain_verify_path(&cleaf, cinters, canchors), "") == 1 { + if string.equals(tls13_cert.validate_chain(&cleaf, cinters, canchors, at3, tls13_cert.PURPOSE_SERVER, -1), "") == 1 { println("ok leaf->intermediate->root path verified") } else { println("FAIL intermediate chain rejected"); total_ok = 0 } - if string.equals(tls13_cert.chain_verify_path(&cleaf, cempty, canchors), "") != 1 { + if string.equals(tls13_cert.validate_chain(&cleaf, cempty, canchors, at3, tls13_cert.PURPOSE_SERVER, -1), "") != 1 { println("ok leaf without intermediate rejected") } else { println("FAIL leaf-alone accepted"); total_ok = 0 } - if string.equals(tls13_cert.chain_verify_path(&cint, cempty, canchors), "") == 1 { - println("ok intermediate chains directly to root") - } else { println("FAIL intermediate->root rejected"); total_ok = 0 } + // The intermediate chains straight to the root, but its keyUsage is + // keyCertSign only, so it is no TLS leaf (#3309): refused for that, and + // not for want of a path. + ir = tls13_cert.validate_chain(&cint, cempty, canchors, at3, tls13_cert.PURPOSE_SERVER, -1) + if string.contains(ir, "keyUsage") == 1 { + println("ok intermediate chains to root, refused as a TLS leaf (${ir})") + } else { println("FAIL intermediate as leaf: ${ir}"); total_ok = 0 } if tls13_cert.leaf_is_ca(&cint) == 1 { println("ok intermediate has basicConstraints cA=TRUE") } else { println("FAIL intermediate missing cA=TRUE"); total_ok = 0 } @@ -438,7 +468,7 @@ main() { pathlen_der = from_hex("308201943082013aa003020102021459562d737e1e060c34d015905f42155a6833c8ab300a06082a8648ce3d04030230163114301206035504030c0b506174684c656e5a65726f301e170d3236313032393037333731315a170d3336313032363037333731315a30163114301206035504030c0b506174684c656e5a65726f3059301306072a8648ce3d020106082a8648ce3d03010703420004390fc63d2b28603c5d079f1b36a221ed9416e328763445b2035b09263984e7e2e9005dafb8c1d0fbc41c9aa724108675ca327a375416cac1749fa49100db9a57a3663064301d0603551d0e041604140dc2001c67f67b04dab972449df9bdfb3fffd231301f0603551d230418301680140dc2001c67f67b04dab972449df9bdfb3fffd23130120603551d130101ff040830060101ff020100300e0603551d0f0101ff040403020204300a06082a8648ce3d0403020348003045022100ccb2eae5c09bca66b223921c843972eefb745b46d606bb1ca3ce6dddf86c969902205494af127cbfb2ee2ce27f31f94ce6466f25fb16ee2346fbd877f8a0b1e6fc05") pathlen_cert = tls13_cert.LeafCert { tbs: null, tbs_len: 0, sig_alg_oid: null, signature: null, signature_len: 0, spki_algo_oid: null, spki_key: null, spki_key_len: 0, issuer: null, issuer_len: 0, subject: null, subject_len: 0, not_before: null, not_before_len: 0, not_after: null, not_after_len: 0, dns_names: null } pathlen_err = tls13_cert.parse_certificate(bytes.view(pathlen_der), &pathlen_cert) - if string.equals(pathlen_err, "") == 1 && pathlen_cert.has_path_len == 1 && pathlen_cert.path_len == 0 { + if string.equals(pathlen_err, "") == 1 && (pathlen_cert.ext_bits & tls13_cert.EXT_PATHLEN) != 0 && pathlen_cert.path_len == 0 { println("ok pathLenConstraint=0 parsed") } else { println("FAIL pathLenConstraint=0 not parsed"); total_ok = 0 } tls13_cert.free_leaf(&pathlen_cert); bytes.free(pathlen_der) @@ -446,7 +476,8 @@ main() { // pathLenConstraint=128 is DER INTEGER 00 80: the leading zero must not // make it read as negative. 300 (01 2c) has no leading zero. The same // certificate with the bytes changed to ff 80 (-128) must give the - // rejecting -1. (Fixtures: openssl req -x509, prime256v1, pathlen:128/300.) + // malformed -1: the certificate is refused. (Fixtures: openssl req -x509, + // prime256v1, pathlen:128/300.) if parsed_path_len("3082017230820118a003020102021402e3400f60aba8e263948b45654d6cbe1889deca300a06082a8648ce3d04030230153113301106035504030c0a506174684c656e313238301e170d3236313032393037353832365a170d3336313032363037353832365a30153113301106035504030c0a506174684c656e3132383059301306072a8648ce3d020106082a8648ce3d030107034200042cce06783196bb7146e0a39b7dd2faa049a4cbcaf5cef3590cfde32a30fe66967479ba13f89f69b2806d74f2260df041e0fcf7b60adc0dd7dee9beb3a9d306fea3463044301d0603551d0e04160414b99719befb036c4a90681d1cb2d08fa30b35501b30130603551d130101ff040930070101ff02020080300e0603551d0f0101ff040403020204300a06082a8648ce3d0403020348003045022100ed34eefa744e8120877af33f75c9d17c63e26c8fa883f1f200c05d36d97b797202202ad4eca699a0b641a6687653a787cbb9c04f5b2f22fe617146efbb7baea10712") == 128 { println("ok pathLenConstraint=128 (00 80) parsed") } else { println("FAIL pathLenConstraint=128 (00 80) misread"); total_ok = 0 } @@ -454,18 +485,41 @@ main() { println("ok pathLenConstraint=300 parsed") } else { println("FAIL pathLenConstraint=300 misread"); total_ok = 0 } if parsed_path_len("3082017230820118a003020102021402e3400f60aba8e263948b45654d6cbe1889deca300a06082a8648ce3d04030230153113301106035504030c0a506174684c656e313238301e170d3236313032393037353832365a170d3336313032363037353832365a30153113301106035504030c0a506174684c656e3132383059301306072a8648ce3d020106082a8648ce3d030107034200042cce06783196bb7146e0a39b7dd2faa049a4cbcaf5cef3590cfde32a30fe66967479ba13f89f69b2806d74f2260df041e0fcf7b60adc0dd7dee9beb3a9d306fea3463044301d0603551d0e04160414b99719befb036c4a90681d1cb2d08fa30b35501b30130603551d130101ff040930070101ff0202ff80300e0603551d0f0101ff040403020204300a06082a8648ce3d0403020348003045022100ed34eefa744e8120877af33f75c9d17c63e26c8fa883f1f200c05d36d97b797202202ad4eca699a0b641a6687653a787cbb9c04f5b2f22fe617146efbb7baea10712") == -1 { - println("ok negative pathLenConstraint becomes a rejecting limit") + println("ok a negative pathLenConstraint makes the certificate malformed (refused)") } else { println("FAIL negative pathLenConstraint not rejected"); total_ok = 0 } + // A constraint of four bytes or more limits no path this code builds and + // reads as unbounded, as OpenSSL reads it (2^24 is INTEGER 01 00 00 00). + if parsed_path_len("308201953082013ba0030201020214277974184e4d89d1a391d2f7e3f20d019be5ac54300a06082a8648ce3d04030230153113301106035504030c0a506174684c656e426967301e170d3236313032393131313231315a170d3336313032363131313231315a30153113301106035504030c0a506174684c656e4269673059301306072a8648ce3d020106082a8648ce3d0301070342000479000f70cc7c75ae84f29b9cb098ee946f189c757fa0561d2f967e1633efda29a555b5d5b8b462a19960d1140c251987a32f1cc512196333d609f3fc1abdd6cda3693067301d0603551d0e041604146a1fd3240e3cf2a52de002a2d7ef6e123950e96d301f0603551d230418301680146a1fd3240e3cf2a52de002a2d7ef6e123950e96d30150603551d130101ff040b30090101ff020401000000300e0603551d0f0101ff040403020204300a06082a8648ce3d040302034800304502201b5d9447f6220daef9c023fa610073d3f6a96181b1c21c3eb1342a5409719ede02210089d4e84e8a9a69d7ed8beac8c8d5b17894417975d2cf50f79bbad1c222cf9ccc") == 2147483647 { + println("ok a pathLenConstraint over three bytes (2^24) reads as unbounded") + } else { println("FAIL a pathLenConstraint over three bytes (2^24) not read as unbounded"); total_ok = 0 } + // 00 2c pads 44 with a zero it does not need: not DER, so refused. + if parsed_path_len("3082017330820118a00302010202143ac45b366b953d3b367dc2566c364f10b43abe76300a06082a8648ce3d04030230153113301106035504030c0a506174684c656e333030301e170d3236313032393037353832365a170d3336313032363037353832365a30153113301106035504030c0a506174684c656e3330303059301306072a8648ce3d020106082a8648ce3d030107034200042cce06783196bb7146e0a39b7dd2faa049a4cbcaf5cef3590cfde32a30fe66967479ba13f89f69b2806d74f2260df041e0fcf7b60adc0dd7dee9beb3a9d306fea3463044301d0603551d0e04160414b99719befb036c4a90681d1cb2d08fa30b35501b30130603551d130101ff040930070101ff0202002c300e0603551d0f0101ff040403020204300a06082a8648ce3d040302034900304602210085db7da35a750a83e979741ebf6e3202f203603d4c8349e22b2c1fb634f7b59c022100d7f734b746c5f0ed02ac92860a4fd454f7b58450f87b5e4f376a8d92008f77cb") == -1 { + println("ok a zero-padded pathLenConstraint makes the certificate malformed (refused)") + } else { println("FAIL a zero-padded pathLenConstraint not rejected"); total_ok = 0 } + // basicConstraints { TRUE, INTEGER 0, INTEGER 300 }: a field repeated, + // where the last read as the limit (300); OpenSSL refuses it. + if parsed_path_len("3082016b30820111a00302010202146999b9510e204d6ffb4726086079ba68b0601751300a06082a8648ce3d0403023010310e300c06035504030c054243647570301e170d3236313032393132323734325a170d3336313032363132323734325a3010310e300c06035504030c0542436475703059301306072a8648ce3d020106082a8648ce3d0301070342000479000f70cc7c75ae84f29b9cb098ee946f189c757fa0561d2f967e1633efda29a555b5d5b8b462a19960d1140c251987a32f1cc512196333d609f3fc1abdd6cda349304730160603551d130101ff040c300a0101ff0201000202012c300e0603551d0f0101ff040403020106301d0603551d0e041604146a1fd3240e3cf2a52de002a2d7ef6e123950e96d300a06082a8648ce3d0403020348003045022100dbb1a3363187b7665c45adb235b56a49836462e53c4f515d9b60b9e74f29dc69022000e59e8cf2b2d37d58a10564fdc982d4ef902819442e8b6cc564b0696c2bf094") == -1 { + println("ok a repeated pathLenConstraint makes the certificate malformed (refused)") + } else { println("FAIL a repeated pathLenConstraint not rejected"); total_ok = 0 } + // basicConstraints { INTEGER 300, TRUE }: the fields out of order. + if parsed_path_len("3082016c30820112a003020102021446a9b75cc80ce2b955ef026f22f18ac1669de5c0300a06082a8648ce3d04030230123110300e06035504030c0742436f72646572301e170d3236313032393132323734325a170d3336313032363132323734325a30123110300e06035504030c0742436f726465723059301306072a8648ce3d020106082a8648ce3d0301070342000479000f70cc7c75ae84f29b9cb098ee946f189c757fa0561d2f967e1633efda29a555b5d5b8b462a19960d1140c251987a32f1cc512196333d609f3fc1abdd6cda346304430130603551d130101ff040930070202012c0101ff300e0603551d0f0101ff040403020106301d0603551d0e041604146a1fd3240e3cf2a52de002a2d7ef6e123950e96d300a06082a8648ce3d0403020348003045022100e4477ed80ddcb98ef0debd3898009527947f286cc528df600c34ba45e74824d802207d7934b465b91e0d054b03172b16c3fec11b78e7706402ecbab52a88b71bdba3") == -1 { + println("ok a pathLenConstraint before the cA makes the certificate malformed (refused)") + } else { println("FAIL a pathLenConstraint before the cA not rejected"); total_ok = 0 } + // basicConstraints SEQUENCE { TRUE } 00: a byte after the SEQUENCE in the + // extension's value, where nothing may follow it. + if parsed_path_len("308201693082010fa00302010202145059a6c88a79125ca556555bc49781efdbf81898300a06082a8648ce3d04030230123110300e06035504030c074243747261696c301e170d3236313032393133313835305a170d3336313032363133313835305a30123110300e06035504030c074243747261696c3059301306072a8648ce3d020106082a8648ce3d0301070342000479000f70cc7c75ae84f29b9cb098ee946f189c757fa0561d2f967e1633efda29a555b5d5b8b462a19960d1140c251987a32f1cc512196333d609f3fc1abdd6cda343304130100603551d130101ff040630030101ff00300e0603551d0f0101ff040403020106301d0603551d0e041604146a1fd3240e3cf2a52de002a2d7ef6e123950e96d300a06082a8648ce3d040302034800304502204bfa672d0c2023e11ae84ace2c5ca6f5290bb2d0953947051de3d59deed04ea50221008920e0e075e3709d7f741027381803d469e23f66d90f8ec424f8103f9216aae4") == -1 { + println("ok a byte after basicConstraints makes the certificate malformed (refused)") + } else { println("FAIL a byte after basicConstraints not rejected"); total_ok = 0 } // pathLenConstraint counts intermediate CAs, not the target certificate, // even when that target happens to assert cA. - x_noku.has_path_len = 1 + x_noku.ext_bits = x_noku.ext_bits | tls13_cert.EXT_PATHLEN x_noku.path_len = 0 x_v_noku.is_ca = 1 if string.equals(tls13_cert.chain_verify_path(&x_v_noku, xi_noku, xanchors), "") == 1 { println("ok pathLenConstraint excludes the target certificate") } else { println("FAIL pathLenConstraint counted the target certificate"); total_ok = 0 } - x_noku.has_path_len = 0 + x_noku.ext_bits = x_noku.ext_bits & ~tls13_cert.EXT_PATHLEN x_v_noku.is_ca = 0 // A dead-end certificate can share the subject and public key of the @@ -507,13 +561,14 @@ main() { bytes.free(pl_anchor.subject) pl_anchor.subject = from_hex("3003310130") pl_anchor.subject_len = 5 - pl_signer.has_path_len = 1 + pl_signer.ext_bits = pl_signer.ext_bits | tls13_cert.EXT_PATHLEN pl_signer.path_len = 0 pl_inters = list_new(); _ = list.add(pl_inters, &x_noku); _ = list.add(pl_inters, &pl_signer) pl_anchors = list_new(); _ = list.add(pl_anchors, &pl_anchor) - if string.equals(tls13_cert.chain_verify_path(&x_v_noku, pl_inters, pl_anchors), "") != 1 { + pl_err = tls13_cert.chain_verify_path(&x_v_noku, pl_inters, pl_anchors) + if string.equals(pl_err, "a CA's path length constraint is exceeded") == 1 { println("ok pathLenConstraint rejects excess subordinate CAs") - } else { println("FAIL pathLenConstraint accepted an excess subordinate CA"); total_ok = 0 } + } else { println("FAIL pathLenConstraint and an excess subordinate CA: '${pl_err}'"); total_ok = 0 } list_free(pl_inters); list_free(pl_anchors) tls13_cert.free_leaf(&pl_signer); tls13_cert.free_leaf(&pl_anchor) @@ -544,7 +599,7 @@ main() { set_path_fixture_validity(&p3i) p3anchors = list_new(); _ = list.add(p3anchors, &p3r) p3inters = list_new(); _ = list.add(p3inters, &p3i) - if string.equals(tls13_cert.chain_verify_path(&p3l, p3inters, p3anchors), "") == 1 { + if string.equals(tls13_cert.validate_chain(&p3l, p3inters, p3anchors, at3, tls13_cert.PURPOSE_SERVER, -1), "") == 1 { println("ok P-256 leaf <- P-384/SHA-384 intermediate <- P-384 root verified") } else { println("FAIL P-384 chain hop rejected"); total_ok = 0 } list_free(p3anchors); list_free(p3inters) diff --git a/tests/integration/crypto_tls13_cert_p384/test_tls13_cert_p384.ae b/tests/integration/crypto_tls13_cert_p384/test_tls13_cert_p384.ae index b0f3a421b..755546696 100644 --- a/tests/integration/crypto_tls13_cert_p384/test_tls13_cert_p384.ae +++ b/tests/integration/crypto_tls13_cert_p384/test_tls13_cert_p384.ae @@ -1,17 +1,16 @@ // Validate the ECDSA-P384-SHA384 (SignatureScheme 0x0503) server // CertificateVerify path in std.cryptography.tls13_cert. // -// This lives in its OWN test file (separate from test_tls13_cert) on purpose: a -// pure-Aether P-384 ECDSA verify is ~12s, and folding it into the already-~90s -// main cert test pushed the combined run over the CI gate's per-test 120s -// budget on the slower Windows/MSYS2 runner. Split out, each file stays well -// under budget. Coverage is unchanged — verify_cert_verify's negative / -// wrong-transcript dispatch is exercised by the P-256 cases in test_tls13_cert. +// It lives in its own file (separate from test_tls13_cert) because a pure-Aether +// P-384 ECDSA verify used to take about 12 s on std.bignum, and folded into the +// main cert test it pushed the run over the per-test budget on the slower +// Windows/MSYS2 runner. Since #2600 a P-384 verify takes about a millisecond; +// the split stays, each file covering its own scheme. verify_cert_verify's +// negative / wrong-transcript dispatch is exercised by the P-256 cases in +// test_tls13_cert. // -// We do NOT keygen/sign at runtime (scalar_mult_base + ecdsa_sign are the two -// slowest primitives in the suite). Instead we use a PRECOMPUTED (pubkey, r, s) -// vector generated once by this module's own p384 over the `th` below, and do a -// single verify. +// The (pubkey, r, s) vector below was generated once by this module's own p384 +// over the `th` below; the test does a single verify. import std.cryptography.tls13_cert import std.bytes diff --git a/tests/integration/crypto_tls13_chain/test_tls13_chain.ae b/tests/integration/crypto_tls13_chain/test_tls13_chain.ae new file mode 100644 index 000000000..e7fcdadee --- /dev/null +++ b/tests/integration/crypto_tls13_chain/test_tls13_chain.ae @@ -0,0 +1,381 @@ +// Path validation of the pure TLS client (#3303, #1298). +// +// The pure client accepted any path whose names matched and whose signatures +// verified: it never asked whether an issuer was a CA at all. A certificate a +// CA issued to any web site (cA FALSE) could sign a certificate for any other +// host, and the client trusted it. The first cases below are that shape, +// through chain_verify_path, the call the client made; each failed before +// the fix (the forged certificate was accepted) and is refused now. The rest +// cover what validate_chain checks on the way: keyUsage keyCertSign, +// pathLenConstraint, extendedKeyUsage for the purpose, name constraints, an +// unknown critical extension, and validity at a chosen time; then what the +// x509-limbo corpus showed missing (#1298): weak and explicit-curve keys, a +// wildcard under an excluded subtree, the max_depth bound, a leaf whose +// keyUsage does not allow signing (#3309), and trust anchors that are not +// self-signed roots (#3310). The signature algorithms it showed missing are +// crypto_tls13_sig_algs (#3307). +// +// The fixtures are a PKI made once with openssl (`req -new`, `x509 -req +// -extfile`, valid 2026-01-01 to 2126-01-01), ECDSA P-256 unless named: +// root self-signed CA (basicConstraints cA, keyUsage keyCertSign) +// ica CA under root; leaf (good.example, serverAuth) under ica +// ee an end entity of root (cA FALSE, ee.example) +// forged victim.example, signed with ee's key +// eenobc an end entity of root with no basicConstraints at all +// forged2 victim.example, signed with eenobc's key +// clientonly extendedKeyUsage clientAuth only, under ica +// ica0 CA under root with pathLenConstraint 0; subca under ica0; +// subleaf under subca +// icanoks CA under root whose keyUsage lacks keyCertSign; nksleaf under it +// icanc CA under root, name constraints permit good.example only; +// ncin (in.good.example) and ncout (other.example) under it +// crit under ica, with an unknown critical extension +// icabadku CA under root whose keyUsage extnValue is an OCTET STRING; +// badkuleaf under it +// icadup CA under root with keyUsage twice (cRLSign, then keyCertSign: +// the second was made as 2.5.29.16 and its OID patched, the +// certificate signed again); dupleaf under it +// icabool CA under root whose basicConstraints cA is the BOOLEAN 0x01; +// boolleaf under it +// weak a 1024-bit RSA leaf under ica +// explicit a P-256 leaf under ica whose key gives the curve explicitly +// icaex CA under root excluding bar.example.com; wild (*.example.com) +// and wildok (*.other.com) under it +// kunoca under root, keyUsage keyCertSign and no basicConstraints; +// kunocaleaf under it +// akikeyid, akiissuer, akiserial, akiall +// self-issued roots whose authorityKeyIdentifier names another +// key, issuer (CN=Someone Else), serial, or all three, each +// with a leaf (akikeyidleaf and so on); akiself, whose AKI +// names its own serial, and akiselfleaf +// pinned a self-signed leaf (peer.example, cA FALSE), its own anchor + +import std.list +import std.string +import std.cryptography.tls13_cert + +extern exit(code: int) + +fn pem_root() -> string { + return "-----BEGIN CERTIFICATE-----\nMIIBbTCCARSgAwIBAgIIAbG3Zcl3Rz4wCgYIKoZIzj0EAwIwGjEYMBYGA1UEAwwP\nQ2hhaW4gVGVzdCBSb290MCAXDTI2MDEwMTAwMDAwMFoYDzIxMjYwMTAxMDAwMDAw\nWjAaMRgwFgYDVQQDDA9DaGFpbiBUZXN0IFJvb3QwWTATBgcqhkjOPQIBBggqhkjO\nPQMBBwNCAAR9WijVAhsIJAknhWwdyIvLEtorzMadRjzVfyAQwKwOLawIHYNNOvPQ\nYmTm71x3tM534A0n3XtBlrp/j6L/QciLo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4G\nA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUkxMUuxXX9urrj1w+az33GyEVI20wCgYI\nKoZIzj0EAwIDRwAwRAIgKZ7Y0hdZRPElIh1HvcDAMb9XHTcp/okFAMtywwN7R1sC\nIBAyiGHEuLnkCWB476svZbEXO00Ct4B80nalck9pukIL\n-----END CERTIFICATE-----\n" +} + +fn pem_ica() -> string { + return "-----BEGIN CERTIFICATE-----\nMIIBjzCCATWgAwIBAgIJAJBavY/jwwlXMAoGCCqGSM49BAMCMBoxGDAWBgNVBAMM\nD0NoYWluIFRlc3QgUm9vdDAgFw0yNjAxMDEwMDAwMDBaGA8yMTI2MDEwMTAwMDAw\nMFowGTEXMBUGA1UEAwwOQ2hhaW4gVGVzdCBJQ0EwWTATBgcqhkjOPQIBBggqhkjO\nPQMBBwNCAASpETrrs+VDr7q/ra0Q5KynmBG7qAk1dNXgILwLD8rgxOK9hkAmss8F\nfrIPLNFlsQnIxKHmK6g1IHhUZv7Fl5X4o2MwYTAPBgNVHRMBAf8EBTADAQH/MA4G\nA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUE+9oi4kAZ9qqWUFROtwYghoTg/YwHwYD\nVR0jBBgwFoAUkxMUuxXX9urrj1w+az33GyEVI20wCgYIKoZIzj0EAwIDSAAwRQIg\nKt7dHC4pBL21r1YGk4VyNPl7uEy5R90HOWfh5pi9S9ICIQCqQOA3J8MHe763jdxw\n+hjE5mXH1ml4f05IJznJnVd5RA==\n-----END CERTIFICATE-----\n" +} + +fn pem_leaf() -> string { + return "-----BEGIN CERTIFICATE-----\nMIIBtzCCAV6gAwIBAgIIMRlYFof+w6QwCgYIKoZIzj0EAwIwGTEXMBUGA1UEAwwO\nQ2hhaW4gVGVzdCBJQ0EwIBcNMjYwMTAxMDAwMDAwWhgPMjEyNjAxMDEwMDAwMDBa\nMBcxFTATBgNVBAMMDGdvb2QuZXhhbXBsZTBZMBMGByqGSM49AgEGCCqGSM49AwEH\nA0IABDDD2g4+irUUUV440ebyU6Qi3VpVud0WdhSngS28cGJkCpXZBHVvwYn70R2Y\n3CQPLsrhzJu2f6aRuJKgV9SKchyjgY8wgYwwDAYDVR0TAQH/BAIwADAOBgNVHQ8B\nAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYBBQUHAwEwFwYDVR0RBBAwDoIMZ29vZC5l\neGFtcGxlMB0GA1UdDgQWBBR/Jgdz9pBzoaP4G1DbR35Kxj5i1TAfBgNVHSMEGDAW\ngBQT72iLiQBn2qpZQVE63BiCGhOD9jAKBggqhkjOPQQDAgNHADBEAiB4DrjMjk+N\nRmbRLHLDuWaGQf9QIYLZUaraBwWqeHOmuAIgV5O5/ilGq3V9Qrziy7CRshXpVUCg\n7dmUMLldx4g+FJ8=\n-----END CERTIFICATE-----\n" +} + +fn pem_ee() -> string { + return "-----BEGIN CERTIFICATE-----\nMIIBtjCCAVygAwIBAgIJALh6d6i8YBNcMAoGCCqGSM49BAMCMBoxGDAWBgNVBAMM\nD0NoYWluIFRlc3QgUm9vdDAgFw0yNjAxMDEwMDAwMDBaGA8yMTI2MDEwMTAwMDAw\nMFowFTETMBEGA1UEAwwKZWUuZXhhbXBsZTBZMBMGByqGSM49AgEGCCqGSM49AwEH\nA0IABNh9gNf9eWeSwPWdhI3DpIOVHrjenicX3Sa1tLnJFYAUS3OqIa0+t52fV8EK\n+PogSXWCbZGUKwvKJsEgUTdRnfyjgY0wgYowDAYDVR0TAQH/BAIwADAOBgNVHQ8B\nAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYBBQUHAwEwFQYDVR0RBA4wDIIKZWUuZXhh\nbXBsZTAdBgNVHQ4EFgQU2C1hh779HGUAtLwhOXqC+++0OQYwHwYDVR0jBBgwFoAU\nkxMUuxXX9urrj1w+az33GyEVI20wCgYIKoZIzj0EAwIDSAAwRQIhAPgn8Alk5Asg\nISkDOFApVhWfMU9u0TRihoNtDey6EUcCAiBsWnuvSTm7jaZzi6iskC6zrc57avCQ\n8gOvYudJgv2nfw==\n-----END CERTIFICATE-----\n" +} + +fn pem_forged() -> string { + return "-----BEGIN CERTIFICATE-----\nMIIBhTCCASqgAwIBAgIJAJp6M/qgtyKkMAoGCCqGSM49BAMCMBUxEzARBgNVBAMM\nCmVlLmV4YW1wbGUwIBcNMjYwMTAxMDAwMDAwWhgPMjEyNjAxMDEwMDAwMDBaMBkx\nFzAVBgNVBAMMDnZpY3RpbS5leGFtcGxlMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcD\nQgAEjy2MCqFgblLNqfj7FCaVaegV/9+IkYJeYVpte/exJ8vv3aGUoXdlo+Kt5zft\nW03JvOkgxeW0hvXHS9KdtQAe96NdMFswGQYDVR0RBBIwEIIOdmljdGltLmV4YW1w\nbGUwHQYDVR0OBBYEFMTLqT26KUrQSXnrAQpKrsWbHlIcMB8GA1UdIwQYMBaAFNgt\nYYe+/RxlALS8ITl6gvvvtDkGMAoGCCqGSM49BAMCA0kAMEYCIQDhwGxYrWXzzbZT\nXfJJokjc2tmFgdLY/dicqD0x9q1LggIhANdlUx/01YP8xidD02QXFq+qGq6q2ssY\nyVzw0ygGHpxR\n-----END CERTIFICATE-----\n" +} + +fn pem_eenobc() -> string { + return "-----BEGIN CERTIFICATE-----\nMIIBiDCCAS+gAwIBAgIJAOuT8Kr+X28hMAoGCCqGSM49BAMCMBoxGDAWBgNVBAMM\nD0NoYWluIFRlc3QgUm9vdDAgFw0yNjAxMDEwMDAwMDBaGA8yMTI2MDEwMTAwMDAw\nMFowGTEXMBUGA1UEAwwOZWVub2JjLmV4YW1wbGUwWTATBgcqhkjOPQIBBggqhkjO\nPQMBBwNCAATTmdom/PCdU96Botu6WF72Igr8nD5jGo8SzfMGvFrFyCTUg7EzX2pf\n5fgh5fYZi3h2IAfch6nv7/fXAmiFePUUo10wWzAZBgNVHREEEjAQgg5lZW5vYmMu\nZXhhbXBsZTAdBgNVHQ4EFgQUwP9lFK4DVOEof9dBeUmYRhKH1DMwHwYDVR0jBBgw\nFoAUkxMUuxXX9urrj1w+az33GyEVI20wCgYIKoZIzj0EAwIDRwAwRAIgZpl7cJ6I\nRuc3eezttvN+kTFR/+bpLyYPPFgnxx9FXmsCIBaVjeDZAr/p76XRHAxXiSEEt8yo\nvneiWRuK/TZb45Mh\n-----END CERTIFICATE-----\n" +} + +fn pem_forged2() -> string { + return "-----BEGIN CERTIFICATE-----\nMIIBhzCCAS2gAwIBAgIIQcQjFTeJtwEwCgYIKoZIzj0EAwIwGTEXMBUGA1UEAwwO\nZWVub2JjLmV4YW1wbGUwIBcNMjYwMTAxMDAwMDAwWhgPMjEyNjAxMDEwMDAwMDBa\nMBkxFzAVBgNVBAMMDnZpY3RpbS5leGFtcGxlMFkwEwYHKoZIzj0CAQYIKoZIzj0D\nAQcDQgAEFU/D0GBoosynBWc1hjeBPlssyZB7KhTHfwVrDz/7a72KiOY5ghBVDHw6\nUsOWCaXgPcd38HK5dGecyeZM+pwD8KNdMFswGQYDVR0RBBIwEIIOdmljdGltLmV4\nYW1wbGUwHQYDVR0OBBYEFJ3l4IbWT8YT0U9/5aCJbSDvf76hMB8GA1UdIwQYMBaA\nFMD/ZRSuA1ThKH/XQXlJmEYSh9QzMAoGCCqGSM49BAMCA0gAMEUCIBN7fvaYseeq\nuzuUmui3Y4JeSXd7hfBifiFzfJexY1VkAiEA0hipOAf4fVKGNy8W+zunch4EnAnB\na7qbgPU1i2PEFSc=\n-----END CERTIFICATE-----\n" +} + +fn pem_clientonly() -> string { + return "-----BEGIN CERTIFICATE-----\nMIIBmzCCAUKgAwIBAgIIDn7pbe/nHO8wCgYIKoZIzj0EAwIwGTEXMBUGA1UEAwwO\nQ2hhaW4gVGVzdCBJQ0EwIBcNMjYwMTAxMDAwMDAwWhgPMjEyNjAxMDEwMDAwMDBa\nMBkxFzAVBgNVBAMMDmNsaWVudC5leGFtcGxlMFkwEwYHKoZIzj0CAQYIKoZIzj0D\nAQcDQgAEI4Qzi8xpgeOqdRBxY4dLpeeD8Wr9R9AwT0z4FV0NSqOU0wxprYBLgaVK\nROH9NBFi0PEpqoHt9guIJRh89A8aBqNyMHAwEwYDVR0lBAwwCgYIKwYBBQUHAwIw\nGQYDVR0RBBIwEIIOY2xpZW50LmV4YW1wbGUwHQYDVR0OBBYEFEWCMkxgil+SUrbY\nZuku1i3llze5MB8GA1UdIwQYMBaAFBPvaIuJAGfaqllBUTrcGIIaE4P2MAoGCCqG\nSM49BAMCA0cAMEQCICrHOLiPRHOiHXqsdF+2l+HZ/x3VgKJHqC1OJC+AW8baAiBZ\nBoWlSr4gn8hcbsLOYG/8wOh/E0jZNGko5KJtIQFwDA==\n-----END CERTIFICATE-----\n" +} + +fn pem_ica0() -> string { + return "-----BEGIN CERTIFICATE-----\nMIIBmzCCAUKgAwIBAgIJAMLf9qj23N6LMAoGCCqGSM49BAMCMBoxGDAWBgNVBAMM\nD0NoYWluIFRlc3QgUm9vdDAgFw0yNjAxMDEwMDAwMDBaGA8yMTI2MDEwMTAwMDAw\nMFowIzEhMB8GA1UEAwwYQ2hhaW4gVGVzdCBJQ0EgcGF0aGxlbiAwMFkwEwYHKoZI\nzj0CAQYIKoZIzj0DAQcDQgAEHlB2UAAVsUlk6KR2djgmZjgSVz2Z94BTUaaTJe0L\nQQXVbmletsjVVdoqM3NM0GLtZaRSSpsvr+5NFLpNXnOhg6NmMGQwEgYDVR0TAQH/\nBAgwBgEB/wIBADAOBgNVHQ8BAf8EBAMCAgQwHQYDVR0OBBYEFC1D6CQdGfKoUlve\nkD0oKbgoxp42MB8GA1UdIwQYMBaAFJMTFLsV1/bq649cPms99xshFSNtMAoGCCqG\nSM49BAMCA0cAMEQCIBideI7PfEAnZjNwgO1XMo8r7riIauvjM0vUDIjpDrZLAiBJ\nLu8yKQjlHifKhufX7wbPzABA9cMaK1Pb3R7u2UBdaw==\n-----END CERTIFICATE-----\n" +} + +fn pem_subca() -> string { + return "-----BEGIN CERTIFICATE-----\nMIIBmzCCAUGgAwIBAgIJAJmIRc5+V1ANMAoGCCqGSM49BAMCMCMxITAfBgNVBAMM\nGENoYWluIFRlc3QgSUNBIHBhdGhsZW4gMDAgFw0yNjAxMDEwMDAwMDBaGA8yMTI2\nMDEwMTAwMDAwMFowHDEaMBgGA1UEAwwRQ2hhaW4gVGVzdCBTdWIgQ0EwWTATBgcq\nhkjOPQIBBggqhkjOPQMBBwNCAAS+K2mCkDX7CkdRk3vg5A/+wgJ0Y3WfUOaQFzTo\nCXzWbgQ2Jk6BPSdVWNRW9dp6EndJeBG9h/B20AlTzVqIGJ2So2MwYTAPBgNVHRMB\nAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUY/7Jxh3TCK9chIUC\nVo4nQgA69AgwHwYDVR0jBBgwFoAULUPoJB0Z8qhSW96QPSgpuCjGnjYwCgYIKoZI\nzj0EAwIDSAAwRQIgY/anlw+ei9nUB1dq9hlkvaWJ8gA9kLJnEIL0HouEX5cCIQDu\nkhxekx3zfeItmfLP3NpojifpfKCD0YQHBLNB8G8QOg==\n-----END CERTIFICATE-----\n" +} + +fn pem_subleaf() -> string { + return "-----BEGIN CERTIFICATE-----\nMIIBhDCCASqgAwIBAgIILMkd8QRRxuYwCgYIKoZIzj0EAwIwHDEaMBgGA1UEAwwR\nQ2hhaW4gVGVzdCBTdWIgQ0EwIBcNMjYwMTAxMDAwMDAwWhgPMjEyNjAxMDEwMDAw\nMDBaMBYxFDASBgNVBAMMC3N1Yi5leGFtcGxlMFkwEwYHKoZIzj0CAQYIKoZIzj0D\nAQcDQgAEaBLR/3EML0w6D8fKNWxJeTcx6um59Lgii9pRzIxE6u805pWggKe5C9C0\nIzSf3HkPOpXVzNzpowP60/EnKKtAbKNaMFgwFgYDVR0RBA8wDYILc3ViLmV4YW1w\nbGUwHQYDVR0OBBYEFPhPycykUHLBwaV1fLjciEYKp8DEMB8GA1UdIwQYMBaAFGP+\nycYd0wivXISFAlaOJ0IAOvQIMAoGCCqGSM49BAMCA0gAMEUCIH//5blPcMu7meq4\njq3A+TqkIeq8gyTFeqC3WjZvn+Z5AiEAhoM0UOZ//y8j1i1rCiKNXvgksmvblnB+\n/Qe3TYSV6ag=\n-----END CERTIFICATE-----\n" +} + +fn pem_icanoks() -> string { + return "-----BEGIN CERTIFICATE-----\nMIIBnTCCAUSgAwIBAgIJALpRev/6Tu4PMAoGCCqGSM49BAMCMBoxGDAWBgNVBAMM\nD0NoYWluIFRlc3QgUm9vdDAgFw0yNjAxMDEwMDAwMDBaGA8yMTI2MDEwMTAwMDAw\nMFowKDEmMCQGA1UEAwwdQ2hhaW4gVGVzdCBJQ0Egbm8ga2V5Q2VydFNpZ24wWTAT\nBgcqhkjOPQIBBggqhkjOPQMBBwNCAARmFPRj7mj+UEqoOk2pISa+Z6UhP8eDe7Kc\n7gRMF+U3ypvwQDqGydvOp8u+6zWAzWDK96UBSHcxuff0j2OOZWP4o2MwYTAPBgNV\nHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIHgDAdBgNVHQ4EFgQUl4xI6MB5GLl3\nRGHihPVCn4gMvCYwHwYDVR0jBBgwFoAUkxMUuxXX9urrj1w+az33GyEVI20wCgYI\nKoZIzj0EAwIDRwAwRAIgFByzmYzj7Rn7LbYTZiwEFuBaNb0Cu9f0dQtAo15TQawC\nIDNTScG4zcryVOIkbUxDvefxkwtEKciZB3Ov6WNgbgXZ\n-----END CERTIFICATE-----\n" +} + +fn pem_nksleaf() -> string { + return "-----BEGIN CERTIFICATE-----\nMIIBkDCCATagAwIBAgIIA2DlyWvYdw8wCgYIKoZIzj0EAwIwKDEmMCQGA1UEAwwd\nQ2hhaW4gVGVzdCBJQ0Egbm8ga2V5Q2VydFNpZ24wIBcNMjYwMTAxMDAwMDAwWhgP\nMjEyNjAxMDEwMDAwMDBaMBYxFDASBgNVBAMMC25rcy5leGFtcGxlMFkwEwYHKoZI\nzj0CAQYIKoZIzj0DAQcDQgAEtW7KfMY7ZNhQbC1r32cdJjS3nOhgFhM/5dmEBqeE\nCw/6CLdPGkWFm7bywZxE2sW0CBSmsfGRuUdfkr5oD6w6DqNaMFgwFgYDVR0RBA8w\nDYILbmtzLmV4YW1wbGUwHQYDVR0OBBYEFBHWDlL8HlzbTwN62b5fuW4JEcC9MB8G\nA1UdIwQYMBaAFJeMSOjAeRi5d0Rh4oT1Qp+IDLwmMAoGCCqGSM49BAMCA0gAMEUC\nIEor3mznKbsdnyw2PVzf+qwKlxHgv8dSzdEdWVRpX7GoAiEAkL3aplGzLlkg5Sps\nSj8kK/hvxYukdSBTrAw+SvHO0MU=\n-----END CERTIFICATE-----\n" +} + +fn pem_icanc() -> string { + return "-----BEGIN CERTIFICATE-----\nMIIBtTCCAVqgAwIBAgIJAKWgHjZwUGe6MAoGCCqGSM49BAMCMBoxGDAWBgNVBAMM\nD0NoYWluIFRlc3QgUm9vdDAgFw0yNjAxMDEwMDAwMDBaGA8yMTI2MDEwMTAwMDAw\nMFowHDEaMBgGA1UEAwwRQ2hhaW4gVGVzdCBJQ0EgTkMwWTATBgcqhkjOPQIBBggq\nhkjOPQMBBwNCAASvgqKfa9BvleWEvPzASMuj54dAv9cEvzYGvE+dR8d0RSNqfvVA\nwObn6vR7ShijGkFs11YRUPL9AJGocWIY59evo4GEMIGBMA8GA1UdEwEB/wQFMAMB\nAf8wDgYDVR0PAQH/BAQDAgIEMB4GA1UdHgEB/wQUMBKgEDAOggxnb29kLmV4YW1w\nbGUwHQYDVR0OBBYEFNCvHGtH4ODXuWD4UzBAVI5fXQuSMB8GA1UdIwQYMBaAFJMT\nFLsV1/bq649cPms99xshFSNtMAoGCCqGSM49BAMCA0kAMEYCIQDRh4AGgPLQcXb1\noTUO3151XFstiaYlubcpWskbQQBM1QIhAJakKYwTwHgg3aoDPlxgoYpuFztHXUO7\nGfpSyKI6xR5I\n-----END CERTIFICATE-----\n" +} + +fn pem_ncin() -> string { + return "-----BEGIN CERTIFICATE-----\nMIIBjDCCATOgAwIBAgIJANt5rtot2mHzMAoGCCqGSM49BAMCMBwxGjAYBgNVBAMM\nEUNoYWluIFRlc3QgSUNBIE5DMCAXDTI2MDEwMTAwMDAwMFoYDzIxMjYwMTAxMDAw\nMDAwWjAaMRgwFgYDVQQDDA9pbi5nb29kLmV4YW1wbGUwWTATBgcqhkjOPQIBBggq\nhkjOPQMBBwNCAARQA5J/Ito04N13qoPsqQGO8IvLzkfkHScqWEyr77k6Ble1IwV8\nK8MGp36bk6iLh80t3tS70Ju1Y+6PBo2JHzPbo14wXDAaBgNVHREEEzARgg9pbi5n\nb29kLmV4YW1wbGUwHQYDVR0OBBYEFP4eHzOVbNmVcAYyYD3re7ua6dG5MB8GA1Ud\nIwQYMBaAFNCvHGtH4ODXuWD4UzBAVI5fXQuSMAoGCCqGSM49BAMCA0cAMEQCIGzp\nwCZsTilWT9O4awdbf7jiqw+vhLssVdIoee3wiGA8AiBY8izMmrF808CA/pteAULP\nTJtiErY1Ufl9BMxjA/9qIA==\n-----END CERTIFICATE-----\n" +} + +fn pem_ncout() -> string { + return "-----BEGIN CERTIFICATE-----\nMIIBijCCAS+gAwIBAgIJAORdoTy0HXBLMAoGCCqGSM49BAMCMBwxGjAYBgNVBAMM\nEUNoYWluIFRlc3QgSUNBIE5DMCAXDTI2MDEwMTAwMDAwMFoYDzIxMjYwMTAxMDAw\nMDAwWjAYMRYwFAYDVQQDDA1vdGhlci5leGFtcGxlMFkwEwYHKoZIzj0CAQYIKoZI\nzj0DAQcDQgAEj742kwsLFTv62WJkLN6tPlCp1sJMSm+LqW4anNwWK2/qsyVxkpg2\nLJ7Sl2r5Pp5MDwR49x4bHJfBgXM7Wf0s/aNcMFowGAYDVR0RBBEwD4INb3RoZXIu\nZXhhbXBsZTAdBgNVHQ4EFgQUtEhVLY+WciHX/ZExbk9ihBsD9xYwHwYDVR0jBBgw\nFoAU0K8ca0fg4Ne5YPhTMEBUjl9dC5IwCgYIKoZIzj0EAwIDSQAwRgIhANY5fHuJ\nbTWiHYF3k4LewShlJnq3J8mkwmhYOuuAbANHAiEAvwxLIIUW7i95X2+uvE7cLU3c\nTqFGi2TA2bvgvy8D7kM=\n-----END CERTIFICATE-----\n" +} + +fn pem_crit() -> string { + return "-----BEGIN CERTIFICATE-----\nMIIBmDCCAT2gAwIBAgIIU90IRfczU08wCgYIKoZIzj0EAwIwGTEXMBUGA1UEAwwO\nQ2hhaW4gVGVzdCBJQ0EwIBcNMjYwMTAxMDAwMDAwWhgPMjEyNjAxMDEwMDAwMDBa\nMBcxFTATBgNVBAMMDGNyaXQuZXhhbXBsZTBZMBMGByqGSM49AgEGCCqGSM49AwEH\nA0IABGMGAeJlRDNlyQ3EVYvnTq3AcH01Eyk+4ZmzoiVERAMcJ+DjdcXKjQotfOEN\n2zb+GMlz3niSHaFZ2IhtA0UZnB2jbzBtMBcGA1UdEQQQMA6CDGNyaXQuZXhhbXBs\nZTASBgkrBgEEAYOyAwEBAf8EAgUAMB0GA1UdDgQWBBRqbvVWkol98Dk0yyD+C3JW\nh9NHWjAfBgNVHSMEGDAWgBQT72iLiQBn2qpZQVE63BiCGhOD9jAKBggqhkjOPQQD\nAgNJADBGAiEA1GTK77TkfqmFd9lBRRG8KfeCMSQp/7RF8Qk17inQqDsCIQDPJfwq\nMvkw83kJO4Eh6m7YRBhhHiyYOjedQA4BzxduOA==\n-----END CERTIFICATE-----\n" +} + +fn pem_weak() -> string { + return "-----BEGIN CERTIFICATE-----\nMIIByzCCAXGgAwIBAgIJAP0mboTG0UH3MAoGCCqGSM49BAMCMBkxFzAVBgNVBAMM\nDkNoYWluIFRlc3QgSUNBMCAXDTI2MDEwMTAwMDAwMFoYDzIxMjYwMTAxMDAwMDAw\nWjAXMRUwEwYDVQQDDAx3ZWFrLmV4YW1wbGUwgZ8wDQYJKoZIhvcNAQEBBQADgY0A\nMIGJAoGBANq0yiKc/H7BBo2iB6RsddnllzOA47N7uACzr+vjz1KBmsQ2iM36NIeB\nRScRJga/enopWcol10/6jUr3W0D6Q8XWB9HrOGRVRZNZ2Mnw2UahwpyNe03I3zmr\nDPY6jun9CHnuqlyjv0CJ+a4KNaJowGaHbNW58L9YiCOwhbtnzrIbAgMBAAGjWzBZ\nMBcGA1UdEQQQMA6CDHdlYWsuZXhhbXBsZTAdBgNVHQ4EFgQUNM5rEsSRCCI3pBsE\nwE8k+uETC6UwHwYDVR0jBBgwFoAUE+9oi4kAZ9qqWUFROtwYghoTg/YwCgYIKoZI\nzj0EAwIDSAAwRQIgf9gEjX5KNXixoQNipPSBYwobVjljuEvHq4/3+92t+qkCIQCB\nOiI6e9ik0WRHnBtjWTNDKbhA6VNmBbujgdtbBcQOCg==\n-----END CERTIFICATE-----\n" +} + +fn pem_explicit() -> string { + return "-----BEGIN CERTIFICATE-----\nMIICfzCCAiWgAwIBAgIIMYV5ulpXqzgwCgYIKoZIzj0EAwIwGTEXMBUGA1UEAwwO\nQ2hhaW4gVGVzdCBJQ0EwIBcNMjYwMTAxMDAwMDAwWhgPMjEyNjAxMDEwMDAwMDBa\nMBsxGTAXBgNVBAMMEGV4cGxpY2l0LmV4YW1wbGUwggFLMIIBAwYHKoZIzj0CATCB\n9wIBATAsBgcqhkjOPQEBAiEA/////wAAAAEAAAAAAAAAAAAAAAD/////////////\n//8wWwQg/////wAAAAEAAAAAAAAAAAAAAAD///////////////wEIFrGNdiqOpPn\ns+u9VXaYhrxlHQawzFOw9jvOPD4n0mBLAxUAxJ02CIbnBJNqZnjhE50mt4GffpAE\nQQRrF9Hy4SxCR/i85uVjpEDydwN9gS3rM6D0oTlF2JjClk/jQuL+Gn+bjufrSnwP\nnhYrzjNXazFezsu2QGg3v1H1AiEA/////wAAAAD//////////7zm+q2nF56E87nK\nwvxjJVECAQEDQgAEMqxpwdZNw+GRe3BpjDh1BIvQHsd3CN5z/8yY7TC6VtvgxKlv\nDJNEf8qrhe45zKsk8FiYkVHJeIX6dZ5WiNgCRqNfMF0wGwYDVR0RBBQwEoIQZXhw\nbGljaXQuZXhhbXBsZTAdBgNVHQ4EFgQUHHgxCcNB2CdTu7P6splEx7g8W2kwHwYD\nVR0jBBgwFoAUE+9oi4kAZ9qqWUFROtwYghoTg/YwCgYIKoZIzj0EAwIDSAAwRQIh\nAJ5X+i2Jf9pLAoXphIMLz2osHImbh+44ZG7v/EMfo/4/AiA0u6Txr48c/y9FDX+Y\norMfDIZTdh9TRvKCXHmclaK7dw==\n-----END CERTIFICATE-----\n" +} + +fn pem_icaex() -> string { + return "-----BEGIN CERTIFICATE-----\nMIIBwTCCAWegAwIBAgIIB9vW3Df6JvowCgYIKoZIzj0EAwIwGjEYMBYGA1UEAwwP\nQ2hhaW4gVGVzdCBSb290MCAXDTI2MDEwMTAwMDAwMFoYDzIxMjYwMTAxMDAwMDAw\nWjAnMSUwIwYDVQQDDBxDaGFpbiBUZXN0IElDQSBleGNsdWRpbmcgYmFyMFkwEwYH\nKoZIzj0CAQYIKoZIzj0DAQcDQgAEL31MUOMndDs3uoX4gJp7Ia8f9422/syEdpwB\nleUIV5q3dI2AZu6AOQehAKISCm70+9XqwFGbJQfJ74qrOTlbpKOBhzCBhDAPBgNV\nHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwICBDAhBgNVHR4BAf8EFzAVoRMwEYIP\nYmFyLmV4YW1wbGUuY29tMB0GA1UdDgQWBBRkaPJx//lT3TBL9rTuasQ79xQYRTAf\nBgNVHSMEGDAWgBSTExS7Fdf26uuPXD5rPfcbIRUjbTAKBggqhkjOPQQDAgNIADBF\nAiEAnN5iS3WNVNuRBGO+CmhHL5hZnMa0FE7tsjnsIW+CO94CIEXEfRS5nb5DTlG5\nLy26+O3ph7M0DVp63pu2nJh8P50J\n-----END CERTIFICATE-----\n" +} + +fn pem_wild() -> string { + return "-----BEGIN CERTIFICATE-----\nMIIBljCCAT2gAwIBAgIJAP3UL9Ox9hAqMAoGCCqGSM49BAMCMCcxJTAjBgNVBAMM\nHENoYWluIFRlc3QgSUNBIGV4Y2x1ZGluZyBiYXIwIBcNMjYwMTAxMDAwMDAwWhgP\nMjEyNjAxMDEwMDAwMDBaMBsxGTAXBgNVBAMMEHdpbGQuZXhhbXBsZS5jb20wWTAT\nBgcqhkjOPQIBBggqhkjOPQMBBwNCAATDNIUVf8/oY1zT3oKQOrt7d4MbPLA4mDWa\ndRz7HSCKXRamUU48Vatjxtb+AJgJ3WuYrbmO6YVLyKZrQIHwQR1Ro1wwWjAYBgNV\nHREEETAPgg0qLmV4YW1wbGUuY29tMB0GA1UdDgQWBBR22nR/KnenoAT3DO+ZGXoH\nUlnROzAfBgNVHSMEGDAWgBRkaPJx//lT3TBL9rTuasQ79xQYRTAKBggqhkjOPQQD\nAgNHADBEAiB2Vayf8IY8qqOI93nNs0AHCmc5D4VQCZKnQqVlvuh/TAIgKvQiTA19\nlQOtV2R/VVeYNZZScjTOrNr86Ajb6BHa9b0=\n-----END CERTIFICATE-----\n" +} + +fn pem_wildok() -> string { + return "-----BEGIN CERTIFICATE-----\nMIIBkjCCATigAwIBAgIIaa6GyEXMU2swCgYIKoZIzj0EAwIwJzElMCMGA1UEAwwc\nQ2hhaW4gVGVzdCBJQ0EgZXhjbHVkaW5nIGJhcjAgFw0yNjAxMDEwMDAwMDBaGA8y\nMTI2MDEwMTAwMDAwMFowGTEXMBUGA1UEAwwOd2lsZC5vdGhlci5jb20wWTATBgcq\nhkjOPQIBBggqhkjOPQMBBwNCAAQauX7Sk+KJqQnj2WqzgQx0Iogn+vaYqTtGjQ1k\n1y7xlonTz4K+BGWMrOKHmP+OQYJ1Acv+y/ip7i4N/V0hszF3o1owWDAWBgNVHREE\nDzANggsqLm90aGVyLmNvbTAdBgNVHQ4EFgQUXo47cVyU/MbGUlJSWWm00R8py8sw\nHwYDVR0jBBgwFoAUZGjycf/5U90wS/a07mrEO/cUGEUwCgYIKoZIzj0EAwIDSAAw\nRQIgKXwCfbhj+WCkymYBLJ+6k2YEkGtHNBWaBeMCXrnade0CIQDC2Vbe9Q3VLr5I\nXA67ui0z+HfSi6aoCXd2AH+9hht4DQ==\n-----END CERTIFICATE-----\n" +} + +fn pem_akikeyid() -> string { + return "-----BEGIN CERTIFICATE-----\nMIIBlzCCAT2gAwIBAgICfgEwCgYIKoZIzj0EAwIwITEfMB0GA1UEAwwWQUtJIFRl\nc3QgUm9vdCBha2lrZXlpZDAgFw0yNjAxMDEwMDAwMDBaGA8yMTI2MDEwMTAwMDAw\nMFowITEfMB0GA1UEAwwWQUtJIFRlc3QgUm9vdCBha2lrZXlpZDBZMBMGByqGSM49\nAgEGCCqGSM49AwEHA0IABLCswswc/A+jV3vDnQpaamjdYjypeAxyUX5G89yT9I3x\n+1qw/jFh72WLAxTLnGF1FPXKEpFOlne9pduIyVpzIi2jYzBhMA8GA1UdEwEB/wQF\nMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBRsFVvO3l0QOtF+wGyEkb1v\nDY3DKDAfBgNVHSMEGDAWgBQRERERERERERERERERERERERERETAKBggqhkjOPQQD\nAgNIADBFAiEAtruEwJzHoDJa+TviDpiOM1ZTAlq/Q8mDz7j5YktiNuICIAWPmad+\nA25xyNwdENvkl9JXUtOYUk9iTHZ+bFrnMHpc\n-----END CERTIFICATE-----\n" +} + +fn pem_akikeyidleaf() -> string { + return "-----BEGIN CERTIFICATE-----\nMIIBlDCCATmgAwIBAgIIdj9Yta7jxrswCgYIKoZIzj0EAwIwITEfMB0GA1UEAwwW\nQUtJIFRlc3QgUm9vdCBha2lrZXlpZDAgFw0yNjAxMDEwMDAwMDBaGA8yMTI2MDEw\nMTAwMDAwMFowGzEZMBcGA1UEAwwQYWtpa2V5aWQuZXhhbXBsZTBZMBMGByqGSM49\nAgEGCCqGSM49AwEHA0IABBGv/Wc77sSDACgSpBTa04L58Dron96DbnA+L4opn9lv\nttxX8In6uab/KKsMHBwgpOIPPNbGxxaTh6CqPyXCnr6jXzBdMBsGA1UdEQQUMBKC\nEGFraWtleWlkLmV4YW1wbGUwHQYDVR0OBBYEFEHFAqwicEg56dJRX03jE9/RGaAw\nMB8GA1UdIwQYMBaAFGwVW87eXRA60X7AbISRvW8NjcMoMAoGCCqGSM49BAMCA0kA\nMEYCIQCVkRhgGkH8eiuv45GyruDOmJVhkuOqWssMQ6vWWT698gIhANaVv+P25PPw\nuRYJLbj15zxjc1maPg59MuaRo/8sRVe5\n-----END CERTIFICATE-----\n" +} + +fn pem_akiissuer() -> string { + return "-----BEGIN CERTIFICATE-----\nMIIBnzCCAUagAwIBAgICfgIwCgYIKoZIzj0EAwIwIjEgMB4GA1UEAwwXQUtJIFRl\nc3QgUm9vdCBha2lpc3N1ZXIwIBcNMjYwMTAxMDAwMDAwWhgPMjEyNjAxMDEwMDAw\nMDBaMCIxIDAeBgNVBAMMF0FLSSBUZXN0IFJvb3QgYWtpaXNzdWVyMFkwEwYHKoZI\nzj0CAQYIKoZIzj0DAQcDQgAETDwQBiH6cynAlb2fzbzqonYvoLEyp5vTsGOABSEj\nm81DKOrfKvzPI+A6n5fj4wWyt8m8CGCy25ZCQkLXvbyM2KNqMGgwDwYDVR0TAQH/\nBAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFLke90q2or+DnMH/HQ7Z\naXj3ypI8MCYGA1UdIwQfMB2hG6QZMBcxFTATBgNVBAMMDFNvbWVvbmUgRWxzZTAK\nBggqhkjOPQQDAgNHADBEAiAHext1BJdB7lyDT9ocf2WasQYhJewui0k0emN+jvqf\nywIgW809gZmzi9MNIck0da/RMLohSTxsEFHOlJq5y1uZYWk=\n-----END CERTIFICATE-----\n" +} + +fn pem_akiissuerleaf() -> string { + return "-----BEGIN CERTIFICATE-----\nMIIBljCCATygAwIBAgIISslJhl99GTMwCgYIKoZIzj0EAwIwIjEgMB4GA1UEAwwX\nQUtJIFRlc3QgUm9vdCBha2lpc3N1ZXIwIBcNMjYwMTAxMDAwMDAwWhgPMjEyNjAx\nMDEwMDAwMDBaMBwxGjAYBgNVBAMMEWFraWlzc3Vlci5leGFtcGxlMFkwEwYHKoZI\nzj0CAQYIKoZIzj0DAQcDQgAEGm4Wpv0ZK/MBIHGNJFMw0ihZwtVtp97xVM/+X+po\nZHgUPQc47aEcRkpLKA1goiHZIaciZ04dT+T90V0TWBeiNKNgMF4wHAYDVR0RBBUw\nE4IRYWtpaXNzdWVyLmV4YW1wbGUwHQYDVR0OBBYEFDcOKjTQSMmSejcBy5leE2d5\n6huqMB8GA1UdIwQYMBaAFLke90q2or+DnMH/HQ7ZaXj3ypI8MAoGCCqGSM49BAMC\nA0gAMEUCIQDKIdPpZXi9+RZ/nJU0/NqRw2P90sUVqLe4fa1ZSSQAFQIgLvVfjWNx\nB4movGDSA/JmA7/7vH3vTrT36kPAICQGxvE=\n-----END CERTIFICATE-----\n" +} + +fn pem_akiserial() -> string { + return "-----BEGIN CERTIFICATE-----\nMIIBhzCCAS6gAwIBAgICfgMwCgYIKoZIzj0EAwIwIjEgMB4GA1UEAwwXQUtJIFRl\nc3QgUm9vdCBha2lzZXJpYWwwIBcNMjYwMTAxMDAwMDAwWhgPMjEyNjAxMDEwMDAw\nMDBaMCIxIDAeBgNVBAMMF0FLSSBUZXN0IFJvb3QgYWtpc2VyaWFsMFkwEwYHKoZI\nzj0CAQYIKoZIzj0DAQcDQgAE7AIluDN03TLpBdo/lDLGvyW8+bGMa61QJRX3cznT\nK0L0skwz115bHIKc+hXZInUjprLcu3JKAkTsvgtGWvtLzaNSMFAwDwYDVR0TAQH/\nBAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFOrxd6e1U1vNA/pmSDGF\n8Qh+UXerMA4GA1UdIwQHMAWCAwECAzAKBggqhkjOPQQDAgNHADBEAiBg2Rvq673A\n6LEfxUjtZh86YA0V5NUITQyevseDQtn5+AIgHt8ZHzyZx33LyLygoncb8jql9MNp\n9n0KyHoSPM1uq/s=\n-----END CERTIFICATE-----\n" +} + +fn pem_akiserialleaf() -> string { + return "-----BEGIN CERTIFICATE-----\nMIIBlzCCAT2gAwIBAgIJAOpWLG5gq0LcMAoGCCqGSM49BAMCMCIxIDAeBgNVBAMM\nF0FLSSBUZXN0IFJvb3QgYWtpc2VyaWFsMCAXDTI2MDEwMTAwMDAwMFoYDzIxMjYw\nMTAxMDAwMDAwWjAcMRowGAYDVQQDDBFha2lzZXJpYWwuZXhhbXBsZTBZMBMGByqG\nSM49AgEGCCqGSM49AwEHA0IABCNgXK/lhCoCfijPu0DH1RRWjwhHoXNQepvFdVYD\nE5cp8o/FyxQdFIVl+TGLGP/8W7nW/bq50QiJzRFY0IZkBQajYDBeMBwGA1UdEQQV\nMBOCEWFraXNlcmlhbC5leGFtcGxlMB0GA1UdDgQWBBTyBwXP0JmHIm43VHe73rge\n2JW10TAfBgNVHSMEGDAWgBTq8XentVNbzQP6ZkgxhfEIflF3qzAKBggqhkjOPQQD\nAgNIADBFAiBUXzs50/Sw5iRDiqZvXgg1pq49mgi7a8Vh0hkgLenO5AIhAMUqn2/K\nJXTxU1sya8sDcVwjoiYGOoUuB+o5OpTDH3YV\n-----END CERTIFICATE-----\n" +} + +fn pem_akiall() -> string { + return "-----BEGIN CERTIFICATE-----\nMIIBtjCCAV2gAwIBAgICfgQwCgYIKoZIzj0EAwIwHzEdMBsGA1UEAwwUQUtJIFRl\nc3QgUm9vdCBha2lhbGwwIBcNMjYwMTAxMDAwMDAwWhgPMjEyNjAxMDEwMDAwMDBa\nMB8xHTAbBgNVBAMMFEFLSSBUZXN0IFJvb3QgYWtpYWxsMFkwEwYHKoZIzj0CAQYI\nKoZIzj0DAQcDQgAEQg1iHTBIXN1tpoPICNObAKLSxVD+KkBcFGDTPdAW+uemTKHU\n09SX9RbKDe4nyMkEcj/XKKSzzBTCHpc5D+uTgqOBhjCBgzAPBgNVHRMBAf8EBTAD\nAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQU9Z4WXye4BL+qcJ/jRpWiop/R\nNOcwQQYDVR0jBDowOIAUERERERERERERERERERERERERERGhG6QZMBcxFTATBgNV\nBAMMDFNvbWVvbmUgRWxzZYIDAQIDMAoGCCqGSM49BAMCA0cAMEQCIGaPhcEO/ZsG\nP3n7YkGdP60KgXA1un2/zcv5TUZeAgFhAiB/e6xk+7GNowomLrrrcPy6dC+8blhV\npH00rKUlrGesqg==\n-----END CERTIFICATE-----\n" +} + +fn pem_akiallleaf() -> string { + return "-----BEGIN CERTIFICATE-----\nMIIBjzCCATSgAwIBAgIJAJ+Vazh6owf9MAoGCCqGSM49BAMCMB8xHTAbBgNVBAMM\nFEFLSSBUZXN0IFJvb3QgYWtpYWxsMCAXDTI2MDEwMTAwMDAwMFoYDzIxMjYwMTAx\nMDAwMDAwWjAZMRcwFQYDVQQDDA5ha2lhbGwuZXhhbXBsZTBZMBMGByqGSM49AgEG\nCCqGSM49AwEHA0IABFs8weGIzZ4ao1x5ggEjX8yaU3+NhaYY1JcIs4meBdKD8VyT\ne7bmj6yArHxwxfbgSDutfpHwvmAyNZkH2ta6lb6jXTBbMBkGA1UdEQQSMBCCDmFr\naWFsbC5leGFtcGxlMB0GA1UdDgQWBBRM4xo34B7z888pm0JOTS7RBI/8+jAfBgNV\nHSMEGDAWgBT1nhZfJ7gEv6pwn+NGlaKin9E05zAKBggqhkjOPQQDAgNJADBGAiEA\n9mKxwKYFF+BSEMF18j6y1C5bz4LRSCR3vPuMJQYBdBUCIQC21pSuedFUiaHgKVo7\nRlrfZHV26OYJzyY+Ui+TtBC6ZQ==\n-----END CERTIFICATE-----\n" +} + +fn pem_akiself() -> string { + return "-----BEGIN CERTIFICATE-----\nMIIBhDCCASugAwIBAgIDAQIDMAoGCCqGSM49BAMCMCAxHjAcBgNVBAMMFUFLSSBU\nZXN0IFJvb3QgYWtpc2VsZjAgFw0yNjAxMDEwMDAwMDBaGA8yMTI2MDEwMTAwMDAw\nMFowIDEeMBwGA1UEAwwVQUtJIFRlc3QgUm9vdCBha2lzZWxmMFkwEwYHKoZIzj0C\nAQYIKoZIzj0DAQcDQgAEEM4pK+Xu0ot+NDV0xutJTQs8AebqCJCWOKF0NG8BaeWX\n2Qw06YRPXZO7bowBJVBNxMfEHBojepEnc1AZ4F71JaNSMFAwDwYDVR0TAQH/BAUw\nAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFBc8PYM8mCLyqGRZ+gSBw+Tb\nm0qnMA4GA1UdIwQHMAWCAwECAzAKBggqhkjOPQQDAgNHADBEAiAIHGvLgxsT+9yV\nrUmuvusORNv7N9tl1oZ3eFvKpZKW6wIgALvcZJuLg/1mTbgL8PmSFDg7CYpSll/0\nYIsHFfHb7hE=\n-----END CERTIFICATE-----\n" +} + +fn pem_akiselfleaf() -> string { + return "-----BEGIN CERTIFICATE-----\nMIIBkDCCATagAwIBAgIIJq8378VgmTgwCgYIKoZIzj0EAwIwIDEeMBwGA1UEAwwV\nQUtJIFRlc3QgUm9vdCBha2lzZWxmMCAXDTI2MDEwMTAwMDAwMFoYDzIxMjYwMTAx\nMDAwMDAwWjAaMRgwFgYDVQQDDA9ha2lzZWxmLmV4YW1wbGUwWTATBgcqhkjOPQIB\nBggqhkjOPQMBBwNCAAQwXwhWMZDN4IZCbOL35yfkeFy0FwX45KhWH407YcMScBJ9\n0izmqLn1TvuRjvPwRs0fRTqE6/ejEwBXIUsO4CYlo14wXDAaBgNVHREEEzARgg9h\na2lzZWxmLmV4YW1wbGUwHQYDVR0OBBYEFJxwb+mecoiyctxYhHb0mvIRnguaMB8G\nA1UdIwQYMBaAFBc8PYM8mCLyqGRZ+gSBw+Tbm0qnMAoGCCqGSM49BAMCA0gAMEUC\nIQD0h/d7AMGPjpT43/LQ0jI9UC7xfVgpKsoUiqUZV+SVfwIgM+JuPWOLvh4CQ8PH\npmz7T2zzlLf1CzdV0ILJpGL/1dI=\n-----END CERTIFICATE-----\n" +} + +fn pem_kunoca() -> string { + return "-----BEGIN CERTIFICATE-----\nMIIBkTCCATegAwIBAgIJALzJ/52vN7+bMAoGCCqGSM49BAMCMBoxGDAWBgNVBAMM\nD0NoYWluIFRlc3QgUm9vdDAgFw0yNjAxMDEwMDAwMDBaGA8yMTI2MDEwMTAwMDAw\nMFowLDEqMCgGA1UEAwwhQ2hhaW4gVGVzdCBrZXlDZXJ0U2lnbiB3aXRob3V0IGNB\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEEUcFbCXUf7FKXo7LS/UXMiKR3UHU\nJ1jgwMXgJzDxKAJ69HTqkw4aahTxaRd2npx3YTmvnZo3aodeyDpshGj1ZKNSMFAw\nDgYDVR0PAQH/BAQDAgIEMB0GA1UdDgQWBBQ/Rt2adefI5PJQ5O0DPF0KCedokTAf\nBgNVHSMEGDAWgBSTExS7Fdf26uuPXD5rPfcbIRUjbTAKBggqhkjOPQQDAgNIADBF\nAiEAvZxO7aNwYGq/hTqvqPAbyqmNLMbA2T44wMnj18/KapMCIHfoUQJ1NvEXCdrR\n7gAiurO3FBxF6dsu52a5ibLZUjxM\n-----END CERTIFICATE-----\n" +} + +fn pem_kunocaleaf() -> string { + return "-----BEGIN CERTIFICATE-----\nMIIBmzCCAUGgAwIBAgIJAI4YOTUjHLNgMAoGCCqGSM49BAMCMCwxKjAoBgNVBAMM\nIUNoYWluIFRlc3Qga2V5Q2VydFNpZ24gd2l0aG91dCBjQTAgFw0yNjAxMDEwMDAw\nMDBaGA8yMTI2MDEwMTAwMDAwMFowGTEXMBUGA1UEAwwOa3Vub2NhLmV4YW1wbGUw\nWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQOEGq2nZ/yKWa4xrWbmuPAnEB+2gwH\nfqQmT1QyrS9FgfdJRyDXBBNJZp05NxpWncWO3+upiV1v5XgJJzBqBFrio10wWzAZ\nBgNVHREEEjAQgg5rdW5vY2EuZXhhbXBsZTAdBgNVHQ4EFgQUgW5LjmTj6c4Egf60\nb8wu5txZVWkwHwYDVR0jBBgwFoAUP0bdmnXnyOTyUOTtAzxdCgnnaJEwCgYIKoZI\nzj0EAwIDSAAwRQIgbe1tX6ydTS5d3prag2Ip72rrO60o2ph5MQsz8y5wwi4CIQDB\nrL4LFy1fzH5tG44zbSg/fyhYhf+A78fY5FK8B0xQXg==\n-----END CERTIFICATE-----\n" +} + +fn pem_pinned() -> string { + return "-----BEGIN CERTIFICATE-----\nMIIBezCCASGgAwIBAgIFUEVFUgEwCgYIKoZIzj0EAwIwFzEVMBMGA1UEAwwMcGVl\nci5leGFtcGxlMCAXDTI2MDEwMTAwMDAwMFoYDzIxMjYwMTAxMDAwMDAwWjAXMRUw\nEwYDVQQDDAxwZWVyLmV4YW1wbGUwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARv\n7S9eWT57ZTmAOztQD2+5EetBRQ+B+cOtLJCr7BVBQPQExOwo/jrT1ahb6i7ZxObZ\nFrq4KWW801ggO2EZ0SqXo1gwVjAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIH\ngDAXBgNVHREEEDAOggxwZWVyLmV4YW1wbGUwHQYDVR0OBBYEFH8HRengm5cRK0K4\na6DbxP8afdpfMAoGCCqGSM49BAMCA0gAMEUCIDCIjkFoXJK1RI1twupNRu+WcP5M\nrU1MCAQ84vwQWP4tAiEArDbfJRioR7QSSuE4iKxVCCFaHbJs/7eLq3CNJGUFXoM=\n-----END CERTIFICATE-----\n" +} + +fn pem_icabadku() -> string { + return "-----BEGIN CERTIFICATE-----\nMIIBnTCCAUKgAwIBAgIJAO5sOGJd9DW0MAoGCCqGSM49BAMCMBoxGDAWBgNVBAMM\nD0NoYWluIFRlc3QgUm9vdDAgFw0yNjAxMDEwMDAwMDBaGA8yMTI2MDEwMTAwMDAw\nMFowJjEkMCIGA1UEAwwbQ2hhaW4gVGVzdCBJQ0EgYmFkIGtleVVzYWdlMFkwEwYH\nKoZIzj0CAQYIKoZIzj0DAQcDQgAE+AT3F9m1t43Wl9KNC+yZsRppa8bDFl4kbaiG\nPsGxKk7l0lYq8gIUpwiyFW+NNS6VqczNLtuv9B4bwTJq1IeE96NjMGEwDwYDVR0T\nAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAQCAQYwHQYDVR0OBBYEFHxFROCF4R/hDlD3\nhc31z5wKhT7eMB8GA1UdIwQYMBaAFJMTFLsV1/bq649cPms99xshFSNtMAoGCCqG\nSM49BAMCA0kAMEYCIQCaWu+7dE/IqZF7nMyC1p94Ii6rWRmTVyp/NllSkM+V3gIh\nAMUI5Zc9uRt4FhKk4Yp+eKa/vEKhlA2ln7/mgL91HabB\n-----END CERTIFICATE-----\n" +} + +fn pem_badkuleaf() -> string { + return "-----BEGIN CERTIFICATE-----\nMIIBkjCCATigAwIBAgIIbgBpfaaB9qkwCgYIKoZIzj0EAwIwJjEkMCIGA1UEAwwb\nQ2hhaW4gVGVzdCBJQ0EgYmFkIGtleVVzYWdlMCAXDTI2MDEwMTAwMDAwMFoYDzIx\nMjYwMTAxMDAwMDAwWjAYMRYwFAYDVQQDDA1iYWRrdS5leGFtcGxlMFkwEwYHKoZI\nzj0CAQYIKoZIzj0DAQcDQgAEC7eYylDd7Cwh+hPPEOaNxfc4RF0FeSPfck4WCbvp\nWKLA1DWIWTAEYoM7+TSw7TavUNCBbLFgIvIdwB41nfK3XaNcMFowGAYDVR0RBBEw\nD4INYmFka3UuZXhhbXBsZTAdBgNVHQ4EFgQUlbAxnUQzkjX9G9Tcqmb4gv1iMNYw\nHwYDVR0jBBgwFoAUfEVE4IXhH+EOUPeFzfXPnAqFPt4wCgYIKoZIzj0EAwIDSAAw\nRQIgag/UoCHWE0rC0/xG48lWlkGA4ZwcRMeOmBosqcCZSt0CIQCT5EMD4B2z+qdr\nGHPri41MtXG+CZpiPxVLJJiKr/I/Ew==\n-----END CERTIFICATE-----\n" +} + +fn pem_icadup() -> string { + return "-----BEGIN CERTIFICATE-----\nMIIBqjCCAVCgAwIBAgIIe5Aw9KwaDkswCgYIKoZIzj0EAwIwGjEYMBYGA1UEAwwP\nQ2hhaW4gVGVzdCBSb290MCAXDTI2MDEwMTAwMDAwMFoYDzIxMjYwMTAxMDAwMDAw\nWjAoMSYwJAYDVQQDDB1DaGFpbiBUZXN0IElDQSBrZXlVc2FnZSB0d2ljZTBZMBMG\nByqGSM49AgEGCCqGSM49AwEHA0IABLx0l07w4+WeOC1Btq4GxdXNrCDBHkLf9/k8\nk7W2p5mN/iJoS+ZpGxpbNws+9e7zBEejmMxxfT+Ag6BDiCzs//WjcDBuMA8GA1Ud\nEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgECMAsGA1UdDwQEAwIBBjAdBgNVHQ4E\nFgQUkZvrcHqUrer7ucotknMV6p9SRY4wHwYDVR0jBBgwFoAUkxMUuxXX9urrj1w+\naz33GyEVI20wCgYIKoZIzj0EAwIDSAAwRQIhANGZYiBYG+efpCzobz3lsse4O8BI\nDzXq4YDBoFZFVW4jAiAyrptafXCUaj/ZNmsQbC2HBOBR+A4+lj4QBHMBIF12Fg==\n-----END CERTIFICATE-----\n" +} + +fn pem_dupleaf() -> string { + return "-----BEGIN CERTIFICATE-----\nMIIBjzCCATagAwIBAgIIBfIZvvOzptswCgYIKoZIzj0EAwIwKDEmMCQGA1UEAwwd\nQ2hhaW4gVGVzdCBJQ0Ega2V5VXNhZ2UgdHdpY2UwIBcNMjYwMTAxMDAwMDAwWhgP\nMjEyNjAxMDEwMDAwMDBaMBYxFDASBgNVBAMMC2R1cC5leGFtcGxlMFkwEwYHKoZI\nzj0CAQYIKoZIzj0DAQcDQgAEtjH30mRqXUaLX/icnlq+Lk1GiwpdgDUfap4xXB9F\ni6gBYeNoerA3MCEIF19aRoSZnVE4qPAOtBQrf/OSHJ9FNaNaMFgwFgYDVR0RBA8w\nDYILZHVwLmV4YW1wbGUwHQYDVR0OBBYEFDz3WYZJCyJ0vwrxvEHm2L5fZICVMB8G\nA1UdIwQYMBaAFJGb63B6lK3q+7nKLZJzFeqfUkWOMAoGCCqGSM49BAMCA0cAMEQC\nIHNhw7oJ3GsgUhVY4r3nggQLeeMyDYEQhlK/QaIuxV0tAiBX0kaeJfJjqEz2ZOgU\nS3r/t9F+AUZ64YmZoZGXXe306A==\n-----END CERTIFICATE-----\n" +} + +fn pem_icabool() -> string { + return "-----BEGIN CERTIFICATE-----\nMIIBmTCCAT6gAwIBAgIITjwusgCIv5MwCgYIKoZIzj0EAwIwGjEYMBYGA1UEAwwP\nQ2hhaW4gVGVzdCBSb290MCAXDTI2MDEwMTAwMDAwMFoYDzIxMjYwMTAxMDAwMDAw\nWjAjMSEwHwYDVQQDDBhDaGFpbiBUZXN0IElDQSBCT09MRUFOIDEwWTATBgcqhkjO\nPQIBBggqhkjOPQMBBwNCAATiFmHI1dRDyOir+yn/zhMmV08qta3TGnA2I6pNZR9w\nnw8Wqed/puC0kd2IJef+1byUW0TphIsXgfOf20uoi7ugo2MwYTAPBgNVHRMBAf8E\nBTADAQEBMA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUO4XEa2MDXrOqpxtOnVJK\ngvVi4kswHwYDVR0jBBgwFoAUkxMUuxXX9urrj1w+az33GyEVI20wCgYIKoZIzj0E\nAwIDSQAwRgIhALR+aXDa6odCiEyU39KVgIFKkDpLBTfRVa9ASjCy8DZfAiEAmobL\ng+pfmj4a5mkMw4SJ4HP6TE8EM4437hfe8Nk/xOY=\n-----END CERTIFICATE-----\n" +} + +fn pem_boolleaf() -> string { + return "-----BEGIN CERTIFICATE-----\nMIIBjTCCATOgAwIBAgIIDYbiZVxTmLkwCgYIKoZIzj0EAwIwIzEhMB8GA1UEAwwY\nQ2hhaW4gVGVzdCBJQ0EgQk9PTEVBTiAxMCAXDTI2MDEwMTAwMDAwMFoYDzIxMjYw\nMTAxMDAwMDAwWjAXMRUwEwYDVQQDDAxib29sLmV4YW1wbGUwWTATBgcqhkjOPQIB\nBggqhkjOPQMBBwNCAARbukahZpYzVbOJ/fHGKxHrf5pfYtK7kBK6CLlcQ68z4V4j\n6tTOsTryGLxy2slT9pWPfLvYMAQk+u3XaRy9SUMpo1swWTAXBgNVHREEEDAOggxi\nb29sLmV4YW1wbGUwHQYDVR0OBBYEFAwlaF0muvQu5hTVQDpYmrIFLUV+MB8GA1Ud\nIwQYMBaAFDuFxGtjA16zqqcbTp1SSoL1YuJLMAoGCCqGSM49BAMCA0gAMEUCIQD+\nHAOsUKQkdgNdbOuuVHr3E3vkzcRU4r5DMxv/d7BSpgIgP6Rudmihu4J4MS3EEH3B\nMnfvaNNMRGh94QYxRnK49kA=\n-----END CERTIFICATE-----\n" +} + +fn certs(pem: string) -> ptr { + l = list_new() + tls13_cert.trust_store_add_pem(l, pem) + return l +} + +// chain_verify_path(leaf, intermediates, [root]): "" or why not. +fn path_result(leaf_pem: string, inter_pem: string) -> string { + anchors = certs(pem_root()) + inters = certs(inter_pem) + leaves = certs(leaf_pem) + r = "the leaf fixture does not parse" + if list_size(leaves) == 1 { + r = tls13_cert.chain_verify_path(list.at(leaves, 0) as *LeafCert, inters, anchors) + } + tls13_cert.free_anchors(anchors) + tls13_cert.free_anchors(inters) + tls13_cert.free_anchors(leaves) + return r +} + +// validate_chain(leaf, intermediates, [root], at, purpose, -1). +fn chain_result(leaf_pem: string, inter_pem: string, at: string, purpose: int) -> string { + anchors = certs(pem_root()) + inters = certs(inter_pem) + leaves = certs(leaf_pem) + r = "the leaf fixture does not parse" + if list_size(leaves) == 1 { + r = tls13_cert.validate_chain(list.at(leaves, 0) as *LeafCert, inters, anchors, at, purpose, -1) + } + tls13_cert.free_anchors(anchors) + tls13_cert.free_anchors(inters) + tls13_cert.free_anchors(leaves) + return r +} + +// validate_chain(leaf, intermediates, [root], now, PURPOSE_SERVER, max_depth). +fn depth_result(leaf_pem: string, inter_pem: string, max_depth: int) -> string { + anchors = certs(pem_root()) + inters = certs(inter_pem) + leaves = certs(leaf_pem) + r = "the leaf fixture does not parse" + if list_size(leaves) == 1 { + r = tls13_cert.validate_chain(list.at(leaves, 0) as *LeafCert, inters, anchors, "", tls13_cert.PURPOSE_SERVER, max_depth) + } + tls13_cert.free_anchors(anchors) + tls13_cert.free_anchors(inters) + tls13_cert.free_anchors(leaves) + return r +} + +// validate_chain(leaf, intermediates, [anchor], 2027, PURPOSE_SERVER, -1). +fn anchor_result(anchor_pem: string, leaf_pem: string, inter_pem: string) -> string { + anchors = certs(anchor_pem) + inters = certs(inter_pem) + leaves = certs(leaf_pem) + r = "the leaf fixture does not parse" + if list_size(leaves) == 1 { + r = tls13_cert.validate_chain(list.at(leaves, 0) as *LeafCert, inters, anchors, "20270101000000", tls13_cert.PURPOSE_SERVER, -1) + } + tls13_cert.free_anchors(anchors) + tls13_cert.free_anchors(inters) + tls13_cert.free_anchors(leaves) + return r +} + +// 0 when `got` is what the case wants, else 1 (and says why). +fn check(label: string, got: string, want_ok: int) -> int { + if want_ok == 1 { + if string.equals(got, "") == 1 { + println("ok ${label}") + return 0 + } + println("FAIL ${label}: refused (${got})") + return 1 + } + if string.equals(got, "") != 1 { + println("ok ${label}: refused (${got})") + return 0 + } + println("FAIL ${label}: accepted") + return 1 +} + +main() { + at = "20270101000000" + server = tls13_cert.PURPOSE_SERVER + fails = 0 + + // The #3303 shape, through the call the client made. + fails = fails + check("leaf <- ica <- root", path_result(pem_leaf(), pem_ica()), 1) + fails = fails + check("an end entity of the root, as a leaf", path_result(pem_ee(), ""), 1) + fails = fails + check("a certificate signed by an end entity (cA FALSE)", path_result(pem_forged(), pem_ee()), 0) + fails = fails + check("a certificate signed by an end entity without basicConstraints", path_result(pem_forged2(), pem_eenobc()), 0) + // keyUsage keyCertSign does not make a CA: without basicConstraints cA, + // an intermediate still issues nothing, now that keyCertSign outside a + // CA is no longer refused on its own (it is not, by OpenSSL). + fails = fails + check("an intermediate with keyCertSign and no basicConstraints", path_result(pem_kunocaleaf(), pem_kunoca()), 0) + fails = fails + check("an end entity offered beside the real intermediate", path_result(pem_leaf(), "${pem_ee()}${pem_ica()}"), 1) + + // What #3340's decoders read leniently (chain_verify_path accepted each + // issuer before): a keyUsage that is not a BIT STRING (it counted as + // absent), a repeated extension (the second keyUsage, with + // keyCertSign, won), and a cA BOOLEAN of 0x01, which DER does not + // allow. Each makes the certificate malformed, as OpenSSL refuses the + // first two. + fails = fails + check("an issuer whose keyUsage is not a BIT STRING", path_result(pem_badkuleaf(), pem_icabadku()), 0) + fails = fails + check("an issuer with keyUsage twice", path_result(pem_dupleaf(), pem_icadup()), 0) + fails = fails + check("an issuer whose cA is the BOOLEAN 0x01", path_result(pem_boolleaf(), pem_icabool()), 0) + + // What else an issuer and the path must satisfy. + fails = fails + check("validate_chain: leaf <- ica <- root", chain_result(pem_leaf(), pem_ica(), at, server), 1) + fails = fails + check("validate_chain: signed by an end entity", chain_result(pem_forged(), pem_ee(), at, server), 0) + fails = fails + check("a clientAuth-only leaf for a server", chain_result(pem_clientonly(), pem_ica(), at, server), 0) + fails = fails + check("a clientAuth-only leaf for a client", chain_result(pem_clientonly(), pem_ica(), at, tls13_cert.PURPOSE_CLIENT), 1) + fails = fails + check("a CA below a pathLenConstraint 0 CA", chain_result(pem_subleaf(), "${pem_subca()}${pem_ica0()}", at, server), 0) + fails = fails + check("an issuer without keyCertSign", chain_result(pem_nksleaf(), pem_icanoks(), at, server), 0) + fails = fails + check("a name inside the permitted subtree", chain_result(pem_ncin(), pem_icanc(), at, server), 1) + fails = fails + check("a name outside the permitted subtree", chain_result(pem_ncout(), pem_icanc(), at, server), 0) + fails = fails + check("an unknown critical extension", chain_result(pem_crit(), pem_ica(), at, server), 0) + fails = fails + check("before notBefore", chain_result(pem_leaf(), pem_ica(), "20251231235959", server), 0) + fails = fails + check("after notAfter", chain_result(pem_leaf(), pem_ica(), "21260101000001", server), 0) + + // What x509-limbo showed missing. + fails = fails + check("a 1024-bit RSA key", chain_result(pem_weak(), pem_ica(), at, server), 0) + fails = fails + check("explicit curve parameters", chain_result(pem_explicit(), pem_ica(), at, server), 0) + fails = fails + check("*.example.com under excluded bar.example.com (CVE-2025-61727)", chain_result(pem_wild(), pem_icaex(), at, server), 0) + fails = fails + check("*.other.com under excluded bar.example.com", chain_result(pem_wildok(), pem_icaex(), at, server), 1) + fails = fails + check("max_depth 0 with one intermediate", depth_result(pem_leaf(), pem_ica(), 0), 0) + fails = fails + check("max_depth 1 with one intermediate", depth_result(pem_leaf(), pem_ica(), 1), 1) + + // A CA certificate (keyUsage keyCertSign and cRLSign, no + // digitalSignature) as the leaf: TLS signs with the leaf's key, so it is + // refused for either purpose, as OpenSSL refuses it (#3309). + fails = fails + check("a CA certificate as the leaf, for a server", chain_result(pem_ica(), "", at, server), 0) + fails = fails + check("a CA certificate as the leaf, for a client", chain_result(pem_ica(), "", at, tls13_cert.PURPOSE_CLIENT), 0) + fails = fails + check("a leaf with keyUsage digitalSignature", chain_result(pem_leaf(), pem_ica(), at, server), 1) + + // Only a self-signed root ends a path, as on OpenSSL (#3310): not a + // trusted intermediate, nor a self-issued root whose + // authorityKeyIdentifier names another key, issuer or serial. + fails = fails + check("a trusted intermediate as the anchor", anchor_result(pem_ica(), pem_leaf(), ""), 0) + fails = fails + check("a root whose AKI keyIdentifier is not its SKI", anchor_result(pem_akikeyid(), pem_akikeyidleaf(), ""), 0) + fails = fails + check("a root whose AKI names another issuer", anchor_result(pem_akiissuer(), pem_akiissuerleaf(), ""), 0) + fails = fails + check("a root whose AKI names another serial", anchor_result(pem_akiserial(), pem_akiserialleaf(), ""), 0) + fails = fails + check("a root whose AKI names another key, issuer and serial", anchor_result(pem_akiall(), pem_akiallleaf(), ""), 0) + fails = fails + check("a root whose AKI names its own serial", anchor_result(pem_akiself(), pem_akiselfleaf(), ""), 1) + fails = fails + check("the same leaf under its root", anchor_result(pem_root(), pem_leaf(), pem_ica()), 1) + + // A pinned self-signed leaf with basicConstraints cA FALSE: trusted as + // it stands (it has no issuer), as OpenSSL trusts it. + fails = fails + check("a pinned self-signed leaf (cA FALSE)", anchor_result(pem_pinned(), pem_pinned(), ""), 1) + + if fails > 0 { + println("tls13_chain: ${fails} FAILURES") + exit(1) + } + println("tls13_chain: all checks pass") +} diff --git a/tests/integration/crypto_tls13_der_length/test_tls13_der_length.ae b/tests/integration/crypto_tls13_der_length/test_tls13_der_length.ae new file mode 100644 index 000000000..508ecd170 --- /dev/null +++ b/tests/integration/crypto_tls13_der_length/test_tls13_der_length.ae @@ -0,0 +1,69 @@ +// DER lengths of four bytes in a stapled OCSP response (#3308). +// +// tls13_cert's DER reader (der_tlv, under parse_ocsp_status and the OCSP +// signature check) took up to 127 length bytes into a 32-bit int. A length +// of four bytes from 0x80000000 up overflowed into a negative one, which +// passed the "fits in the buffer" bound, and a length near 0x7FFFFFFF made +// that bound's own addition wrap; either way the caller's next offset +// pointed before the buffer, and the next read walked off it. The server +// controls the staple, so a malicious server could stop the client. Each +// response below is well formed around one bad length, inside the +// responseType OID, and must be refused as OCSP_ERROR. Before the fix the +// first panicked the program ("slice index -2147483633 out of range"). +// +// 30 13 0A 01 00 A0 0E 30 0C 06 2B 06 01 04 01 00 +// +// with 84 80 00 00 00 (0x80000000), 84 7F FF FF FF (0x7FFFFFFF), and, +// as the control that was always refused, 83 01 00 00: a length past the +// end of the buffer. + +import std.cryptography.tls13_cert +import std.bytes +import std.string + +extern exit(code: int) +extern string_char_at(str: string, index: int) -> int + +fn hexval(h: int) -> int { + if h >= 48 && h <= 57 { return h - 48 } + if h >= 97 && h <= 102 { return h - 87 } + return h - 55 +} + +fn from_hex(s: string) -> ptr { + n = string.length(s) / 2 + b = bytes.new(n) + i = 0 + while i < n { + hi = hexval(string_char_at(s, 2 * i)) + lo = hexval(string_char_at(s, 2 * i + 1)) + bytes.set(b, i, hi * 16 + lo) + i = i + 1 + } + return b +} + +// 0 when the response is refused as OCSP_ERROR, else 1. +fn refused(label: string, hex: string) -> int { + der = from_hex(hex) + st = tls13_cert.parse_ocsp_status(bytes.view(der)[0..bytes.length(der)]) + bytes.free(der) + if st == tls13_cert.OCSP_ERROR { + println("ok ${label}: refused") + return 0 + } + println("FAIL ${label}: status ${st}, want OCSP_ERROR") + return 1 +} + +main() { + fails = 0 + fails = fails + refused("length 84 80 00 00 00", "30130A0100A00E300C0684800000002B0601040100") + fails = fails + refused("length 84 7F FF FF FF", "30130A0100A00E300C06847FFFFFFF2B0601040100") + fails = fails + refused("length 83 01 00 00, past the buffer", "30120A0100A00D300B06830100002B0601040100") + if fails > 0 { + println("tls13_der_length: ${fails} FAILURES") + exit(1) + } + println("tls13_der_length: all checks pass") +} diff --git a/tests/integration/crypto_tls13_hs/test_tls13_hs.ae b/tests/integration/crypto_tls13_hs/test_tls13_hs.ae index 62ad199ec..3d7ee69f8 100644 --- a/tests/integration/crypto_tls13_hs/test_tls13_hs.ae +++ b/tests/integration/crypto_tls13_hs/test_tls13_hs.ae @@ -40,6 +40,22 @@ fn g24(b: ptr, off: int) -> int { return ((bytes.get(b, off) & 0xFF) << 16) | ((bytes.get(b, off + 1) & 0xFF) << 8) | (bytes.get(b, off + 2) & 0xFF) } +// The offset and length of extension `t`'s body in the ClientHello message +// `ch`, or (-1, 0) when it has none. +fn ch_ext(ch: ptr, t: int) -> (int, int) { + p = 4 + 2 + 32 + p = p + 1 + g8(ch, p) + p = p + 2 + g16(ch, p) + p = p + 1 + g8(ch, p) + end = p + 2 + g16(ch, p) + p = p + 2 + while p + 4 <= end { + if g16(ch, p) == t { return p + 4, g16(ch, p + 2) } + p = p + 4 + g16(ch, p + 2) + } + return -1, 0 +} + fn put16(b: ptr, off: int, v: int) -> int { bytes.set(b, off, (v >> 8) & 0xFF) bytes.set(b, off + 1, v & 0xFF) @@ -182,10 +198,11 @@ main() { } // --- HelloRetryRequest detection (RFC 8446 §4.1.3 special random) --- - // A minimal HRR: version 0303 + the SHA-256("HelloRetryRequest") random + + // A minimal HRR: version 0303 + the SHA-256("HelloRetryRequest") random + // (RFC 8446 4.1.3, from the RFC) + // empty sid + cipher 1301 + comp 00 + exts{ supported_versions, key_share= // secp256r1(0x0017) }. - hrr = from_hex("020000340303cf21ad74e59a6111be1dfa3a06b44e8c845c7630048b15d5a00d203a15d5b26e00130100000c002b00020304003300020017") + hrr = from_hex("020000340303cf21ad74e59a6111be1d8c021e65b891c2a211167abb8c5e079e09e2c8a8339c00130100000c002b00020304003300020017") hp = tls13_hs.ShParsed { version: 0, cipher_suite: 0, group: 0, key_share: null, key_share_len: 0, is_hello_retry: 0, psk_accepted: 0 } herr = tls13_hs.parse_server_hello(bytes.view(hrr), &hp) if string.equals(herr, "") == 1 { @@ -198,6 +215,24 @@ main() { if hp.key_share != null { bytes.free(hp.key_share) } bytes.free(hrr) + // The HelloRetryRequest random is RFC 8446 4.1.3's, copied here from + // the RFC: the module had a mistyped constant (#3325), and this test + // the same one, so a real HelloRetryRequest read as a ServerHello. + rfc_hrr = from_hex("cf21ad74e59a6111be1d8c021e65b891c2a211167abb8c5e079e09e2c8a8339c") + hsid = bytes.new(1) + bytes.set(hsid, 0, 7) + built = tls13_hs.hello_retry_request(bytes.view(hsid)[0..1], 0x1301, tls13_hs.GROUP_X25519) + rsame = 1 + ri = 0 + while ri < 32 { + if (bytes.get(built, 6 + ri) & 0xFF) != (bytes.get(rfc_hrr, ri) & 0xFF) { rsame = 0 } + ri = ri + 1 + } + if rsame == 1 { + println("ok hello_retry_request carries RFC 8446's HelloRetryRequest random") + } else { println("FAIL hello_retry_request's random is not RFC 8446's"); total_ok = 0 } + bytes.free(rfc_hrr); bytes.free(hsid); bytes.free(built) + // --- dual-keyshare ClientHello offers both x25519 and secp256r1 --- // A secp256r1 65-byte uncompressed point placeholder (0x04 || 64 bytes). p256pt = bytes.new(65) @@ -210,6 +245,13 @@ main() { if g8(dual, 0) == 1 { println("ok dual-keyshare CH builds (msg_type = client_hello, ${bytes.length(dual)} bytes)") } else { println("FAIL dual CH msg_type ${g8(dual, 0)}"); total_ok = 0 } + // A full ClientHello offers psk_key_exchange_modes { psk_dhe_ke }: Go + // and BoringSSL servers send no ticket to a client that offers no mode + // (RFC 8446 4.2.9, #3363). + mo, ml = ch_ext(dual, 45) + if mo >= 0 && ml == 2 && g8(dual, mo) == 1 && g8(dual, mo + 1) == 1 { + println("ok a full ClientHello offers psk_key_exchange_modes { psk_dhe_ke }") + } else { println("FAIL a full ClientHello offers no psk_key_exchange_modes { psk_dhe_ke } (#3363)"); total_ok = 0 } bytes.free(p256pt) bytes.free(dual) diff --git a/tests/integration/crypto_tls13_mtls_signature/mtls.ae b/tests/integration/crypto_tls13_mtls_signature/mtls.ae new file mode 100644 index 000000000..f12941e27 --- /dev/null +++ b/tests/integration/crypto_tls13_mtls_signature/mtls.ae @@ -0,0 +1,74 @@ +// The pure TLS client with a client certificate (connect_mtls), for +// test_tls13_mtls_signature.sh: CLIENT_CERT and CLIENT_KEY name its PEM +// files (an ECDSA P-256 pair), PORT the server's port on localhost. It sends +// "GET / HTTP/1.0" and prints "GOT " for the answer, or +// "REFUSED: " when the handshake fails, or "RECVERR: " when the +// server ends the connection instead of answering. + +import std.os +import std.fs +import std.bytes +import std.list +import std.string +import std.cryptography +import std.cryptography.tls13_client +import std.cryptography.tls13_server + +extern exit(code: int) +extern atoi(s: string) -> int + +main() { + cert_pem, cerr = fs.read(os.getenv("CLIENT_CERT")) + key_pem, kerr = fs.read(os.getenv("CLIENT_KEY")) + if cerr != "" || kerr != "" { + println("FAIL: read the pair: ${cerr}${kerr}") + exit(1) + } + chain, _lens = tls13_server.parse_cert_chain_pem(cert_pem) + if chain == null || list.size(chain) < 1 { + println("FAIL: the client certificate does not parse") + exit(1) + } + cert = list.at(chain, 0) as ptr + // The P-256 scalar connect_mtls signs with. + scalar = tls13_server.parse_ec_private_key_pem(key_pem) + if scalar == null || bytes.length(scalar) != 32 { + println("FAIL: the client key is not a P-256 key") + exit(1) + } + xpriv, rerr = cryptography.random_buffer(32) + if rerr != "" { + println("FAIL: ${rerr}") + exit(1) + } + conn, err = tls13_client.connect_mtls("localhost", atoi(os.getenv("PORT")), xpriv, cert, bytes.length(cert), scalar) + bytes.free(xpriv) + bytes.wipe_free(scalar) + if err != "" { + println("REFUSED: ${err}") + exit(0) + } + req = "GET / HTTP/1.0\r\n\r\n" + serr = tls13_client.conn_send(conn, string.bytes(req)) + if serr != "" { + println("RECVERR: send: ${serr}") + tls13_client.close_conn(conn) + exit(0) + } + buf, n, rerr2 = tls13_client.conn_recv(conn) + if rerr2 != "" { + println("RECVERR: ${rerr2}") + } else if buf == null || n == 0 { + println("RECVERR: closed with no answer") + } else { + text = bytes.to_string(buf, n) + line = text + nl = string.index_of(text, "\r\n") + if nl >= 0 { + line = string.substring(text, 0, nl) + } + println("GOT ${line}") + bytes.free(buf) + } + tls13_client.close_conn(conn) +} diff --git a/tests/integration/crypto_tls13_mtls_signature/test_tls13_mtls_signature.sh b/tests/integration/crypto_tls13_mtls_signature/test_tls13_mtls_signature.sh new file mode 100644 index 000000000..d3db5b5d4 --- /dev/null +++ b/tests/integration/crypto_tls13_mtls_signature/test_tls13_mtls_signature.sh @@ -0,0 +1,111 @@ +#!/bin/sh +# The pure TLS client checks its own CertificateVerify signature against +# its certificate's key before sending it (#3376), and its nonce is hedged. +# +# A client with a certificate (tls13_client.connect_mtls, mtls.ae) against +# `openssl s_server -Verify 1`, which requires a client certificate and +# checks its signature. One root signs the server's certificate and the +# client's; each side trusts the root. +# +# 1. The client's certificate with its own key: the server takes the +# signature and answers the request, twice (a hedged nonce is fresh +# each time; both signatures verify). +# 2. The certificate with another P-256 key, standing in for a signature +# gone wrong: the client refuses to send it and says so, and the +# server gets internal_error, not a signature it rejects. Before the +# check the client sent it, the server refused it with decrypt_error, +# and the client learnt that only from its first read. +# +# Skipped without openssl. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +. "$ROOT/tests/lib/server_jobs.sh" +AE="$ROOT/build/ae" +NAME=crypto_tls13_mtls_signature + +[ -x "$AE" ] || { echo " [SKIP] $NAME: ae not built"; exit 0; } +command -v openssl >/dev/null 2>&1 || { echo " [SKIP] $NAME: no openssl"; exit 0; } +unset SSL_CERT_DIR AETHER_PURE_TLS || : + +TMP="$(mktemp -d)" +. "$ROOT/tests/lib/test_openssl_conf.sh" +test_openssl_conf "$TMP" +PIDS="" +cleanup() { + for p in $PIDS; do stop_server "$p"; done + rm -rf "$TMP" || : + return 0 +} +trap cleanup EXIT +fail() { echo " [FAIL] $NAME: $1"; exit 1; } +ssl() { "$@" >"$TMP/ssl.log" 2>&1 || { sed -n '1,10p' "$TMP/ssl.log"; fail "openssl: $*"; }; } + +printf 'basicConstraints=CA:FALSE\nkeyUsage=critical,digitalSignature\nsubjectAltName=DNS:localhost\n' >"$TMP/srv.ext" +printf 'basicConstraints=CA:FALSE\nkeyUsage=critical,digitalSignature\nextendedKeyUsage=clientAuth\n' >"$TMP/cli.ext" +for k in root srv cli other; do + ssl openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out "$TMP/$k.key" +done +MSYS2_ARG_CONV_EXCL='/CN=' ssl openssl req -x509 -new -key "$TMP/root.key" -out "$TMP/root.pem" -days 2 \ + -subj "/CN=mTLS Test Root" -addext "basicConstraints=critical,CA:TRUE" -addext "keyUsage=critical,keyCertSign,cRLSign" +for k in srv cli; do + MSYS2_ARG_CONV_EXCL='/CN=' ssl openssl req -new -key "$TMP/$k.key" -out "$TMP/$k.csr" -subj "/CN=$k" + ssl openssl x509 -req -in "$TMP/$k.csr" -CA "$TMP/root.pem" -CAkey "$TMP/root.key" -CAcreateserial \ + -days 2 -extfile "$TMP/$k.ext" -out "$TMP/$k.pem" +done + +"$AE" build "$SCRIPT_DIR/mtls.ae" -o "$TMP/mtls" >"$TMP/b_mtls.log" 2>&1 \ + || { sed -n '1,12p' "$TMP/b_mtls.log"; fail "mtls.ae did not build"; } + +# One s_server connection; the client's line lands in $got, the server's +# log in $TMP/s.log. +fails=0 +serve() { + SP="" + for try in 1 2 3 4 5; do + P=$(( 20000 + ($$ * 7 + try * 1009) % 40000 )) + openssl s_server -4 -www -accept "$P" -tls1_3 -naccept 1 -Verify 1 -CAfile "$TMP/root.pem" \ + -cert "$TMP/srv.pem" -key "$TMP/srv.key" >"$TMP/s.log" 2>&1 /dev/null; do sleep 0.1; w=$((w + 1)); done + if server_alive "$spid" && grep -q ACCEPT "$TMP/s.log" 2>/dev/null; then + SP=$P + break + fi + kill_server "$spid" + done + [ -n "$SP" ] || fail "could not start openssl s_server" + got=$(SSL_CERT_FILE="$TMP/root.pem" CLIENT_CERT="$TMP/cli.pem" CLIENT_KEY="$1" PORT="$SP" "$TMP/mtls" 2>&1 \ + | grep -v 'warning' || :) + n=0 + while server_alive "$spid" && [ "$n" -lt 50 ]; do sleep 0.1; n=$((n + 1)); done + stop_server "$spid" +} + +for round in 1 2; do + serve "$TMP/cli.key" + case "$got" in + *"GOT HTTP/1.0 200 ok"*) echo " ok: its own key, round $round: the server took the signature and answered" ;; + *) echo " FAIL: its own key, round $round: client [$got], server [$(grep -i 'error\|alert\|verify' "$TMP/s.log" | tr '\n' '|')]"; fails=$((fails + 1)) ;; + esac +done + +serve "$TMP/other.key" +case "$got" in + *"REFUSED: "*"not sent"*) + if grep -qi "internal error\|alert number 80" "$TMP/s.log"; then + echo " ok: another key: the client did not send the signature, and the server got internal_error" + else + echo " FAIL: another key: the server's log has no internal_error: [$(grep -i 'error\|alert' "$TMP/s.log" | tr '\n' '|')]" + fails=$((fails + 1)) + fi + ;; + *) echo " FAIL: another key: client [$got], server [$(grep -i 'error\|alert\|verify' "$TMP/s.log" | tr '\n' '|')]"; fails=$((fails + 1)) ;; +esac + +[ "$fails" = 0 ] || fail "$fails case(s)" +echo " [PASS] $NAME" diff --git a/tests/integration/crypto_tls13_peer/client.ae b/tests/integration/crypto_tls13_peer/client.ae new file mode 100644 index 000000000..cb41586ba --- /dev/null +++ b/tests/integration/crypto_tls13_peer/client.ae @@ -0,0 +1,52 @@ +// The pure TLS client against peer.ae: connect_socket to 127.0.0.1 +// PEER_PORT for "peer.example", trusting CAFILE. Prints "OK" after a +// verified handshake, else "ERR: ". With CLIENT_READ=1 it then reads +// once and prints "RECV ", "RECV-EOF" or "RECVERR: "; with +// CLIENT_TICKET=1 it first waits for a session ticket and prints "TICKET" +// or "TICKETERR: "; with CLIENT_ECHO=1 it sends back what it read. + +import std.os +import std.tcp +import std.bytes +import std.string +import std.cryptography.tls13_client + +main() { + port, _perr = string.to_int(os.getenv("PEER_PORT")) + sock, derr = tcp.connect("127.0.0.1", port) + if derr != "" { + println("ERR: connect: ${derr}") + return + } + conn, herr = tls13_client.connect_socket(sock, "peer.example", os.getenv("CAFILE")) + if herr != "" { + println("ERR: ${herr}") + return + } + println("OK") + if os.getenv("CLIENT_TICKET") == "1" { + t, terr = tls13_client.conn_get_ticket(conn) + if terr != "" { + println("TICKETERR: ${terr}") + } else { + println("TICKET early=${tls13_client.ticket_max_early_data(t)} len=${tls13_client.ticket_len(t)}") + tls13_client.free_ticket(t) + } + } + if os.getenv("CLIENT_READ") == "1" { + buf, n, rerr = tls13_client.conn_recv(conn) + if rerr != "" { + println("RECVERR: ${rerr}") + } else if buf == null { + println("RECV-EOF") + } else { + println("RECV ${n}") + if os.getenv("CLIENT_ECHO") == "1" { + serr = tls13_client.conn_send(conn, bytes.view(buf)[0..n]) + if serr != "" { println("SENDERR: ${serr}") } + } + bytes.free(buf) + } + } + tls13_client.close_conn(conn) +} diff --git a/tests/integration/crypto_tls13_peer/kuclient.ae b/tests/integration/crypto_tls13_peer/kuclient.ae new file mode 100644 index 000000000..b6637a5ba --- /dev/null +++ b/tests/integration/crypto_tls13_peer/kuclient.ae @@ -0,0 +1,37 @@ +// The pure TLS client updating its own keys (#3320): connect_socket to +// 127.0.0.1 PEER_PORT for "peer.example", trusting CAFILE, then +// conn_key_update(CLIENT_KU), then two bytes of data under the new keys. +// Prints "OK", "KUERR: " or "ERR: ". + +import std.os +import std.tcp +import std.bytes +import std.string +import std.cryptography.tls13_client + +main() { + port, _perr = string.to_int(os.getenv("PEER_PORT")) + sock, derr = tcp.connect("127.0.0.1", port) + if derr != "" { + println("ERR: connect: ${derr}") + return + } + conn, herr = tls13_client.connect_socket(sock, "peer.example", os.getenv("CAFILE")) + if herr != "" { + println("ERR: ${herr}") + return + } + req, _rerr = string.to_int(os.getenv("CLIENT_KU")) + kerr = tls13_client.conn_key_update(conn, req) + if kerr != "" { + println("KUERR: ${kerr}") + } else { + hi = bytes.new(2) + bytes.set(hi, 0, 104) + bytes.set(hi, 1, 105) + serr = tls13_client.conn_send(conn, bytes.view(hi)[0..2]) + bytes.free(hi) + if serr != "" { println("SENDERR: ${serr}") } else { println("OK") } + } + tls13_client.close_conn(conn) +} diff --git a/tests/integration/crypto_tls13_peer/kuserver.ae b/tests/integration/crypto_tls13_peer/kuserver.ae new file mode 100644 index 000000000..696b0df47 --- /dev/null +++ b/tests/integration/crypto_tls13_peer/kuserver.ae @@ -0,0 +1,59 @@ +// The pure TLS server updating its own keys (#3320): as server.ae, then +// conn_key_update(SERVER_KU) and two bytes of data under the new keys. + +import std.os +import std.fs +import std.tcp +import std.bytes +import std.cryptography +import std.string +import std.cryptography.tls13_server + +extern exit(code: int) + +main() { + srv, lerr = tcp.listen_on("127.0.0.1", 0) + if lerr != "" { + println("FAIL: ${lerr}") + exit(1) + } + _pw = fs.write(os.getenv("PEER_PORT_OUT"), "${tcp.server_port(srv)}") + sock, aerr = tcp.accept(srv) + if aerr != "" { + println("FAIL: ${aerr}") + exit(1) + } + chain, lens = tls13_server.parse_cert_chain_pem(peer_pem()) + key, _kerr = tls13_server.load_private_key(peer_key()) + xpriv, _rerr = cryptography.random_buffer(32) + conn, err = tls13_server.accept(sock, chain, lens, key, xpriv) + bytes.free(xpriv) + tls13_server.free_private_key(key) + if err != "" { + println("REFUSED: ${err}") + tcp.close(sock) + exit(0) + } + println("ACCEPTED") + req, _qerr = string.to_int(os.getenv("SERVER_KU")) + kerr = tls13_server.conn_key_update(conn, req) + if kerr != "" { + println("KUERR: ${kerr}") + } else { + hi = bytes.new(2) + bytes.set(hi, 0, 104) + bytes.set(hi, 1, 105) + serr = tls13_server.conn_send(conn, bytes.view(hi)[0..2]) + bytes.free(hi) + if serr != "" { println("SENDERR: ${serr}") } + } + tls13_server.close_conn(conn) + exit(0) +} +fn peer_pem() -> string { + return "-----BEGIN CERTIFICATE-----\nMIIBezCCASGgAwIBAgIFUEVFUgEwCgYIKoZIzj0EAwIwFzEVMBMGA1UEAwwMcGVl\nci5leGFtcGxlMCAXDTI2MDEwMTAwMDAwMFoYDzIxMjYwMTAxMDAwMDAwWjAXMRUw\nEwYDVQQDDAxwZWVyLmV4YW1wbGUwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARv\n7S9eWT57ZTmAOztQD2+5EetBRQ+B+cOtLJCr7BVBQPQExOwo/jrT1ahb6i7ZxObZ\nFrq4KWW801ggO2EZ0SqXo1gwVjAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIH\ngDAXBgNVHREEEDAOggxwZWVyLmV4YW1wbGUwHQYDVR0OBBYEFH8HRengm5cRK0K4\na6DbxP8afdpfMAoGCCqGSM49BAMCA0gAMEUCIDCIjkFoXJK1RI1twupNRu+WcP5M\nrU1MCAQ84vwQWP4tAiEArDbfJRioR7QSSuE4iKxVCCFaHbJs/7eLq3CNJGUFXoM=\n-----END CERTIFICATE-----\n" +} + +fn peer_key() -> string { + return "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgJkV1/PXrot1pb8nd\ndzrI+0SX8Cx63OCppeTS/FARImOhRANCAARv7S9eWT57ZTmAOztQD2+5EetBRQ+B\n+cOtLJCr7BVBQPQExOwo/jrT1ahb6i7ZxObZFrq4KWW801ggO2EZ0SqX\n-----END PRIVATE KEY-----\n" +} diff --git a/tests/integration/crypto_tls13_peer/peer.ae b/tests/integration/crypto_tls13_peer/peer.ae new file mode 100644 index 000000000..7cac01dd0 --- /dev/null +++ b/tests/integration/crypto_tls13_peer/peer.ae @@ -0,0 +1,1317 @@ +// A scripted TLS 1.3 server for the pure client's protocol checks: it runs +// a real handshake (any suite and group the client offers, ECDSA P-256 +// certificate for peer.example) and changes exactly what PEER_CASE says, so +// a test can send the client the bytes a broken or hostile server would. +// test_tls13_peer.sh runs it against client.ae, case by case. +// +// PEER_CASE is a list of knob=value pairs separated by ";" (values in hex +// where they are bytes): +// sh_ext=HEX raw extensions appended to the ServerHello's +// ee_ext=HEX raw extensions appended to EncryptedExtensions' +// ee_body=HEX the whole EncryptedExtensions body instead ("-": empty) +// cert_ext=HEX the leaf CertificateEntry's extensions +// ccs=1|2|bad change_cipher_spec records after the ServerHello +// pre_ccs=1|2|bad the same before the ServerHello +// empty_hs=1 an empty protected handshake record before EE +// plain_ee=1 EncryptedExtensions in a plaintext record +// flight_alerts=HEX alert records (level, description pairs) before +// EncryptedExtensions +// fin_trailing=1 bytes after the Finished, in its record +// split_sh=N the ServerHello in records of at most N bytes +// split_flight=N each protected handshake message in records of at +// most N bytes +// hrr=MODE a HelloRetryRequest first (see hrr_msg); after +// "group:0018" the ServerHello is for secp384r1, with the +// client's new share +// sh_group=x25519 the ServerHello names x25519 whatever was agreed +// sh_version=HEX the ServerHello's legacy_version +// sh_sid=bad a ServerHello session id echo one bit off +// sh_comp=1 a ServerHello with compression method 1 +// pack_sh=1 handshake bytes after the ServerHello in its record +// sh_tail=N N bytes of application_data records behind the +// ServerHello, in the same write, and the answer read +// 200 ms later +// extra=HEX a raw handshake message right after EncryptedExtensions +// cr=HEX a CertificateRequest with this body after it +// key=ed25519 an Ed25519 certificate and CertificateVerify (0x0807) +// cv_scheme=HEX the CertificateVerify names this scheme instead +// cv_bad=1 a CertificateVerify signature with one bit flipped +// cv_trailing=1 one byte after the CertificateVerify's signature +// skip_cv=1 no CertificateVerify +// fin_long=1 a Finished one byte longer than the hash +// fin_type=HEX the Finished sent as this message type +// post=WHAT after the handshake, under the application keys: +// oversize, padded, bigcipher, emptyN, ccs, badtype, +// innertype, badmac, emptyhs, and post_session's (kuN, +// nst:HEX, alerts:HEX, postcr, stream:N); then prints +// "POST " +// Writes its port to PEER_PORT_OUT and its certificate to PEER_CERT_OUT, serves one +// connection, and prints what the client answered to its flight: +// "ALERT " (plaintext or under the client's handshake keys), +// "FINISHED" (the client accepted it), or "CLOSED". + +import std.os +import std.fs +import std.tcp +import std.bytes +import std.string +import std.list +import std.cryptography +import std.cryptography.x25519 +import std.cryptography.p384 +import std.cryptography.mlkem +import std.cryptography.tls13_hs +import std.cryptography.tls13_ks +import std.cryptography.tls13_kdf +import std.cryptography.tls13_record +import std.cryptography.tls13_client +import std.cryptography.tls13_server +import std.cryptography.tls13_cert +import std.cryptography.aes +import std.cryptography.chacha20poly1305 + +extern exit(code: int) +extern string_char_at(str: string, index: int) -> int + +// ---- knobs ---- + +fn knob(name: string) -> string { + spec = os.getenv("PEER_CASE") + if spec == "" { return "" } + parts = string.split(spec, ";") + n = string.array_size(parts) + out = "" + i = 0 + while i < n { + p = string.array_get(parts, i) + pre = "${name}=" + if string.starts_with(p, pre) == 1 { + out = string.substring(p, string.length(pre), string.length(p)) + } + i = i + 1 + } + string.array_free(parts) + return out +} + +fn hexval(h: int) -> int { + if h >= 48 && h <= 57 { return h - 48 } + if h >= 97 && h <= 102 { return h - 87 } + return h - 55 +} + +fn from_hex(s: string) -> ptr { + n = string.length(s) / 2 + b = bytes.new(n) + i = 0 + while i < n { + bytes.set(b, i, hexval(string_char_at(s, 2 * i)) * 16 + hexval(string_char_at(s, 2 * i + 1))) + i = i + 1 + } + return b +} + +// ---- bytes ---- + +struct Buf { + b: ptr + n: int +} + +fn buf_new() -> *Buf { + h = malloc(sizeof(Buf)) as *Buf + h.b = bytes.new(64) + h.n = 0 + return h +} + +fn put8(w: *Buf, v: int) { + bytes.set(w.b, w.n, v & 0xFF) + w.n = w.n + 1 +} + +fn put16(w: *Buf, v: int) { + put8(w, v >> 8) + put8(w, v) +} + +fn put24(w: *Buf, v: int) { + put8(w, v >> 16) + put8(w, v >> 8) + put8(w, v) +} + +fn put_bytes(w: *Buf, src: ptr, off: int, n: int) { + if n > 0 { bytes.copy_from_bytes(w.b, w.n, src, off, n) } + w.n = w.n + n +} + +// The buffer as fresh bytes of exactly its length; frees the Buf. +fn buf_take(w: *Buf) -> ptr { + out = bytes.new(w.n) + if w.n > 0 { bytes.copy_from_bytes(out, 0, w.b, 0, w.n) } + bytes.free(w.b) + free(w as ptr) + return out +} + +extern malloc(size: int) -> ptr +extern free(p: ptr) + +// ---- records ---- + +struct Reader { + sock: ptr + buf: ptr + len: int + off: int +} + +// The next record (header included) as fresh bytes, or null at EOF. +fn next_record(r: *Reader) -> ptr { + while 1 == 1 { + avail = r.len - r.off + if avail >= 5 { + plen = ((bytes.get(r.buf, r.off + 3) & 0xFF) << 8) | (bytes.get(r.buf, r.off + 4) & 0xFF) + if avail >= 5 + plen { + rec = bytes.new(5 + plen) + bytes.copy_from_bytes(rec, 0, r.buf, r.off, 5 + plen) + r.off = r.off + 5 + plen + return rec + } + } + chunk = bytes.new(16384) + bytes.set(chunk, 16383, 0) + got, err = tcp.read_into(r.sock, bytes.view(chunk)[0..16384]) + if err != "" || got <= 0 { + bytes.free(chunk) + return null + } + bytes.copy_from_bytes(r.buf, r.len, chunk, 0, got) + r.len = r.len + got + bytes.free(chunk) + } +} + +fn send_raw(sock: ptr, rec: ptr) { + _n, _e = tcp.write_all(sock, bytes.view(rec)[0..bytes.length(rec)]) +} + +fn send_plain(sock: ptr, ct: int, msg: ptr) { + w = buf_new() + put8(w, ct) + put16(w, 0x0303) + put16(w, bytes.length(msg)) + put_bytes(w, msg, 0, bytes.length(msg)) + rec = buf_take(w) + send_raw(sock, rec) + bytes.free(rec) +} + +fn send_sealed(sock: ptr, ctx: ptr, ct: int, msg: ptr) { + sfk = knob("split_flight") + if ct == 22 && sfk != "" { + // Each handshake message in records of at most N bytes (RFC 8446 + // 5.1, #3366). + step, _se = string.to_int(sfk) + mlen = bytes.length(msg) + off = 0 + while off < mlen { + m = mlen - off + if m > step { m = step } + part = tls13_record.seal_record(ctx, ct, bytes.view(msg)[off..off + m]) + send_raw(sock, part) + bytes.free(part) + off = off + m + } + return + } + rec = tls13_record.seal_record(ctx, ct, bytes.view(msg)[0..bytes.length(msg)]) + send_raw(sock, rec) + bytes.free(rec) +} + +// ---- handshake messages ---- + +fn hs_msg(typ: int, body: ptr) -> ptr { + w = buf_new() + put8(w, typ) + put24(w, bytes.length(body)) + put_bytes(w, body, 0, bytes.length(body)) + return buf_take(w) +} + +fn server_hello(random: ptr, sid: ptr, sid_len: int, suite: int, group: int, ks: ptr, ks_len: int) -> ptr { + exts = buf_new() + put16(exts, 0x002b) + put16(exts, 2) + put16(exts, 0x0304) + put16(exts, 0x0033) + put16(exts, 4 + ks_len) + put16(exts, group) + put16(exts, ks_len) + put_bytes(exts, ks, 0, ks_len) + extra = knob("sh_ext") + if extra != "" { + x = from_hex(extra) + put_bytes(exts, x, 0, bytes.length(x)) + bytes.free(x) + } + e = buf_take(exts) + w = buf_new() + // sh_version=HEX: another legacy_version (#3318). + ver = 0x0303 + vk = knob("sh_version") + if vk != "" { + vb = from_hex(vk) + ver = ((bytes.get(vb, 0) & 0xFF) << 8) | (bytes.get(vb, 1) & 0xFF) + bytes.free(vb) + } + put16(w, ver) + put_bytes(w, random, 0, 32) + put8(w, sid_len) + put_bytes(w, sid, 0, sid_len) + // sh_sid=bad: a session id echo that is not the client's (#3318). + if knob("sh_sid") == "bad" && sid_len > 0 { bytes.set(w.b, w.n - 1, (bytes.get(w.b, w.n - 1) ^ 1) & 0xFF) } + put16(w, suite) + // sh_comp=1: compression method 1 (#3364). + if knob("sh_comp") != "" { put8(w, 1) } else { put8(w, 0) } + put16(w, bytes.length(e)) + put_bytes(w, e, 0, bytes.length(e)) + bytes.free(e) + body = buf_take(w) + m = hs_msg(2, body) + bytes.free(body) + return m +} + +fn encrypted_extensions() -> ptr { + raw = knob("ee_body") + if raw == "-" { + empty = bytes.new(0) + em = hs_msg(8, empty) + bytes.free(empty) + return em + } + if raw != "" { + b = from_hex(raw) + m = hs_msg(8, b) + bytes.free(b) + return m + } + exts = buf_new() + extra = knob("ee_ext") + if extra != "" { + x = from_hex(extra) + put_bytes(exts, x, 0, bytes.length(x)) + bytes.free(x) + } + e = buf_take(exts) + w = buf_new() + put16(w, bytes.length(e)) + put_bytes(w, e, 0, bytes.length(e)) + bytes.free(e) + body = buf_take(w) + m = hs_msg(8, body) + bytes.free(body) + return m +} + +// Certificate { context<1> = empty, certificate_list<3> = one entry }. +fn certificate(der: ptr) -> ptr { + ext = bytes.new(0) + hexe = knob("cert_ext") + if hexe != "" { + bytes.free(ext) + ext = from_hex(hexe) + } + dl = bytes.length(der) + el = bytes.length(ext) + w = buf_new() + put8(w, 0) + put24(w, 3 + dl + 2 + el) + put24(w, dl) + put_bytes(w, der, 0, dl) + put16(w, el) + put_bytes(w, ext, 0, el) + bytes.free(ext) + body = buf_take(w) + m = hs_msg(11, body) + bytes.free(body) + return m +} + +// A protected record whose TLSInnerPlaintext is `inner` exactly (content, +// type byte, padding), sealed by hand so it may break the limits +// seal_record keeps. +fn send_inner(sock: ptr, ctx: ptr, inner: ptr) { + c = ctx as *RecordCtx + il = bytes.length(inner) + w = buf_new() + put8(w, 23) + put16(w, 0x0303) + put16(w, il + 16) + aad = buf_take(w) + nonce = tls13_record.nonce_for(c.iv, c.seq) + enc = null + if c.aead == tls13_record.AEAD_CHACHA20_POLY1305 { + enc = chacha20poly1305.aead_seal(c.key, nonce, bytes.view(aad)[0..5], bytes.view(inner)[0..il]) + } else { + enc = aes.gcm_seal(c.aes_ctx, nonce, bytes.view(aad)[0..5], bytes.view(inner)[0..il]) + } + c.seq = c.seq + 1 + w2 = buf_new() + put_bytes(w2, aad, 0, 5) + put_bytes(w2, enc, 0, bytes.length(enc)) + rec = buf_take(w2) + send_raw(sock, rec) + bytes.free(rec) + bytes.free(enc) + bytes.free(nonce) + bytes.free(aad) +} + +// TLSInnerPlaintext of `n` bytes of 0x41, type `ct`, `pad` zeros. +fn inner_of(n: int, ct: int, pad: int) -> ptr { + w = buf_new() + k = 0 + while k < n { + put8(w, 0x41) + k = k + 1 + } + put8(w, ct) + k = 0 + while k < pad { + put8(w, 0) + k = k + 1 + } + return buf_take(w) +} + +// What the peer sends once the handshake is done, by the "post" knob. +fn post_handshake(sock: ptr, app: ptr, what: string, r: *Reader, capp: ptr) { + if what == "oversize" { + i = inner_of(16385, 23, 0) + send_inner(sock, app, i) + bytes.free(i) + } else if what == "padded" { + i = inner_of(8193, 23, 8192) + send_inner(sock, app, i) + bytes.free(i) + } else if what == "bigcipher" { + w = buf_new() + put8(w, 23) + put16(w, 0x0303) + put16(w, 16641) + k = 0 + while k < 16641 { + put8(w, 0x5a) + k = k + 1 + } + rec = buf_take(w) + send_raw(sock, rec) + bytes.free(rec) + // The client reads the header, refuses the record and closes with + // the rest unread: read only after its close, the alert is lost to + // the reset that close sends unless the client drained the socket + // first, whatever the timing. + sleep(100) + } else if string.starts_with(what, "empty") == 1 && what != "emptyhs" { + count, _cerr = string.to_int(string.substring(what, 5, string.length(what))) + k = 0 + while k < count { + i = inner_of(0, 23, 0) + send_inner(sock, app, i) + answered_early(r, capp, "POST ") + bytes.free(i) + k = k + 1 + } + i2 = inner_of(2, 23, 0) + send_inner(sock, app, i2) + bytes.free(i2) + } else if what == "ccs" { + c = from_hex("01") + send_plain(sock, 20, c) + bytes.free(c) + } else if what == "badtype" { + c = from_hex("0000") + send_plain(sock, 24, c) + bytes.free(c) + } else if what == "innertype" { + i = inner_of(2, 24, 0) + send_inner(sock, app, i) + bytes.free(i) + } else if what == "badmac" { + c = from_hex("6869") + rec = tls13_record.seal_record(app, 23, bytes.view(c)[0..2]) + bytes.set(rec, 6, (bytes.get(rec, 6) ^ 1) & 0xFF) + send_raw(sock, rec) + bytes.free(rec) + bytes.free(c) + } else if what == "emptyhs" { + i = inner_of(0, 22, 0) + send_inner(sock, app, i) + bytes.free(i) + } +} + +// (aead, key length, hash, hash length) of a TLS 1.3 suite. +fn suite_params(cs: int) -> (int, int, string, int) { + if cs == tls13_hs.TLS_AES_256_GCM_SHA384 { return tls13_record.AEAD_AES_256_GCM, 32, "sha384", 48 } + if cs == tls13_hs.TLS_CHACHA20_POLY1305_SHA256 { return tls13_record.AEAD_CHACHA20_POLY1305, 32, "sha256", 32 } + return tls13_record.AEAD_AES_128_GCM, 16, "sha256", 32 +} + +// What the client sent back, read under `cli_ctx`: "ALERT n", "FINISHED", +// "DATA" or "CLOSED". +fn client_answer(r: *Reader, cli_ctx: ptr) -> string { + // "CCS+" before the answer when a change_cipher_spec came first. + pre = "" + while 1 == 1 { + rec = next_record(r) + if rec == null { return "${pre}CLOSED" } + ct = bytes.get(rec, 0) & 0xFF + if ct == 0x14 { + bytes.free(rec) + pre = "CCS+" + } else if ct == 0x15 { + d = bytes.get(rec, 6) & 0xFF + bytes.free(rec) + return "${pre}ALERT ${d}" + } else { + if cli_ctx == null { + bytes.free(rec) + return "${pre}ENCRYPTED" + } + opened = tls13_record.OpenedRecord { content_type: 0, plaintext: null, plaintext_len: 0 } + oerr = tls13_record.open_record(cli_ctx, bytes.view(rec)[0..bytes.length(rec)], &opened) + bytes.free(rec) + if oerr != "" { return "${pre}UNREADABLE" } + res = "DATA" + if opened.content_type == tls13_record.CONTENT_ALERT && opened.plaintext_len >= 2 { + res = "ALERT ${bytes.get(opened.plaintext, 1) & 0xFF}" + } else if opened.content_type == tls13_record.CONTENT_HANDSHAKE && opened.plaintext_len >= 1 { + if (bytes.get(opened.plaintext, 0) & 0xFF) == 20 { res = "FINISHED" } else { res = "HANDSHAKE" } + } + if opened.plaintext != null { bytes.free(opened.plaintext) } + return "${pre}${res}" + } + } +} + +// When the client has already answered (it refused something mid-flight), +// print that answer after `prefix` and stop: sending on would make the +// client's close a reset, and a reset can drop its alert. +fn answered_early(r: *Reader, ctx: ptr, prefix: string) { + ready, _perr = tcp.poll(r.sock, 60) + if ready { + println("${prefix}${client_answer(r, ctx)}") + tcp.close(r.sock) + exit(0) + } +} + +main() { + srv, lerr = tcp.listen_on("127.0.0.1", 0) + if lerr != "" { + println("FAIL: ${lerr}") + exit(1) + } + out = os.getenv("PEER_CERT_OUT") + if out != "" { _w = fs.write(out, peer_pem()) } + // The port goes to a file: a pipe would hold a printed line until exit. + _pw = fs.write(os.getenv("PEER_PORT_OUT"), "${tcp.server_port(srv)}") + sock, aerr = tcp.accept(srv) + if aerr != "" { + println("FAIL: ${aerr}") + exit(1) + } + r = Reader { sock: sock, buf: bytes.new(4096), len: 0, off: 0 } + + chrec = next_record(&r) + if chrec == null { + println("FAIL: no ClientHello") + exit(1) + } + chlen = bytes.length(chrec) - 5 + ch = bytes.new(chlen) + bytes.copy_from_bytes(ch, 0, chrec, 5, chlen) + bytes.free(chrec) + chp = tls13_hs.ChParsed { random: null, session_id: null, session_id_len: 0, cipher_suite: 0, group: 0, key_share: null, key_share_len: 0, has_sni: 0, saw_supported_versions: 0 } + perr = tls13_hs.parse_client_hello(bytes.view(ch)[0..chlen], &chp) + if perr != "" { + println("FAIL: ClientHello: ${perr}") + exit(1) + } + tr = tls13_client.transcript_new() + tls13_client.transcript_add(tr, bytes.view(ch)[0..chlen]) + + // hrr=...: a HelloRetryRequest first (#3325), and the second + // ClientHello in its place. + // The client's secp384r1 share, when a HelloRetryRequest asked for one. + p384_share = null + hrrk = knob("hrr") + if hrrk != "" { + _ha, _hk, halgo, hhl = suite_params(chp.cipher_suite) + hrr = hrr_msg(chp.session_id, chp.session_id_len, chp.cipher_suite, hrrk) + send_plain(sock, 22, hrr) + // Transcript: message_hash(ClientHello1), then the HelloRetryRequest + // (RFC 8446 4.4.1), computed here apart from tls13_client. + th1 = tls13_client.transcript_hash_algo(tr, halgo) + tls13_client.transcript_free(tr) + tr = tls13_client.transcript_new() + mh = buf_new() + put8(mh, 254) + put24(mh, hhl) + put_bytes(mh, th1, 0, hhl) + mhb = buf_take(mh) + tls13_client.transcript_add(tr, bytes.view(mhb)[0..bytes.length(mhb)]) + tls13_client.transcript_add(tr, bytes.view(hrr)[0..bytes.length(hrr)]) + bytes.free(mhb) + bytes.free(th1) + bytes.free(hrr) + // The client's answer: an alert, or its change_cipher_spec and the + // second ClientHello, in a record of version 0x0303 (5.1). + rec2 = next_record(&r) + if rec2 != null && (bytes.get(rec2, 0) & 0xFF) == 0x14 { + bytes.free(rec2) + rec2 = next_record(&r) + } + if rec2 == null { + println("CLOSED") + exit(0) + } + if (bytes.get(rec2, 0) & 0xFF) == 0x15 { + println("ALERT ${bytes.get(rec2, 6) & 0xFF}") + exit(0) + } + if (bytes.get(rec2, 0) & 0xFF) != 0x16 || (bytes.get(rec2, 2) & 0xFF) != 3 { + println("FAIL: the second ClientHello is not a handshake record of version 0x0303") + exit(0) + } + ch2l = bytes.length(rec2) - 5 + ch2 = bytes.new(ch2l) + bytes.copy_from_bytes(ch2, 0, rec2, 5, ch2l) + bytes.free(rec2) + if string.contains(hrrk, "cookie") == 1 && has_cookie(ch2, HRR_COOKIE) == 0 { + println("FAIL: the second ClientHello does not echo the cookie") + exit(0) + } + tls13_hs.free_ch(&chp) + if string.contains(hrrk, "group:0018") == 1 { p384_share = find_share(ch2, 0x0018) } + perr2 = tls13_hs.parse_client_hello(bytes.view(ch2)[0..ch2l], &chp) + if perr2 != "" { + println("FAIL: second ClientHello: ${perr2}") + exit(1) + } + tls13_client.transcript_add(tr, bytes.view(ch2)[0..ch2l]) + bytes.free(ch2) + if string.contains(hrrk, "twice") == 1 { + // A second HelloRetryRequest: unexpected_message (4.1.4). + hrr2 = hrr_msg(chp.session_id, chp.session_id_len, chp.cipher_suite, "cookie") + send_plain(sock, 22, hrr2) + bytes.free(hrr2) + println(client_answer(&r, null)) + exit(0) + } + if string.contains(hrrk, "suite") == 1 { + // A ServerHello with another suite than the HelloRetryRequest's. + if chp.cipher_suite == tls13_hs.TLS_CHACHA20_POLY1305_SHA256 { chp.cipher_suite = tls13_hs.TLS_AES_128_GCM_SHA256 } else { chp.cipher_suite = tls13_hs.TLS_CHACHA20_POLY1305_SHA256 } + } + } + + // The shared secret and our key share for the group the client chose. + xpriv, rerr = cryptography.random_buffer(32) + if rerr != "" { + println("FAIL: ${rerr}") + exit(1) + } + xpub = x25519.base_point(xpriv) + shared = null + ks = null + if chp.group == tls13_hs.GROUP_X25519MLKEM768 { + ek = bytes.new(1184) + bytes.copy_from_bytes(ek, 0, chp.key_share, 0, 1184) + cx = bytes.new(32) + bytes.copy_from_bytes(cx, 0, chp.key_share, 1184, 32) + ct, mss, _merr = mlkem.mlkem768_encaps(ek) + xss, _xerr = x25519.agree(xpriv, cx) + ks = bytes.new(1120) + bytes.copy_from_bytes(ks, 0, ct, 0, 1088) + bytes.copy_from_bytes(ks, 1088, xpub, 0, 32) + shared = bytes.new(64) + bytes.copy_from_bytes(shared, 0, mss, 0, 32) + bytes.copy_from_bytes(shared, 32, xss, 0, 32) + bytes.free(ek); bytes.free(cx); bytes.free(ct); bytes.free(mss); bytes.free(xss) + } else if p384_share != null { + // secp384r1, which the HelloRetryRequest asked for (#3369). + shared, ks = p384_exchange(p384_share) + bytes.free(p384_share) + if shared == null { + println("FAIL: the client's secp384r1 share is not a point") + exit(1) + } + chp.group = 0x0018 + } else { + xss2, _xerr2 = x25519.agree(xpriv, chp.key_share) + shared = xss2 + ks = bytes.new(32) + bytes.copy_from_bytes(ks, 0, xpub, 0, 32) + } + if knob("sh_group") == "x25519" { + // A ServerHello naming x25519, with a share of it the client never + // asked for (#3369). + bytes.free(ks) + ks = bytes.new(32) + bytes.copy_from_bytes(ks, 0, xpub, 0, 32) + chp.group = tls13_hs.GROUP_X25519 + } + aead, klen, algo, hl = suite_params(chp.cipher_suite) + + srnd, _sr = cryptography.random_buffer(32) + sh = server_hello(srnd, chp.session_id, chp.session_id_len, chp.cipher_suite, chp.group, ks, bytes.length(ks)) + pck = knob("pre_ccs") + if pck != "" { + // change_cipher_spec records before the ServerHello: "1" the one + // compatibility mode allows, "2" two, "bad" one of value 2 (#3379). + pc = from_hex("01") + if pck == "bad" { bytes.set(pc, 0, 2) } + send_plain(sock, 20, pc) + if pck == "2" { send_plain(sock, 20, pc) } + bytes.free(pc) + } + splitk = knob("split_sh") + if splitk != "" { + // The ServerHello in records of at most N bytes each (RFC 8446 5.1). + step, _se = string.to_int(splitk) + off = 0 + while off < bytes.length(sh) { + m = bytes.length(sh) - off + if m > step { m = step } + frag = bytes.new(m) + bytes.copy_from_bytes(frag, 0, sh, off, m) + send_plain(sock, 22, frag) + bytes.free(frag) + off = off + m + } + } else if knob("pack_sh") != "" { + // More handshake bytes after the ServerHello in its record. + w = buf_new() + put_bytes(w, sh, 0, bytes.length(sh)) + put8(w, 8) + put24(w, 2) + put16(w, 0) + packed = buf_take(w) + send_plain(sock, 22, packed) + bytes.free(packed) + } else if knob("sh_tail") != "" { + // The ServerHello and N bytes of application_data records behind + // it, in one write: more than the client reads at once, so they are + // still unread when it refuses the ServerHello (#3368). + tn, _te = string.to_int(knob("sh_tail")) + w = buf_new() + put8(w, 22) + put16(w, 0x0303) + put16(w, bytes.length(sh)) + put_bytes(w, sh, 0, bytes.length(sh)) + sent = 0 + filler = bytes.new(4096) + bytes.set(filler, 4095, 0) + while sent < tn { + put8(w, 23) + put16(w, 0x0303) + put16(w, 4096) + put_bytes(w, filler, 0, 4096) + sent = sent + 4096 + } + bytes.free(filler) + flight = buf_take(w) + send_raw(sock, flight) + bytes.free(flight) + } else { + send_plain(sock, 22, sh) + } + tls13_client.transcript_add(tr, bytes.view(sh)[0..bytes.length(sh)]) + + // A client that refuses the ServerHello answers now, in the clear. + // With sh_tail, read it 200 ms on, as a server still busy would: a + // reset the client sent by then has dropped its alert unread, on + // Windows (#3368). + if knob("sh_tail") != "" { sleep(200) } + ready, _perr = tcp.poll(sock, 300) + if ready { + println(client_answer(&r, null)) + tcp.close(sock) + exit(0) + } + + th = tls13_client.transcript_hash_algo(tr, algo) + early = tls13_ks.early_secret(algo) + hsec = tls13_ks.handshake_secret(algo, early, bytes.view(shared)[0..bytes.length(shared)]) + c_hs = tls13_ks.traffic_secret(algo, hsec, "c hs traffic", bytes.view(th)[0..hl]) + s_hs = tls13_ks.traffic_secret(algo, hsec, "s hs traffic", bytes.view(th)[0..hl]) + sk = tls13_ks.write_key(algo, bytes.view(s_hs)[0..hl], klen) + siv = tls13_ks.write_iv(algo, s_hs) + ck = tls13_ks.write_key(algo, bytes.view(c_hs)[0..hl], klen) + civ = tls13_ks.write_iv(algo, c_hs) + srv_ctx = tls13_record.new_aead(aead, bytes.view(sk)[0..klen], siv) + cli_ctx = tls13_record.new_aead(aead, bytes.view(ck)[0..klen], civ) + + ccsk = knob("ccs") + if ccsk != "" { + // Middlebox-compatibility change_cipher_spec records: "1" one, as + // allowed; "2" two; "bad" one of value 2 (RFC 8446 5). + one = from_hex("01") + if ccsk == "bad" { + bytes.set(one, 0, 2) + } + send_plain(sock, 20, one) + if ccsk == "2" { send_plain(sock, 20, one) } + answered_early(&r, cli_ctx, "") + bytes.free(one) + } + if knob("empty_hs") != "" { + i0 = inner_of(0, 22, 0) + send_inner(sock, srv_ctx, i0) + answered_early(&r, cli_ctx, "") + bytes.free(i0) + } + fak = knob("flight_alerts") + if fak != "" { + // Alert records (level, description pairs) before + // EncryptedExtensions, under the handshake keys (#3323). + fal = from_hex(fak) + fi = 0 + while fi + 1 < bytes.length(fal) { + one = bytes.new(2) + bytes.set(one, 0, bytes.get(fal, fi)) + bytes.set(one, 1, bytes.get(fal, fi + 1)) + send_sealed(sock, srv_ctx, 21, one) + bytes.free(one) + fi = fi + 2 + } + bytes.free(fal) + answered_early(&r, cli_ctx, "") + } + ee = encrypted_extensions() + if knob("plain_ee") != "" { + send_plain(sock, 22, ee) + } else { + send_sealed(sock, srv_ctx, 22, ee) + } + tls13_client.transcript_add(tr, bytes.view(ee)[0..bytes.length(ee)]) + answered_early(&r, cli_ctx, "") + + // Messages the flight should not hold, or a CertificateRequest, sent as + // they are after EncryptedExtensions. + extra = knob("extra") + if extra != "" { + xm = from_hex(extra) + send_sealed(sock, srv_ctx, 22, xm) + tls13_client.transcript_add(tr, bytes.view(xm)[0..bytes.length(xm)]) + answered_early(&r, cli_ctx, "") + bytes.free(xm) + } + crk = knob("cr") + if crk != "" { + crb = from_hex(crk) + crm = hs_msg(13, crb) + send_sealed(sock, srv_ctx, 22, crm) + tls13_client.transcript_add(tr, bytes.view(crm)[0..bytes.length(crm)]) + answered_early(&r, cli_ctx, "") + bytes.free(crb) + bytes.free(crm) + } + + chain, _lens = tls13_server.parse_cert_chain_pem(peer_pem()) + der = list.at(chain, 0) as ptr + cert = certificate(der) + send_sealed(sock, srv_ctx, 22, cert) + answered_early(&r, cli_ctx, "") + tls13_client.transcript_add(tr, bytes.view(cert)[0..bytes.length(cert)]) + + if knob("skip_cv") == "" { + th_c = tls13_client.transcript_hash_algo(tr, algo) + sig = null + scheme = 0x0403 + if knob("key") == "ed25519" { + seed = from_hex(ED25519_SEED) + sig = tls13_cert.sign_server_cert_verify_ed25519(seed, bytes.view(th_c)[0..hl]) + bytes.free(seed) + scheme = 0x0807 + } else { + key = tls13_server.parse_ec_private_key_pem(peer_key()) + sig = tls13_cert.sign_server_cert_verify_ecdsa_p256(key, bytes.view(th_c)[0..hl]) + } + cvs = knob("cv_scheme") + if cvs != "" { + sb = from_hex(cvs) + scheme = ((bytes.get(sb, 0) & 0xFF) << 8) | (bytes.get(sb, 1) & 0xFF) + bytes.free(sb) + } + sl = bytes.length(sig) + if knob("cv_bad") != "" { + bytes.set(sig, sl - 1, (bytes.get(sig, sl - 1) ^ 1) & 0xFF) + } + // algorithm || signature<2>, and one byte more with cv_trailing. + w = buf_new() + put16(w, scheme) + put16(w, sl) + put_bytes(w, sig, 0, sl) + if knob("cv_trailing") != "" { put8(w, 0) } + cvb = buf_take(w) + cv = hs_msg(15, cvb) + bytes.free(cvb) + send_sealed(sock, srv_ctx, 22, cv) + answered_early(&r, cli_ctx, "") + tls13_client.transcript_add(tr, bytes.view(cv)[0..bytes.length(cv)]) + } + + th_v = tls13_client.transcript_hash_algo(tr, algo) + svd = tls13_client.finished_verify_data_algo(s_hs, bytes.view(th_v)[0..hl], algo, hl) + fin = tls13_hs.finished_msg(svd, hl) + if knob("fin_long") != "" { + // verify_data one byte longer than the hash. + w2 = buf_new() + put_bytes(w2, svd, 0, hl) + put8(w2, 0) + lv = buf_take(w2) + bytes.free(fin) + fin = hs_msg(20, lv) + bytes.free(lv) + } + ftk = knob("fin_type") + if ftk != "" { + // The Finished sent as another message type (#3367). + fb = from_hex(ftk) + bytes.set(fin, 0, bytes.get(fb, 0)) + bytes.free(fb) + } + if knob("fin_trailing") != "" { + // Bytes after the Finished, in its record: a handshake message may + // not span the key change that follows it (RFC 8446 5.1). + w = buf_new() + put_bytes(w, fin, 0, bytes.length(fin)) + put8(w, 4) + put24(w, 0) + both = buf_take(w) + send_sealed(sock, srv_ctx, 22, both) + bytes.free(both) + } else { + send_sealed(sock, srv_ctx, 22, fin) + } + tls13_client.transcript_add(tr, bytes.view(fin)[0..bytes.length(fin)]) + + answer = client_answer(&r, cli_ctx) + println(answer) + post = knob("post") + if post != "" && string.ends_with(answer, "FINISHED") == 1 { + th_sf = tls13_client.transcript_hash_algo(tr, algo) + ms = tls13_ks.master_secret(algo, hsec) + c_ap = tls13_ks.traffic_secret(algo, ms, "c ap traffic", bytes.view(th_sf)[0..hl]) + s_ap = tls13_ks.traffic_secret(algo, ms, "s ap traffic", bytes.view(th_sf)[0..hl]) + if is_session_post(post) == 1 { + k = Keys { algo: algo, hl: hl, aead: aead, klen: klen, s_sec: s_ap, c_sec: c_ap, s_ctx: ctx_of(algo, hl, aead, klen, s_ap), c_ctx: ctx_of(algo, hl, aead, klen, c_ap) } + post_session(sock, &r, &k, post) + tcp.close(sock) + exit(0) + } + ak = tls13_ks.write_key(algo, bytes.view(s_ap)[0..hl], klen) + aiv = tls13_ks.write_iv(algo, s_ap) + bk = tls13_ks.write_key(algo, bytes.view(c_ap)[0..hl], klen) + biv = tls13_ks.write_iv(algo, c_ap) + app = tls13_record.new_aead(aead, bytes.view(ak)[0..klen], aiv) + capp = tls13_record.new_aead(aead, bytes.view(bk)[0..klen], biv) + post_handshake(sock, app, post, &r, capp) + println("POST ${client_answer(&r, capp)}") + } + tcp.close(sock) + exit(0) +} + +// ---- after the handshake: KeyUpdate, NewSessionTicket, alerts ---- +// +// post=WHAT for these, under the application keys (the peer computes the +// KeyUpdate secrets itself, with tls13_kdf, apart from tls13_ks): +// kuN N KeyUpdates (update_not_requested), then 2 bytes of data +// (kuxN without them, for a count the client refuses) +// kureqN N KeyUpdates (update_requested), then data; the client +// echoes it (CLIENT_ECHO=1) after its own KeyUpdate +// kubad a KeyUpdate whose request_update is 2 +// kulong a KeyUpdate whose body is two bytes +// kutrail a KeyUpdate and the header of another message, one record +// kusplit a KeyUpdate over two records, then data +// nst:HEX a NewSessionTicket with this body, then data (nstx:HEX +// without the data, for one the client refuses) +// nstsplit:HEX the same over two records +// alerts:HEX alert records (level, description pairs), then data +// (alertsx:HEX without it) +// postcr a CertificateRequest +// listen nothing: what the client sends on its own (kuclient.ae) +// Then "POST ": each record it sent, read under its +// keys, which move on at each KeyUpdate: "KU", "DATA ", "ALERT ", +// "CLOSED". + +// A record context for traffic secret `s`. +fn ctx_of(algo: string, hl: int, aead: int, klen: int, s: ptr) -> ptr { + k = tls13_ks.write_key(algo, bytes.view(s)[0..hl], klen) + iv = tls13_ks.write_iv(algo, s) + ctx = tls13_record.new_aead(aead, bytes.view(k)[0..klen], iv) + bytes.free(k) + bytes.free(iv) + return ctx +} + +// The traffic secret after `s` (RFC 8446 7.2); `s` is freed. +fn next_secret(algo: string, hl: int, s: ptr) -> ptr { + empty = bytes.new(0) + n = tls13_kdf.expand_label(algo, bytes.view(s)[0..hl], "traffic upd", bytes.view(empty)[0..0], hl) + bytes.free(empty) + bytes.free(s) + return n +} + +struct Keys { + algo: string + hl: int + aead: int + klen: int + s_sec: ptr // the peer's sending secret + c_sec: ptr // the client's sending secret + s_ctx: ptr + c_ctx: ptr +} + +// The peer's sending keys move on. +fn peer_update(k: *Keys) { + k.s_sec = next_secret(k.algo, k.hl, k.s_sec) + tls13_record.free_ctx(k.s_ctx) + k.s_ctx = ctx_of(k.algo, k.hl, k.aead, k.klen, k.s_sec) +} + +// The client's sending keys move on. +fn client_update(k: *Keys) { + k.c_sec = next_secret(k.algo, k.hl, k.c_sec) + tls13_record.free_ctx(k.c_ctx) + k.c_ctx = ctx_of(k.algo, k.hl, k.aead, k.klen, k.c_sec) +} + +fn send_ku(sock: ptr, k: *Keys, request: int) { + w = buf_new() + put8(w, 24) + put24(w, 1) + put8(w, request) + m = buf_take(w) + send_sealed(sock, k.s_ctx, 22, m) + bytes.free(m) + peer_update(k) +} + +fn send_data2(sock: ptr, k: *Keys) { + d = from_hex("6869") + send_sealed(sock, k.s_ctx, 23, d) + bytes.free(d) +} + +// What the client sent from here on, record by record, until it closes or +// sends data or an alert: "KU" for each KeyUpdate (its keys then move on). +fn session_answer(r: *Reader, k: *Keys) -> string { + out = "" + while 1 == 1 { + rec = next_record(r) + if rec == null { return "${out}CLOSED" } + ct = bytes.get(rec, 0) & 0xFF + if ct != 0x17 { + bytes.free(rec) + return "${out}PLAIN ${ct}" + } + opened = tls13_record.OpenedRecord { content_type: 0, plaintext: null, plaintext_len: 0 } + oerr = tls13_record.open_record(k.c_ctx, bytes.view(rec)[0..bytes.length(rec)], &opened) + bytes.free(rec) + if oerr != "" { return "${out}UNREADABLE" } + t = opened.content_type + n = opened.plaintext_len + first = 0 + if n > 0 { first = bytes.get(opened.plaintext, 0) & 0xFF } + second = 0 + if n > 1 { second = bytes.get(opened.plaintext, 1) & 0xFF } + if opened.plaintext != null { bytes.free(opened.plaintext) } + if t == 22 && first == 24 { + out = "${out}KU " + client_update(k) + } else if t == 21 { + return "${out}ALERT ${second}" + } else if t == 23 { + return "${out}DATA ${n}" + } else { + return "${out}HANDSHAKE ${first}" + } + } +} + +fn post_session(sock: ptr, r: *Reader, k: *Keys, what: string) { + if string.starts_with(what, "kureq") == 1 { + count, _e = string.to_int(string.substring(what, 5, string.length(what))) + i = 0 + while i < count { + send_ku(sock, k, 1) + i = i + 1 + } + send_data2(sock, k) + } else if what == "kubad" { + bad = from_hex("1800000102") + send_sealed(sock, k.s_ctx, 22, bad) + bytes.free(bad) + } else if what == "kulong" { + bad = from_hex("180000020000") + send_sealed(sock, k.s_ctx, 22, bad) + bytes.free(bad) + } else if what == "kutrail" { + bad = from_hex("180000010004000000") + send_sealed(sock, k.s_ctx, 22, bad) + bytes.free(bad) + } else if what == "kusplit" { + a = from_hex("180000") + b = from_hex("0100") + send_sealed(sock, k.s_ctx, 22, a) + send_sealed(sock, k.s_ctx, 22, b) + bytes.free(a) + bytes.free(b) + peer_update(k) + send_data2(sock, k) + } else if string.starts_with(what, "ku") == 1 { + // kuN, then data; kuxN, none (the client refuses the last). + from = 2 + if string.starts_with(what, "kux") == 1 { from = 3 } + count, _e2 = string.to_int(string.substring(what, from, string.length(what))) + i = 0 + while i < count { + send_ku(sock, k, 0) + i = i + 1 + } + if from == 2 { send_data2(sock, k) } + } else if string.starts_with(what, "nst") == 1 { + // nst:HEX, nstsplit:HEX and nstx:HEX (no data after, for a ticket + // the client refuses: data it does not read would make its close a + // reset, and a reset can drop its alert). + at = string.index_of(what, ":") + 1 + body = from_hex(string.substring(what, at, string.length(what))) + m = hs_msg(4, body) + bytes.free(body) + if string.starts_with(what, "nstsplit:") == 1 { + h = bytes.length(m) / 2 + a = bytes.new(h) + bytes.copy_from_bytes(a, 0, m, 0, h) + b = bytes.new(bytes.length(m) - h) + bytes.copy_from_bytes(b, 0, m, h, bytes.length(m) - h) + send_sealed(sock, k.s_ctx, 22, a) + send_sealed(sock, k.s_ctx, 22, b) + bytes.free(a) + bytes.free(b) + } else { + send_sealed(sock, k.s_ctx, 22, m) + } + bytes.free(m) + if string.starts_with(what, "nstx:") != 1 { send_data2(sock, k) } + } else if string.starts_with(what, "alerts") == 1 { + // alerts:HEX, then data; alertsx:HEX, none. + at = string.index_of(what, ":") + 1 + al = from_hex(string.substring(what, at, string.length(what))) + i = 0 + while i + 1 < bytes.length(al) { + one = bytes.new(2) + bytes.set(one, 0, bytes.get(al, i)) + bytes.set(one, 1, bytes.get(al, i + 1)) + send_sealed(sock, k.s_ctx, 21, one) + bytes.free(one) + i = i + 2 + } + bytes.free(al) + if string.starts_with(what, "alertsx:") != 1 { send_data2(sock, k) } + } else if what == "postcr" { + cr = from_hex("0d00000e01aa000a000d000600040403") + send_sealed(sock, k.s_ctx, 22, cr) + bytes.free(cr) + } else if string.starts_with(what, "stream:") == 1 { + // stream:N: N bytes of application data and no ticket; main then + // closes the connection without waiting for an answer (#3356). + total, _te = string.to_int(string.substring(what, 7, string.length(what))) + chunk = bytes.new(16384) + bytes.set(chunk, 16383, 0) + sent = 0 + while sent < total { + send_sealed(sock, k.s_ctx, 23, chunk) + sent = sent + 16384 + } + bytes.free(chunk) + println("POST STREAMED") + return + } + println("POST ${session_answer(r, k)}") +} + +// 1 when post=WHAT is one post_session drives. +fn is_session_post(what: string) -> int { + if string.starts_with(what, "ku") == 1 { return 1 } + if string.starts_with(what, "nst") == 1 { return 1 } + if string.starts_with(what, "alerts") == 1 { return 1 } + if string.starts_with(what, "stream:") == 1 { return 1 } + if what == "postcr" || what == "listen" { return 1 } + return 0 +} + +// ---- HelloRetryRequest (hrr=MODE, #3325) ---- + +const HRR_RANDOM = "cf21ad74e59a6111be1d8c021e65b891c2a211167abb8c5e079e09e2c8a8339c" +const HRR_COOKIE = "c00c1e0f" + +// A HelloRetryRequest for MODE: "cookie" (HRR_COOKIE), "group:HEX" (a +// key_share naming that group), "none" (neither), "emptycookie" (a cookie +// of no bytes); "twice" and "suite" add to "cookie" (see main), "comp" +// gives it compression method 1. +fn hrr_msg(sid: ptr, sid_len: int, suite: int, mode: string) -> ptr { + exts = buf_new() + put16(exts, 0x002b) + put16(exts, 2) + put16(exts, 0x0304) + if string.starts_with(mode, "group:") == 1 { + g = from_hex(string.substring(mode, 6, string.length(mode))) + put16(exts, 0x0033) + put16(exts, 2) + put_bytes(exts, g, 0, 2) + bytes.free(g) + } + if string.contains(mode, "cookie") == 1 && mode != "emptycookie" { + c = from_hex(HRR_COOKIE) + put16(exts, 0x002c) + put16(exts, 2 + bytes.length(c)) + put16(exts, bytes.length(c)) + put_bytes(exts, c, 0, bytes.length(c)) + bytes.free(c) + } + if mode == "emptycookie" { + put16(exts, 0x002c) + put16(exts, 2) + put16(exts, 0) + } + e = buf_take(exts) + w = buf_new() + put16(w, 0x0303) + rnd = from_hex(HRR_RANDOM) + put_bytes(w, rnd, 0, 32) + bytes.free(rnd) + put8(w, sid_len) + put_bytes(w, sid, 0, sid_len) + put16(w, suite) + // "comp" in the mode: compression method 1 (#3364). + if string.contains(mode, "comp") == 1 { put8(w, 1) } else { put8(w, 0) } + put16(w, bytes.length(e)) + put_bytes(w, e, 0, bytes.length(e)) + bytes.free(e) + body = buf_take(w) + m = hs_msg(2, body) + bytes.free(body) + return m +} + +// 1 when the ClientHello `ch` carries a cookie extension holding `hex`. +fn has_cookie(ch: ptr, hex: string) -> int { + want = from_hex(hex) + wl = bytes.length(want) + n = bytes.length(ch) + p = 4 + 2 + 32 + p = p + 1 + (bytes.get(ch, p) & 0xFF) + p = p + 2 + (((bytes.get(ch, p) & 0xFF) << 8) | (bytes.get(ch, p + 1) & 0xFF)) + p = p + 1 + (bytes.get(ch, p) & 0xFF) + p = p + 2 + found = 0 + while p + 4 <= n { + t = ((bytes.get(ch, p) & 0xFF) << 8) | (bytes.get(ch, p + 1) & 0xFF) + el = ((bytes.get(ch, p + 2) & 0xFF) << 8) | (bytes.get(ch, p + 3) & 0xFF) + if t == 44 && el == 2 + wl { + same = 1 + i = 0 + while i < wl { + if (bytes.get(ch, p + 6 + i) & 0xFF) != (bytes.get(want, i) & 0xFF) { same = 0 } + i = i + 1 + } + if same == 1 { found = 1 } + } + p = p + 4 + el + } + bytes.free(want) + return found +} + +// The key of the ClientHello `ch`'s share for `group` (fresh bytes), or +// null when key_share holds none for it. +fn find_share(ch: ptr, group: int) -> ptr { + n = bytes.length(ch) + p = 4 + 2 + 32 + p = p + 1 + (bytes.get(ch, p) & 0xFF) + p = p + 2 + (((bytes.get(ch, p) & 0xFF) << 8) | (bytes.get(ch, p + 1) & 0xFF)) + p = p + 1 + (bytes.get(ch, p) & 0xFF) + p = p + 2 + while p + 4 <= n { + t = ((bytes.get(ch, p) & 0xFF) << 8) | (bytes.get(ch, p + 1) & 0xFF) + el = ((bytes.get(ch, p + 2) & 0xFF) << 8) | (bytes.get(ch, p + 3) & 0xFF) + if t == 51 { + q = p + 6 + while q + 4 <= p + 4 + el { + g = ((bytes.get(ch, q) & 0xFF) << 8) | (bytes.get(ch, q + 1) & 0xFF) + kl = ((bytes.get(ch, q + 2) & 0xFF) << 8) | (bytes.get(ch, q + 3) & 0xFF) + if g == group { + k = bytes.new(kl) + bytes.copy_from_bytes(k, 0, ch, q + 4, kl) + return k + } + q = q + 4 + kl + } + } + p = p + 4 + el + } + return null +} + +// secp384r1 with the client's 97-byte uncompressed point `share`: (the +// shared x coordinate, our own point), or (null, null) when it is not one. +fn p384_exchange(share: ptr) -> (ptr, ptr) { + if bytes.length(share) != 97 || (bytes.get(share, 0) & 0xFF) != 4 { return null, null } + k = null + while k == null { + kb, _ke = cryptography.random_buffer(48) + if p384.private_key_valid(kb) == 1 { k = kb } else { bytes.free(kb) } + } + cx = bytes.new(48) + bytes.copy_from_bytes(cx, 0, share, 1, 48) + cy = bytes.new(48) + bytes.copy_from_bytes(cy, 0, share, 49, 48) + shared = p384.ecdh(k, cx, cy) + px, py = p384.scalar_mult_base(k) + bytes.free(cx); bytes.free(cy); bytes.free(k) + if shared == null { + bytes.free(px); bytes.free(py) + return null, null + } + pt = bytes.new(97) + bytes.set(pt, 0, 4) + bytes.copy_from_bytes(pt, 1, px, 0, 48) + bytes.copy_from_bytes(pt, 49, py, 0, 48) + bytes.free(px); bytes.free(py) + return shared, pt +} + +// The Ed25519 key (key=ed25519): its 32-byte seed, and a self-signed +// certificate for peer.example (CA:FALSE, digitalSignature, 2026 to 2126). +const ED25519_SEED = "9b2b46535fdf26252dc97d455e307d106b355aee79082fa6e866fe346c5b6512" + +fn peer_pem() -> string { + if knob("key") == "ed25519" { + return "-----BEGIN CERTIFICATE-----\nMIIBOjCB7aADAgECAgVQRUVSAjAFBgMrZXAwFzEVMBMGA1UEAwwMcGVlci5leGFt\ncGxlMCAXDTI2MDEwMTAwMDAwMFoYDzIxMjYwMTAxMDAwMDAwWjAXMRUwEwYDVQQD\nDAxwZWVyLmV4YW1wbGUwKjAFBgMrZXADIQAwue0/gcQ6Onbr/PNB7pQvUE+wyTdm\npW3upBzSsmLxAaNYMFYwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwFwYD\nVR0RBBAwDoIMcGVlci5leGFtcGxlMB0GA1UdDgQWBBTvT1C8YMlP+KC6lydkNwIv\nFUxijjAFBgMrZXADQQDcbJrUjo3xrEDnWDaKak8JCWarW76VW6CMRRtIFGgjq4ws\nO8Ph5oSpvp5ZIR0SVkX7nySGA+ocIyXz6P+1JjEG\n-----END CERTIFICATE-----\n" + } + return "-----BEGIN CERTIFICATE-----\nMIIBezCCASGgAwIBAgIFUEVFUgEwCgYIKoZIzj0EAwIwFzEVMBMGA1UEAwwMcGVl\nci5leGFtcGxlMCAXDTI2MDEwMTAwMDAwMFoYDzIxMjYwMTAxMDAwMDAwWjAXMRUw\nEwYDVQQDDAxwZWVyLmV4YW1wbGUwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARv\n7S9eWT57ZTmAOztQD2+5EetBRQ+B+cOtLJCr7BVBQPQExOwo/jrT1ahb6i7ZxObZ\nFrq4KWW801ggO2EZ0SqXo1gwVjAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIH\ngDAXBgNVHREEEDAOggxwZWVyLmV4YW1wbGUwHQYDVR0OBBYEFH8HRengm5cRK0K4\na6DbxP8afdpfMAoGCCqGSM49BAMCA0gAMEUCIDCIjkFoXJK1RI1twupNRu+WcP5M\nrU1MCAQ84vwQWP4tAiEArDbfJRioR7QSSuE4iKxVCCFaHbJs/7eLq3CNJGUFXoM=\n-----END CERTIFICATE-----\n" +} + +fn peer_key() -> string { + return "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgJkV1/PXrot1pb8nd\ndzrI+0SX8Cx63OCppeTS/FARImOhRANCAARv7S9eWT57ZTmAOztQD2+5EetBRQ+B\n+cOtLJCr7BVBQPQExOwo/jrT1ahb6i7ZxObZFrq4KWW801ggO2EZ0SqX\n-----END PRIVATE KEY-----\n" +} diff --git a/tests/integration/crypto_tls13_peer/peerclient.ae b/tests/integration/crypto_tls13_peer/peerclient.ae new file mode 100644 index 000000000..6ff259ed1 --- /dev/null +++ b/tests/integration/crypto_tls13_peer/peerclient.ae @@ -0,0 +1,591 @@ +// A scripted TLS 1.3 client for the pure server's checks of the client's +// second flight (#3324): it runs a real handshake with server.ae (x25519, +// the suite the server picks), then sends its Finished as PEER_CF says and +// prints what the server answered: "ALERT (app)" under the server's +// application keys, "ALERT (hs)" under its handshake keys, +// "ALERT (plain)", "CLOSED" or "OTHER". +// +// ok the right Finished +// split the right Finished over two records +// badmac verify_data with one bit flipped +// short verify_data one byte short +// long verify_data one byte long +// trailing the right Finished and one byte more in its record +// wrongtype an empty Certificate instead +// badrecord the Finished in a record that does not authenticate +// plain the Finished in a plaintext record +// appdata application data under the handshake keys +// +// With PEER_CF=ok, PEER_POST says what to send after the handshake (see +// post_session below). PEER_HRR has the server send a HelloRetryRequest +// first (#3325; see the ClientHello it sends); PEER_GROUP=p256 offers +// secp256r1 alone and PEER_EARLY=1 sends 0-RTT data the server must skip +// (#3357). + +import std.os +import std.tcp +import std.bytes +import std.string +import std.cryptography +import std.cryptography.x25519 +import std.cryptography.p256 +import std.cryptography.tls13_hs +import std.cryptography.tls13_ks +import std.cryptography.tls13_kdf +import std.cryptography.tls13_record +import std.cryptography.tls13_client + +extern exit(code: int) + +struct Reader { + sock: ptr + buf: ptr + len: int + off: int +} + +// The next record (header included) as fresh bytes, or null at EOF. +fn next_record(r: *Reader) -> ptr { + while 1 == 1 { + avail = r.len - r.off + if avail >= 5 { + plen = ((bytes.get(r.buf, r.off + 3) & 0xFF) << 8) | (bytes.get(r.buf, r.off + 4) & 0xFF) + if avail >= 5 + plen { + rec = bytes.new(5 + plen) + bytes.copy_from_bytes(rec, 0, r.buf, r.off, 5 + plen) + r.off = r.off + 5 + plen + return rec + } + } + chunk = bytes.new(16384) + bytes.set(chunk, 16383, 0) + got, err = tcp.read_into(r.sock, bytes.view(chunk)[0..16384]) + if err != "" || got <= 0 { + bytes.free(chunk) + return null + } + bytes.copy_from_bytes(r.buf, r.len, chunk, 0, got) + r.len = r.len + got + bytes.free(chunk) + } +} + +fn send_raw(sock: ptr, rec: ptr) { + _n, _e = tcp.write_all(sock, bytes.view(rec)[0..bytes.length(rec)]) +} + +fn send_plain(sock: ptr, ct: int, msg: ptr, off: int, n: int) { + rec = bytes.new(5 + n) + bytes.set(rec, 0, ct) + bytes.set(rec, 1, 3) + bytes.set(rec, 2, 3) + bytes.set(rec, 3, (n >> 8) & 0xFF) + bytes.set(rec, 4, n & 0xFF) + if n > 0 { bytes.copy_from_bytes(rec, 5, msg, off, n) } + send_raw(sock, rec) + bytes.free(rec) +} + +fn send_sealed(sock: ptr, ctx: ptr, ct: int, msg: ptr, off: int, n: int) { + rec = tls13_record.seal_record(ctx, ct, bytes.view(msg)[off..off + n]) + send_raw(sock, rec) + bytes.free(rec) +} + +// (aead, key length, hash, hash length) of a TLS 1.3 suite. +fn suite_params(cs: int) -> (int, int, string, int) { + if cs == tls13_hs.TLS_AES_256_GCM_SHA384 { return tls13_record.AEAD_AES_256_GCM, 32, "sha384", 48 } + if cs == tls13_hs.TLS_CHACHA20_POLY1305_SHA256 { return tls13_record.AEAD_CHACHA20_POLY1305, 32, "sha256", 32 } + return tls13_record.AEAD_AES_128_GCM, 16, "sha256", 32 +} + +// A record context for traffic `secret`. +fn ctx_for(algo: string, hl: int, aead: int, klen: int, secret: ptr) -> ptr { + k = tls13_ks.write_key(algo, bytes.view(secret)[0..hl], klen) + iv = tls13_ks.write_iv(algo, secret) + ctx = tls13_record.new_aead(aead, bytes.view(k)[0..klen], iv) + bytes.free(k) + bytes.free(iv) + return ctx +} + +// What the server sent back: an alert in the clear, or under `app` or `hs`. +fn answer(r: *Reader, app: ptr, hs: ptr) -> string { + rec = next_record(r) + if rec == null { return "CLOSED" } + ct = bytes.get(rec, 0) & 0xFF + if ct == 0x15 { + d = bytes.get(rec, 6) & 0xFF + bytes.free(rec) + return "ALERT ${d} (plain)" + } + if ct != 0x17 { + bytes.free(rec) + return "OTHER" + } + rl = bytes.length(rec) + res = "OTHER" + o1 = tls13_record.OpenedRecord { content_type: 0, plaintext: null, plaintext_len: 0, alert: 0 } + e1 = tls13_record.open_record(app, bytes.view(rec)[0..rl], &o1) + if e1 == "" { + if o1.content_type == tls13_record.CONTENT_ALERT && o1.plaintext_len >= 2 { res = "ALERT ${bytes.get(o1.plaintext, 1) & 0xFF} (app)" } + if o1.plaintext != null { bytes.free(o1.plaintext) } + } else { + o2 = tls13_record.OpenedRecord { content_type: 0, plaintext: null, plaintext_len: 0, alert: 0 } + e2 = tls13_record.open_record(hs, bytes.view(rec)[0..rl], &o2) + if e2 == "" { + if o2.content_type == tls13_record.CONTENT_ALERT && o2.plaintext_len >= 2 { res = "ALERT ${bytes.get(o2.plaintext, 1) & 0xFF} (hs)" } + if o2.plaintext != null { bytes.free(o2.plaintext) } + } + } + bytes.free(rec) + return res +} + +main() { + port, _perr = string.to_int(os.getenv("PEER_PORT")) + sock, derr = tcp.connect("127.0.0.1", port) + if derr != "" { + println("FAIL: connect: ${derr}") + exit(1) + } + r = Reader { sock: sock, buf: bytes.new(4096), len: 0, off: 0 } + rnd, _r1 = cryptography.random_buffer(32) + sid, _r2 = cryptography.random_buffer(32) + priv, _r3 = cryptography.random_buffer(32) + pub = x25519.base_point(priv) + empty = bytes.new(0) + ch = tls13_hs.client_hello_full(rnd, bytes.view(sid)[0..32], tls13_hs.GROUP_X25519, bytes.view(pub)[0..32], 0, bytes.view(empty)[0..0], 0, bytes.view(empty)[0..0], "peer.example") + // PEER_HRR (#3325): a first ClientHello naming secp384r1 and x25519 in + // supported_groups with a share for secp384r1 only, which the pure + // server does not take: it must ask for x25519 with a HelloRetryRequest. + hrr_mode = os.getenv("PEER_HRR") + p384pt = bytes.new(97) + bytes.set(p384pt, 0, 4) + k97 = 1 + while k97 < 97 { + bytes.set(p384pt, k97, 0x11) + k97 = k97 + 1 + } + if hrr_mode != "" { + bytes.free(ch) + both = tls13_hs.client_hello_full(rnd, bytes.view(sid)[0..32], 0x0018, bytes.view(p384pt)[0..97], tls13_hs.GROUP_X25519, bytes.view(pub)[0..32], 0, bytes.view(empty)[0..0], "peer.example") + pch = tls13_hs.PskCh { buf: null, buf_len: 0, binder_off: 0, truncated_len: 0 } + tls13_hs.client_hello_retry(bytes.view(both)[0..bytes.length(both)], 0x0018, bytes.view(p384pt)[0..97], bytes.view(empty)[0..0], &pch) + bytes.free(both) + ch = pch.buf + } + // PEER_GROUP=p256 (#3357): a share for secp256r1 alone, which the pure + // server must take (RFC 8446 9.1). PEER_EARLY=1: an early_data + // extension and a record of 0-RTT data the server cannot read, which it + // must skip (4.2.10). + p256_priv = null + if os.getenv("PEER_GROUP") == "p256" { + bytes.free(ch) + p256_priv, _r4 = cryptography.random_buffer(32) + bytes.set(p256_priv, 0, 1) + qx, qy = p256.scalar_mult_base(p256_priv) + qpt = bytes.new(65) + bytes.set(qpt, 0, 4) + bytes.copy_from_bytes(qpt, 1, qx, 0, 32) + bytes.copy_from_bytes(qpt, 33, qy, 0, 32) + bytes.free(qx) + bytes.free(qy) + ch = tls13_hs.client_hello_full(rnd, bytes.view(sid)[0..32], tls13_hs.GROUP_SECP256R1, bytes.view(qpt)[0..65], 0, bytes.view(empty)[0..0], 0, bytes.view(empty)[0..0], "peer.example") + bytes.free(qpt) + } + peer_early = os.getenv("PEER_EARLY") == "1" + if peer_early { + ch2 = with_early_data(ch) + bytes.free(ch) + ch = ch2 + } + send_plain(sock, 22, ch, 0, bytes.length(ch)) + if peer_early { + one = bytes.new(1) + bytes.set(one, 0, 1) + send_plain(sock, 20, one, 0, 1) + bytes.free(one) + junk, _r5 = cryptography.random_buffer(40) + send_plain(sock, 23, junk, 0, 40) + bytes.free(junk) + } + tr = tls13_client.transcript_new() + tls13_client.transcript_add(tr, bytes.view(ch)[0..bytes.length(ch)]) + + // The ServerHello, in one plaintext record. + shrec = next_record(&r) + if shrec == null || (bytes.get(shrec, 0) & 0xFF) != 0x16 { + println("FAIL: no ServerHello") + exit(1) + } + if hrr_mode != "" { + hl0 = bytes.length(shrec) - 5 + hrr = bytes.new(hl0) + bytes.copy_from_bytes(hrr, 0, shrec, 5, hl0) + bytes.free(shrec) + hp = tls13_hs.ShParsed { version: 0, cipher_suite: 0, group: 0, key_share: null, key_share_len: 0, is_hello_retry: 0, psk_accepted: 0, alert: 0 } + herr = tls13_hs.parse_server_hello(bytes.view(hrr)[0..hl0], &hp) + if herr != "" || hp.is_hello_retry != 1 || hp.group != tls13_hs.GROUP_X25519 { + println("NO HRR: ${herr} retry=${hp.is_hello_retry} group=${hp.group}") + exit(0) + } + // Transcript: message_hash(ClientHello1), the HelloRetryRequest + // (RFC 8446 4.4.1), computed here apart from tls13_client. + _ha, _hk, halgo, hhl = suite_params(hp.cipher_suite) + th1 = tls13_client.transcript_hash_algo(tr, halgo) + tls13_client.transcript_free(tr) + tr = tls13_client.transcript_new() + mh = bytes.new(4 + hhl) + bytes.set(mh, 0, 254) + bytes.set(mh, 1, 0) + bytes.set(mh, 2, 0) + bytes.set(mh, 3, hhl) + bytes.copy_from_bytes(mh, 4, th1, 0, hhl) + tls13_client.transcript_add(tr, bytes.view(mh)[0..4 + hhl]) + tls13_client.transcript_add(tr, bytes.view(hrr)[0..hl0]) + bytes.free(mh) + bytes.free(th1) + bytes.free(hrr) + // The server's compatibility change_cipher_spec follows its first + // handshake message, the HelloRetryRequest (D.4), and only that one. + ccsr = next_record(&r) + if ccsr == null || (bytes.get(ccsr, 0) & 0xFF) != 0x14 { + println("FAIL: no change_cipher_spec after the HelloRetryRequest") + exit(1) + } + bytes.free(ccsr) + // The second ClientHello as PEER_HRR says: "ok" with the x25519 + // share; "noshare" the first again; "sid" another session id; + // "suite" another cipher suite first; "ccs2" two change_cipher_spec + // records before it. + ch2 = null + if hrr_mode == "noshare" { + ch2 = bytes.new(bytes.length(ch)) + bytes.copy_from_bytes(ch2, 0, ch, 0, bytes.length(ch)) + } else { + pch2 = tls13_hs.PskCh { buf: null, buf_len: 0, binder_off: 0, truncated_len: 0 } + tls13_hs.client_hello_retry(bytes.view(ch)[0..bytes.length(ch)], tls13_hs.GROUP_X25519, bytes.view(pub)[0..32], bytes.view(empty)[0..0], &pch2) + ch2 = pch2.buf + } + if hrr_mode == "sid" { bytes.set(ch2, 39, (bytes.get(ch2, 39) ^ 1) & 0xFF) } + if hrr_mode == "suite" { + bytes.set(ch2, 73, 0x13) + bytes.set(ch2, 74, 0x02) + } + one = bytes.new(1) + bytes.set(one, 0, 1) + send_plain(sock, 20, one, 0, 1) + if hrr_mode == "ccs2" { send_plain(sock, 20, one, 0, 1) } + bytes.free(one) + send_plain(sock, 22, ch2, 0, bytes.length(ch2)) + tls13_client.transcript_add(tr, bytes.view(ch2)[0..bytes.length(ch2)]) + bytes.free(ch2) + if hrr_mode != "ok" { + println("HRR ${answer(&r, null, null)}") + tcp.close(sock) + exit(0) + } + shrec = next_record(&r) + if shrec == null || (bytes.get(shrec, 0) & 0xFF) != 0x16 { + println("FAIL: no ServerHello after the HelloRetryRequest") + exit(1) + } + } + shl = bytes.length(shrec) - 5 + sh = bytes.new(shl) + bytes.copy_from_bytes(sh, 0, shrec, 5, shl) + shp = tls13_hs.ShParsed { version: 0, cipher_suite: 0, group: 0, key_share: null, key_share_len: 0, is_hello_retry: 0, psk_accepted: 0, alert: 0 } + serr = tls13_hs.parse_server_hello(bytes.view(sh)[0..shl], &shp) + want_ks = 32 + if p256_priv != null { want_ks = 65 } + if serr != "" || shp.key_share_len != want_ks { + println("FAIL: ServerHello: ${serr}") + exit(1) + } + tls13_client.transcript_add(tr, bytes.view(sh)[0..shl]) + aead, klen, algo, hl = suite_params(shp.cipher_suite) + shared = null + if p256_priv != null { + sx = bytes.new(32) + sy = bytes.new(32) + bytes.copy_from_bytes(sx, 0, shp.key_share, 1, 32) + bytes.copy_from_bytes(sy, 0, shp.key_share, 33, 32) + shared = p256.ecdh(p256_priv, sx, sy) + bytes.free(sx) + bytes.free(sy) + } else { + xs, _aerr = x25519.agree(priv, shp.key_share) + shared = xs + } + th = tls13_client.transcript_hash_algo(tr, algo) + early = tls13_ks.early_secret(algo) + hsec = tls13_ks.handshake_secret(algo, early, bytes.view(shared)[0..32]) + c_hs = tls13_ks.traffic_secret(algo, hsec, "c hs traffic", bytes.view(th)[0..hl]) + s_hs = tls13_ks.traffic_secret(algo, hsec, "s hs traffic", bytes.view(th)[0..hl]) + srv_ctx = ctx_for(algo, hl, aead, klen, s_hs) + cli_ctx = ctx_for(algo, hl, aead, klen, c_hs) + + // The server's flight, through its Finished, into the transcript. + fl = bytes.new(4096) + fll = 0 + done = 0 + while done == 0 { + rec = next_record(&r) + if rec == null { + println("FAIL: the server's flight ended early") + exit(1) + } + if (bytes.get(rec, 0) & 0xFF) == 0x17 { + o = tls13_record.OpenedRecord { content_type: 0, plaintext: null, plaintext_len: 0, alert: 0 } + oe = tls13_record.open_record(srv_ctx, bytes.view(rec)[0..bytes.length(rec)], &o) + if oe != "" { + println("FAIL: flight: ${oe}") + exit(1) + } + bytes.copy_from_bytes(fl, fll, o.plaintext, 0, o.plaintext_len) + fll = fll + o.plaintext_len + bytes.free(o.plaintext) + p = 0 + while p + 4 <= fll && done == 0 { + ml = ((bytes.get(fl, p + 1) & 0xFF) << 16) | ((bytes.get(fl, p + 2) & 0xFF) << 8) | (bytes.get(fl, p + 3) & 0xFF) + if p + 4 + ml <= fll { + if (bytes.get(fl, p) & 0xFF) == 20 { done = 1 } + p = p + 4 + ml + } else { + p = fll + 1 + } + } + } + bytes.free(rec) + } + tls13_client.transcript_add(tr, bytes.view(fl)[0..fll]) + th_sf = tls13_client.transcript_hash_algo(tr, algo) + ms = tls13_ks.master_secret(algo, hsec) + s_ap = tls13_ks.traffic_secret(algo, ms, "s ap traffic", bytes.view(th_sf)[0..hl]) + app_ctx = ctx_for(algo, hl, aead, klen, s_ap) + + cvd = tls13_client.finished_verify_data_algo(c_hs, bytes.view(th_sf)[0..hl], algo, hl) + how = os.getenv("PEER_CF") + n = 4 + hl + if how == "short" { n = 4 + hl - 1 } + if how == "long" || how == "trailing" { n = 4 + hl + 1 } + fin = bytes.new(n) + bytes.set(fin, n - 1, 0) + bytes.set(fin, 0, 20) + vl = hl + if how == "short" { vl = hl - 1 } + if how == "long" { vl = hl + 1 } + bytes.set(fin, 3, vl) + if how == "short" { + bytes.copy_from_bytes(fin, 4, cvd, 0, hl - 1) + } else { + bytes.copy_from_bytes(fin, 4, cvd, 0, hl) + } + if how == "badmac" { bytes.set(fin, 4, (bytes.get(fin, 4) ^ 1) & 0xFF) } + if how == "wrongtype" { + bytes.free(fin) + fin = bytes.new(8) + bytes.set(fin, 7, 0) + bytes.set(fin, 0, 11) + bytes.set(fin, 3, 4) + n = 8 + } + if how == "split" { + send_sealed(sock, cli_ctx, 22, fin, 0, 3) + send_sealed(sock, cli_ctx, 22, fin, 3, n - 3) + } else if how == "badrecord" { + rec2 = tls13_record.seal_record(cli_ctx, 22, bytes.view(fin)[0..n]) + bytes.set(rec2, 6, (bytes.get(rec2, 6) ^ 1) & 0xFF) + send_raw(sock, rec2) + bytes.free(rec2) + } else if how == "plain" { + send_plain(sock, 22, fin, 0, n) + } else if how == "appdata" { + send_sealed(sock, cli_ctx, 23, fin, 0, n) + } else { + send_sealed(sock, cli_ctx, 22, fin, 0, n) + } + post = os.getenv("PEER_POST") + if post != "" && how == "ok" { + c_ap = tls13_ks.traffic_secret(algo, ms, "c ap traffic", bytes.view(th_sf)[0..hl]) + k = Keys { algo: algo, hl: hl, aead: aead, klen: klen, c_sec: c_ap, s_sec: s_ap, c_ctx: ctx_for(algo, hl, aead, klen, c_ap), s_ctx: app_ctx } + post_session(sock, &r, &k, post) + tcp.close(sock) + exit(0) + } + println(answer(&r, app_ctx, srv_ctx)) + tcp.close(sock) + exit(0) +} + +// ---- after the handshake (PEER_POST): KeyUpdate and alerts to the pure +// server (#3320, #3323), under the client's application keys, which the +// peer computes itself with tls13_kdf, apart from tls13_ks: +// kuN, kuxN N KeyUpdates (update_not_requested), then 2 bytes of +// data (kuxN without them, for a count the server refuses) +// kureqN N KeyUpdates (update_requested), then data +// kubad a KeyUpdate whose request_update is 2 +// kulong a KeyUpdate whose body is two bytes +// kutrail a KeyUpdate and the header of another message, one record +// nstx a NewSessionTicket, which no client sends +// alerts:HEX alert records (level, description pairs), then data +// (alertsx:HEX without it) +// listen nothing: what the server sends on its own +// Then "POST ", record by record under its keys, +// which move on at each KeyUpdate: "KU", "DATA ", "ALERT ", "CLOSED". + +struct Keys { + algo: string + hl: int + aead: int + klen: int + c_sec: ptr // this client's sending secret + s_sec: ptr // the server's sending secret + c_ctx: ptr + s_ctx: ptr +} + +// The traffic secret after `s` (RFC 8446 7.2); `s` is freed. +fn next_secret(algo: string, hl: int, s: ptr) -> ptr { + empty = bytes.new(0) + n = tls13_kdf.expand_label(algo, bytes.view(s)[0..hl], "traffic upd", bytes.view(empty)[0..0], hl) + bytes.free(empty) + bytes.free(s) + return n +} + +fn send_ku(sock: ptr, k: *Keys, request: int) { + m = bytes.new(5) + bytes.set(m, 0, 24) + bytes.set(m, 1, 0) + bytes.set(m, 2, 0) + bytes.set(m, 3, 1) + bytes.set(m, 4, request) + send_sealed(sock, k.c_ctx, 22, m, 0, 5) + bytes.free(m) + k.c_sec = next_secret(k.algo, k.hl, k.c_sec) + tls13_record.free_ctx(k.c_ctx) + k.c_ctx = ctx_for(k.algo, k.hl, k.aead, k.klen, k.c_sec) +} + +fn send_hex(sock: ptr, k: *Keys, ct: int, h: string) { + n = string.length(h) / 2 + b = bytes.new(n) + i = 0 + while i < n { + bytes.set(b, i, hexval(string_char_at(h, 2 * i)) * 16 + hexval(string_char_at(h, 2 * i + 1))) + i = i + 1 + } + send_sealed(sock, k.c_ctx, ct, b, 0, n) + bytes.free(b) +} + +extern string_char_at(str: string, index: int) -> int + +fn hexval(h: int) -> int { + if h >= 48 && h <= 57 { return h - 48 } + if h >= 97 && h <= 102 { return h - 87 } + return h - 55 +} + +fn post_session(sock: ptr, r: *Reader, k: *Keys, what: string) { + if string.starts_with(what, "kureq") == 1 { + count, _e = string.to_int(string.substring(what, 5, string.length(what))) + i = 0 + while i < count { + send_ku(sock, k, 1) + i = i + 1 + } + send_hex(sock, k, 23, "6869") + } else if what == "kubad" { + send_hex(sock, k, 22, "1800000102") + } else if what == "kulong" { + send_hex(sock, k, 22, "180000020000") + } else if what == "kutrail" { + send_hex(sock, k, 22, "180000010004000000") + } else if string.starts_with(what, "ku") == 1 { + from = 2 + if string.starts_with(what, "kux") == 1 { from = 3 } + count2, _e2 = string.to_int(string.substring(what, from, string.length(what))) + j = 0 + while j < count2 { + send_ku(sock, k, 0) + j = j + 1 + } + if from == 2 { send_hex(sock, k, 23, "6869") } + } else if what == "nstx" { + send_hex(sock, k, 22, "0400000f0000012c0102030400000101aa0000") + } else if string.starts_with(what, "alerts") == 1 { + at = string.index_of(what, ":") + 1 + al = string.substring(what, at, string.length(what)) + p = 0 + while p + 3 < string.length(al) { + send_hex(sock, k, 21, string.substring(al, p, p + 4)) + p = p + 4 + } + if string.starts_with(what, "alertsx:") != 1 { send_hex(sock, k, 23, "6869") } + } + println("POST ${session_answer(r, k)}") +} + +// What the server sent from here on, record by record, until it closes or +// sends data or an alert: "KU" for each KeyUpdate (its keys then move on). +fn session_answer(r: *Reader, k: *Keys) -> string { + out = "" + while 1 == 1 { + rec = next_record(r) + if rec == null { return "${out}CLOSED" } + if (bytes.get(rec, 0) & 0xFF) != 0x17 { + bytes.free(rec) + return "${out}PLAIN" + } + o = tls13_record.OpenedRecord { content_type: 0, plaintext: null, plaintext_len: 0, alert: 0 } + oe = tls13_record.open_record(k.s_ctx, bytes.view(rec)[0..bytes.length(rec)], &o) + bytes.free(rec) + if oe != "" { return "${out}UNREADABLE" } + t = o.content_type + n = o.plaintext_len + first = 0 + if n > 0 { first = bytes.get(o.plaintext, 0) & 0xFF } + second = 0 + if n > 1 { second = bytes.get(o.plaintext, 1) & 0xFF } + if o.plaintext != null { bytes.free(o.plaintext) } + if t == 22 && first == 24 { + out = "${out}KU " + k.s_sec = next_secret(k.algo, k.hl, k.s_sec) + tls13_record.free_ctx(k.s_ctx) + k.s_ctx = ctx_for(k.algo, k.hl, k.aead, k.klen, k.s_sec) + } else if t == 21 { + return "${out}ALERT ${second}" + } else if t == 23 { + return "${out}DATA ${n}" + } else { + return "${out}HANDSHAKE ${first}" + } + } +} + +// The ClientHello `ch` with an empty early_data extension added at the +// end of its extensions (#3357). +fn with_early_data(ch: ptr) -> ptr { + n = bytes.length(ch) + out = bytes.new(n + 4) + bytes.copy_from_bytes(out, 0, ch, 0, n) + bytes.set(out, n, 0) + bytes.set(out, n + 1, 0x2a) + bytes.set(out, n + 2, 0) + bytes.set(out, n + 3, 0) + bl = n + 4 - 4 + bytes.set(out, 1, (bl >> 16) & 0xFF) + bytes.set(out, 2, (bl >> 8) & 0xFF) + bytes.set(out, 3, bl & 0xFF) + p = 4 + 2 + 32 + p = p + 1 + (bytes.get(ch, p) & 0xFF) + p = p + 2 + (((bytes.get(ch, p) & 0xFF) << 8) | (bytes.get(ch, p + 1) & 0xFF)) + p = p + 1 + (bytes.get(ch, p) & 0xFF) + el = (((bytes.get(ch, p) & 0xFF) << 8) | (bytes.get(ch, p + 1) & 0xFF)) + 4 + bytes.set(out, p, (el >> 8) & 0xFF) + bytes.set(out, p + 1, el & 0xFF) + return out +} diff --git a/tests/integration/crypto_tls13_peer/rawclient.ae b/tests/integration/crypto_tls13_peer/rawclient.ae new file mode 100644 index 000000000..0e2bb0cef --- /dev/null +++ b/tests/integration/crypto_tls13_peer/rawclient.ae @@ -0,0 +1,264 @@ +// Sends the bytes RAW (hex) to 127.0.0.1 PEER_PORT and prints what came +// back: "ALERT " for a plaintext alert, "SERVERHELLO" for a +// handshake record ("SERVERHELLO+CCS" when a change_cipher_spec follows +// it), "CLOSED", or "OTHER". RAW=ch:MODE sends a real ClientHello with one +// thing wrong (see mutated_client_hello); RAW=split_ch sends a real +// ClientHello split over two records instead. + +import std.os +import std.tcp +import std.bytes +import std.string +import std.cryptography +import std.cryptography.x25519 +import std.cryptography.tls13_hs + +extern string_char_at(str: string, index: int) -> int + +fn hexval(h: int) -> int { + if h >= 48 && h <= 57 { return h - 48 } + if h >= 97 && h <= 102 { return h - 87 } + return h - 55 +} + +// A real ClientHello (x25519 only) sent split over two handshake records, +// to see the server reassemble it (#3321). +fn split_client_hello() -> ptr { + rnd, _r1 = cryptography.random_buffer(32) + sid, _r2 = cryptography.random_buffer(32) + priv, _r3 = cryptography.random_buffer(32) + pub = x25519.base_point(priv) + empty = bytes.new(0) + ch = tls13_hs.client_hello_full(rnd, bytes.view(sid)[0..32], tls13_hs.GROUP_X25519, bytes.view(pub)[0..32], 0, bytes.view(empty)[0..0], 0, bytes.view(empty)[0..0], "peer.example") + n = bytes.length(ch) + half = n / 2 + out = bytes.new(10 + n) + bytes.set(out, 0, 0x16) + bytes.set(out, 1, 3) + bytes.set(out, 2, 3) + bytes.set(out, 3, (half >> 8) & 0xFF) + bytes.set(out, 4, half & 0xFF) + bytes.copy_from_bytes(out, 5, ch, 0, half) + bytes.set(out, 5 + half, 0x16) + bytes.set(out, 6 + half, 3) + bytes.set(out, 7 + half, 3) + bytes.set(out, 8 + half, ((n - half) >> 8) & 0xFF) + bytes.set(out, 9 + half, (n - half) & 0xFF) + bytes.copy_from_bytes(out, 10 + half, ch, half, n - half) + bytes.free(rnd); bytes.free(sid); bytes.free(priv); bytes.free(pub); bytes.free(empty); bytes.free(ch) + return out +} + +// ---- ClientHellos with one thing wrong (RAW=ch:MODE, #3357) ---- +// +// A real ClientHello (an x25519 share; SNI, supported_versions, +// supported_groups, signature_algorithms, status_request, ALPN, key_share) +// taken apart and put back together with MODE's change, in one record: +// comp_extra compression methods { 1, 0 }: TLS 1.3 wants null alone +// comp_nonull compression methods { 1 } +// dup_ext the ALPN extension twice +// sni_trailing a byte after the server_name list, inside the extension +// ca_empty certificate_authorities with an empty list +// ks_dup the x25519 share twice +// ks_trailing a byte after the key shares, inside the extension +// no_groups no supported_groups +// no_keyshare no key_share +// psk_not_last a pre_shared_key that is not the last extension +// wrong_type handshake type 3 instead of client_hello +// msg_trailing a byte after the extensions, inside the message +// empty_sid no legacy_session_id (nothing wrong: the server still sends +// its compatibility change_cipher_spec) + +struct Ext { + t: int + body: ptr +} + +fn g16(b: ptr, p: int) -> int { + return ((bytes.get(b, p) & 0xFF) << 8) | (bytes.get(b, p + 1) & 0xFF) +} + +fn put_ext(out: ptr, at: int, t: int, body: ptr) -> int { + n = bytes.length(body) + bytes.set(out, at, (t >> 8) & 0xFF) + bytes.set(out, at + 1, t & 0xFF) + bytes.set(out, at + 2, (n >> 8) & 0xFF) + bytes.set(out, at + 3, n & 0xFF) + if n > 0 { bytes.copy_from_bytes(out, at + 4, body, 0, n) } + return at + 4 + n +} + +fn hexbytes(s: string) -> ptr { + n = string.length(s) / 2 + b = bytes.new(n) + i = 0 + while i < n { + bytes.set(b, i, hexval(string_char_at(s, 2 * i)) * 16 + hexval(string_char_at(s, 2 * i + 1))) + i = i + 1 + } + return b +} + +// The record of MODE's ClientHello. +fn mutated_client_hello(mode: string) -> ptr { + rnd, _r1 = cryptography.random_buffer(32) + sid, _r2 = cryptography.random_buffer(32) + priv, _r3 = cryptography.random_buffer(32) + pub = x25519.base_point(priv) + empty = bytes.new(0) + sidn = 32 + if mode == "empty_sid" { sidn = 0 } + ch = tls13_hs.client_hello_full(rnd, bytes.view(sid)[0..sidn], tls13_hs.GROUP_X25519, bytes.view(pub)[0..32], 0, bytes.view(empty)[0..0], 0, bytes.view(empty)[0..0], "peer.example") + // The fixed part: header, version, random, session id, suites. + p = 4 + 2 + 32 + p = p + 1 + (bytes.get(ch, p) & 0xFF) + p = p + 2 + g16(ch, p) + fixed_end = p + p = p + 1 + (bytes.get(ch, p) & 0xFF) + ext_start = p + 2 + ext_end = ext_start + g16(ch, p) + out = bytes.new(4096) + bytes.set(out, 4095, 0) + bytes.copy_from_bytes(out, 0, ch, 0, fixed_end) + o = fixed_end + if mode == "comp_extra" { + bytes.set(out, o, 2) + bytes.set(out, o + 1, 1) + bytes.set(out, o + 2, 0) + o = o + 3 + } else if mode == "comp_nonull" { + bytes.set(out, o, 1) + bytes.set(out, o + 1, 1) + o = o + 2 + } else { + bytes.set(out, o, 1) + bytes.set(out, o + 1, 0) + o = o + 2 + } + elen_at = o + o = o + 2 + q = ext_start + while q + 4 <= ext_end { + t = g16(ch, q) + n = g16(ch, q + 2) + body = bytes.new(n) + if n > 0 { bytes.copy_from_bytes(body, 0, ch, q + 4, n) } + keep = 1 + if mode == "no_groups" && t == 10 { keep = 0 } + if mode == "no_keyshare" && t == 51 { keep = 0 } + if mode == "sni_trailing" && t == 0 { + bytes.set(body, n, 0) + } + if mode == "ks_trailing" && t == 51 { + bytes.set(body, n, 0) + } + if mode == "ks_dup" && t == 51 { + // client_shares holding the one share twice. + one = n - 2 + dup = bytes.new(2 + 2 * one) + bytes.set(dup, 0, ((2 * one) >> 8) & 0xFF) + bytes.set(dup, 1, (2 * one) & 0xFF) + bytes.copy_from_bytes(dup, 2, body, 2, one) + bytes.copy_from_bytes(dup, 2 + one, body, 2, one) + bytes.free(body) + body = dup + } + if mode == "psk_not_last" && t == 51 { + psk = hexbytes("000a0004abcd0001000000000021200000000000000000000000000000000000000000000000000000000000000000") + o = put_ext(out, o, 41, psk) + bytes.free(psk) + } + if keep == 1 { o = put_ext(out, o, t, body) } + if mode == "dup_ext" && t == 16 { o = put_ext(out, o, t, body) } + bytes.free(body) + q = q + 4 + n + } + if mode == "ca_empty" { + ca = hexbytes("0000") + o = put_ext(out, o, 47, ca) + bytes.free(ca) + } + el = o - elen_at - 2 + bytes.set(out, elen_at, (el >> 8) & 0xFF) + bytes.set(out, elen_at + 1, el & 0xFF) + if mode == "msg_trailing" { + bytes.set(out, o, 0) + o = o + 1 + } + bl = o - 4 + bytes.set(out, 1, (bl >> 16) & 0xFF) + bytes.set(out, 2, (bl >> 8) & 0xFF) + bytes.set(out, 3, bl & 0xFF) + if mode == "wrong_type" { bytes.set(out, 0, 3) } + rec = bytes.new(5 + o) + bytes.set(rec, 0, 0x16) + bytes.set(rec, 1, 3) + bytes.set(rec, 2, 1) + bytes.set(rec, 3, (o >> 8) & 0xFF) + bytes.set(rec, 4, o & 0xFF) + bytes.copy_from_bytes(rec, 5, out, 0, o) + bytes.free(out) + bytes.free(rnd); bytes.free(sid); bytes.free(priv); bytes.free(pub); bytes.free(empty); bytes.free(ch) + return rec +} + +main() { + port, _perr = string.to_int(os.getenv("PEER_PORT")) + sock, derr = tcp.connect("127.0.0.1", port) + if derr != "" { + println("ERR: connect: ${derr}") + return + } + raw = os.getenv("RAW") + b = null + n = 0 + if string.starts_with(raw, "ch:") == 1 { + b = mutated_client_hello(string.substring(raw, 3, string.length(raw))) + n = bytes.length(b) + } else if raw == "split_ch" { + b = split_client_hello() + n = bytes.length(b) + } else { + n = string.length(raw) / 2 + b = bytes.new(n) + i = 0 + while i < n { + bytes.set(b, i, hexval(string_char_at(raw, 2 * i)) * 16 + hexval(string_char_at(raw, 2 * i + 1))) + i = i + 1 + } + } + _s, _werr = tcp.write_all(sock, bytes.view(b)[0..n]) + bytes.free(b) + buf = bytes.new(4096) + bytes.set(buf, 4095, 0) + got, rerr = tcp.read_into(sock, bytes.view(buf)[0..4096]) + // A ServerHello record: read on until the record after it has its header. + if rerr == "" && got >= 5 && (bytes.get(buf, 0) & 0xFF) == 0x16 { + need = 5 + (((bytes.get(buf, 3) & 0xFF) << 8) | (bytes.get(buf, 4) & 0xFF)) + 5 + tries = 0 + while got < need && got < 4000 && tries < 50 { + ready, _pe = tcp.poll(sock, 100) + if ready { + more, merr = tcp.read_into(sock, bytes.view(buf)[got..4096]) + if merr != "" || more <= 0 { tries = 50 } else { got = got + more } + } + tries = tries + 1 + } + } + if rerr != "" || got <= 0 { + println("CLOSED") + } else if (bytes.get(buf, 0) & 0xFF) == 0x16 { + shl = 5 + (((bytes.get(buf, 3) & 0xFF) << 8) | (bytes.get(buf, 4) & 0xFF)) + if got > shl && (bytes.get(buf, shl) & 0xFF) == 0x14 { + println("SERVERHELLO+CCS") + } else { + println("SERVERHELLO") + } + } else if got >= 7 && (bytes.get(buf, 0) & 0xFF) == 0x15 { + println("ALERT ${bytes.get(buf, 6) & 0xFF}") + } else { + println("OTHER") + } + bytes.free(buf) + tcp.close(sock) +} diff --git a/tests/integration/crypto_tls13_peer/server.ae b/tests/integration/crypto_tls13_peer/server.ae new file mode 100644 index 000000000..7d7cba6ab --- /dev/null +++ b/tests/integration/crypto_tls13_peer/server.ae @@ -0,0 +1,89 @@ +// The pure TLS server (tls13_server.accept) for test_tls13_peer.sh's server +// cases: it listens on 127.0.0.1, writes its port to PEER_PORT_OUT, serves one +// connection with peer.ae's certificate, and prints "ACCEPTED" or +// "REFUSED: ". With SERVER_READ=1 it then echoes what it reads, printing +// "RECV " for each record, until "EOF" or "RECVERR: ". SERVER_CERT +// and SERVER_KEY name PEM files to serve instead of the built-in pair; +// SERVER_CORRUPT_KEY=1 flips a bit of the loaded key's shielded form. + +import std.os +import std.fs +import std.tcp +import std.bytes +import std.cryptography +import std.cryptography.tls13_server +import std.cryptography.shield + +extern exit(code: int) + +main() { + srv, lerr = tcp.listen_on("127.0.0.1", 0) + if lerr != "" { + println("FAIL: ${lerr}") + exit(1) + } + _pw = fs.write(os.getenv("PEER_PORT_OUT"), "${tcp.server_port(srv)}") + sock, aerr = tcp.accept(srv) + if aerr != "" { + println("FAIL: ${aerr}") + exit(1) + } + // SERVER_CERT and SERVER_KEY name PEM files to serve instead (#3357). + cert_pem = peer_pem() + key_pem = peer_key() + if os.getenv("SERVER_CERT") != "" { + cp, _ce = fs.read(os.getenv("SERVER_CERT")) + kp, _ke = fs.read(os.getenv("SERVER_KEY")) + cert_pem = cp + key_pem = kp + } + chain, lens = tls13_server.parse_cert_chain_pem(cert_pem) + key, _kerr = tls13_server.load_private_key(key_pem) + // SERVER_CORRUPT_KEY=1 flips one bit of the shielded key, as a bit + // flipped in memory would (#3376): the server must refuse to use it. + if os.getenv("SERVER_CORRUPT_KEY") == "1" { + sh = (key as *tls13_server.ServerKey).shielded as *shield.Shielded + bytes.set(sh.sealed, 3, bytes.get(sh.sealed, 3) ^ 8) + } + xpriv, _rerr = cryptography.random_buffer(32) + conn, err = tls13_server.accept(sock, chain, lens, key, xpriv) + bytes.free(xpriv) + tls13_server.free_private_key(key) + if err != "" { + println("REFUSED: ${err}") + tcp.close(sock) + exit(0) + } + println("ACCEPTED") + if os.getenv("SERVER_READ") == "1" { + going = 1 + while going == 1 { + buf, n, rerr = tls13_server.conn_recv(conn) + if rerr != "" { + println("RECVERR: ${rerr}") + going = 0 + } else if buf == null { + println("EOF") + going = 0 + } else { + println("RECV ${n}") + serr = tls13_server.conn_send(conn, bytes.view(buf)[0..n]) + bytes.free(buf) + if serr != "" { + println("SENDERR: ${serr}") + going = 0 + } + } + } + } + tls13_server.close_conn(conn) + exit(0) +} + +fn peer_pem() -> string { + return "-----BEGIN CERTIFICATE-----\nMIIBezCCASGgAwIBAgIFUEVFUgEwCgYIKoZIzj0EAwIwFzEVMBMGA1UEAwwMcGVl\nci5leGFtcGxlMCAXDTI2MDEwMTAwMDAwMFoYDzIxMjYwMTAxMDAwMDAwWjAXMRUw\nEwYDVQQDDAxwZWVyLmV4YW1wbGUwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARv\n7S9eWT57ZTmAOztQD2+5EetBRQ+B+cOtLJCr7BVBQPQExOwo/jrT1ahb6i7ZxObZ\nFrq4KWW801ggO2EZ0SqXo1gwVjAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIH\ngDAXBgNVHREEEDAOggxwZWVyLmV4YW1wbGUwHQYDVR0OBBYEFH8HRengm5cRK0K4\na6DbxP8afdpfMAoGCCqGSM49BAMCA0gAMEUCIDCIjkFoXJK1RI1twupNRu+WcP5M\nrU1MCAQ84vwQWP4tAiEArDbfJRioR7QSSuE4iKxVCCFaHbJs/7eLq3CNJGUFXoM=\n-----END CERTIFICATE-----\n" +} + +fn peer_key() -> string { + return "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgJkV1/PXrot1pb8nd\ndzrI+0SX8Cx63OCppeTS/FARImOhRANCAARv7S9eWT57ZTmAOztQD2+5EetBRQ+B\n+cOtLJCr7BVBQPQExOwo/jrT1ahb6i7ZxObZFrq4KWW801ggO2EZ0SqX\n-----END PRIVATE KEY-----\n" +} diff --git a/tests/integration/crypto_tls13_peer/stream.ae b/tests/integration/crypto_tls13_peer/stream.ae new file mode 100644 index 000000000..6f6bc1d5b --- /dev/null +++ b/tests/integration/crypto_tls13_peer/stream.ae @@ -0,0 +1,133 @@ +// A long stream over the pure TLS stack, for test_tls13_stream.sh (#3379). +// ROLE=server listens on 127.0.0.1 (its port to PORT_OUT, its certificate +// to CERT_OUT); ROLE=client connects to PORT trusting CERT. The client +// sends STREAM_MB MiB in 16 KiB writes and the server reads them; then the +// server sends as much back and the client reads it. The reader of each +// direction prints the growth of the heap between its first 8 MiB and its +// last byte: " read grew ", or " ERR: ". + +import std.os +import std.fs +import std.tcp +import std.bytes +import std.string +import std.mem +import std.cryptography +import std.cryptography.tls13_client +import std.cryptography.tls13_server + +extern exit(code: int) + +const CHUNK = 16384 +const MARK = 8388608 // 8 MiB + +fn filled() -> ptr { + b = bytes.new(CHUNK) + i = 0 + while i < CHUNK { + bytes.set(b, i, (i * 7) & 255) + i = i + 1 + } + return b +} + +// Read `total` bytes through `recv` (1 server, 0 client); the heap growth. +fn read_all(conn: ptr, server: int, total: long, who: string) { + got = 0 as long + h0 = 0 as long + marked = 0 + while got < total { + buf = null + n = 0 + err = "" + if server == 1 { buf, n, err = tls13_server.conn_recv(conn) } else { buf, n, err = tls13_client.conn_recv(conn) } + if err != "" || buf == null { + println("${who} ERR: after ${got} bytes: ${err}") + return + } + bytes.free(buf) + got = got + (n as long) + if marked == 0 && got >= MARK { + h0 = mem.heap_in_use() + marked = 1 + } + } + h1 = mem.heap_in_use() + println("${who} read ${got} grew ${h1 - h0}") +} + +fn send_all(conn: ptr, server: int, total: long, who: string) -> int { + b = filled() + sent = 0 as long + while sent < total { + err = "" + if server == 1 { err = tls13_server.conn_send(conn, bytes.view(b)[0..CHUNK]) } else { err = tls13_client.conn_send(conn, bytes.view(b)[0..CHUNK]) } + if err != "" { + println("${who} ERR: send after ${sent} bytes: ${err}") + bytes.free(b) + return 0 + } + sent = sent + (CHUNK as long) + } + bytes.free(b) + return 1 +} + +main() { + mb, _me = string.to_int(os.getenv("STREAM_MB")) + total = (mb as long) * 1048576 + if os.getenv("ROLE") == "server" { + srv, lerr = tcp.listen_on("127.0.0.1", 0) + if lerr != "" { + println("server ERR: ${lerr}") + exit(1) + } + _wc = fs.write(os.getenv("CERT_OUT"), peer_pem()) + _wp = fs.write(os.getenv("PORT_OUT"), "${tcp.server_port(srv)}") + sock, aerr = tcp.accept(srv) + if aerr != "" { + println("server ERR: ${aerr}") + exit(1) + } + chain, lens = tls13_server.parse_cert_chain_pem(peer_pem()) + key, _kerr = tls13_server.load_private_key(peer_key()) + xpriv, _rerr = cryptography.random_buffer(32) + conn, herr = tls13_server.accept(sock, chain, lens, key, xpriv) + bytes.free(xpriv) + tls13_server.free_private_key(key) + if herr != "" { + println("server ERR: ${herr}") + exit(1) + } + read_all(conn, 1, total, "server") + _s = send_all(conn, 1, total, "server") + // The client's close_notify ends it. + b, _n, _e = tls13_server.conn_recv(conn) + if b != null { bytes.free(b) } + tls13_server.close_conn(conn) + return + } + port, _pe = string.to_int(os.getenv("PORT")) + sock, derr = tcp.connect("127.0.0.1", port) + if derr != "" { + println("client ERR: connect: ${derr}") + exit(1) + } + conn, cerr = tls13_client.connect_socket(sock, "peer.example", os.getenv("CERT")) + if cerr != "" { + println("client ERR: ${cerr}") + exit(1) + } + if send_all(conn, 0, total, "client") == 1 { + read_all(conn, 0, total, "client") + } + tls13_client.close_conn(conn) +} + +fn peer_pem() -> string { + return "-----BEGIN CERTIFICATE-----\nMIIBezCCASGgAwIBAgIFUEVFUgEwCgYIKoZIzj0EAwIwFzEVMBMGA1UEAwwMcGVl\nci5leGFtcGxlMCAXDTI2MDEwMTAwMDAwMFoYDzIxMjYwMTAxMDAwMDAwWjAXMRUw\nEwYDVQQDDAxwZWVyLmV4YW1wbGUwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARv\n7S9eWT57ZTmAOztQD2+5EetBRQ+B+cOtLJCr7BVBQPQExOwo/jrT1ahb6i7ZxObZ\nFrq4KWW801ggO2EZ0SqXo1gwVjAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIH\ngDAXBgNVHREEEDAOggxwZWVyLmV4YW1wbGUwHQYDVR0OBBYEFH8HRengm5cRK0K4\na6DbxP8afdpfMAoGCCqGSM49BAMCA0gAMEUCIDCIjkFoXJK1RI1twupNRu+WcP5M\nrU1MCAQ84vwQWP4tAiEArDbfJRioR7QSSuE4iKxVCCFaHbJs/7eLq3CNJGUFXoM=\n-----END CERTIFICATE-----\n" +} + +fn peer_key() -> string { + return "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgJkV1/PXrot1pb8nd\ndzrI+0SX8Cx63OCppeTS/FARImOhRANCAARv7S9eWT57ZTmAOztQD2+5EetBRQ+B\n+cOtLJCr7BVBQPQExOwo/jrT1ahb6i7ZxObZFrq4KWW801ggO2EZ0SqX\n-----END PRIVATE KEY-----\n" +} diff --git a/tests/integration/crypto_tls13_peer/test_tls13_peer.sh b/tests/integration/crypto_tls13_peer/test_tls13_peer.sh new file mode 100644 index 000000000..a83ceb211 --- /dev/null +++ b/tests/integration/crypto_tls13_peer/test_tls13_peer.sh @@ -0,0 +1,403 @@ +#!/bin/sh +# The pure TLS client against a scripted server (peer.ae) that sends what a +# broken or hostile server would: each case is a PEER_CASE, what the client +# must answer (OK, or an error containing the text), and what the peer must +# see from it (FINISHED, or the alert it must send, by number). +# +# #3315 extensions in ServerHello and EncryptedExtensions (RFC 8446 4.2): +# unsupported_extension (110) for what the client never offered, +# illegal_parameter (47) for an offered one out of place or twice, +# decode_error (50) for a block that does not parse. +# #3317 the record layer (RFC 8446 5): limits, types, change_cipher_spec, +# empty records; client and server. +# #3321 handshake messages split across records, client and server. +# #3322 the dummy change_cipher_spec of middlebox compatibility mode. +# #3316 CertificateEntry extensions (RFC 8446 4.4.2): the same alerts, +# and decode_error for a status_request that is not one OCSP +# response. +# #3324 the right alert: illegal_parameter for a CertificateVerify +# scheme not offered or not fitting the key, decrypt_error for a +# bad signature, decode_error for trailing data, unexpected_message +# for a message out of its place; and the pure server's alerts +# about the client Finished (peerclient.ae), under its +# application keys. +# #3335 a server with an Ed25519 certificate. +# #3320 KeyUpdate, client and server, both ways (kuclient.ae, kuserver.ae). +# #3319 NewSessionTicket checks. +# #3323 alerts: user_canceled is read past, no other alert is non-fatal. +# #3325 HelloRetryRequest, client and server. +# #3318 the ServerHello's legacy_version and session id echo. +# #3357 the pure server's ClientHello checks, secp256r1, skipped 0-RTT data. +# #3364 decode_error for a repeated EncryptedExtensions extension (found +# before it is judged unsolicited) and a nonzero compression method. +# #3365 a CertificateRequest's certificate_authorities and signature list. +# #3366 a server flight in 1-byte records; the bound is on its bytes. +# #3367 a message the server's flight cannot hold ends it at once. +# #3369 the client names secp384r1 without a share and follows a +# HelloRetryRequest for it; the ServerHello must be for that group. +# #3379 one change_cipher_spec (the byte 1) in a client handshake; no +# application data before a ClientHello. +# #3368 the client reads what the server sent before it closes a failed +# handshake, so a reset does not take its alert. +# +# No network beyond loopback, no openssl: the peer's certificate is built in. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +. "$ROOT/tests/lib/server_jobs.sh" +AE="$ROOT/build/ae" +NAME=crypto_tls13_peer + +[ -x "$AE" ] || { echo " [SKIP] $NAME: ae not built"; exit 0; } + +TMP="$(mktemp -d)" +PIDS="" +cleanup() { + for p in $PIDS; do stop_server "$p"; done + rm -rf "$TMP" || : + return 0 +} +trap cleanup EXIT +fail() { echo " [FAIL] $NAME: $1"; exit 1; } + +for p in peer client server rawclient peerclient kuclient kuserver; do + "$AE" build "$SCRIPT_DIR/$p.ae" -o "$TMP/$p" >"$TMP/b_$p.log" 2>&1 \ + || { sed -n '1,12p' "$TMP/b_$p.log"; fail "$p did not build"; } +done + +fails=0 +# run_case +run_case() { + rm -f "$TMP/port" "$TMP/peer.out" + PEER_CASE="$2" PEER_CERT_OUT="$TMP/peer.pem" PEER_PORT_OUT="$TMP/port" "$TMP/peer" >"$TMP/peer.out" 2>&1 & + ppid=$! + PIDS="$PIDS $ppid" + i=0 + while [ "$i" -lt 100 ] && [ ! -s "$TMP/port" ]; do sleep 0.1; i=$((i + 1)); done + [ -s "$TMP/port" ] || { cat "$TMP/peer.out"; fail "$1: the peer never listened"; } + cr=0 + case "$2" in *post=*) cr=1 ;; esac + # CL names another client program, CENV adds its environment. + got=$(env $CENV CLIENT_READ="$cr" PEER_PORT="$(cat "$TMP/port")" CAFILE="$TMP/peer.pem" "$TMP/${CL:-client}" 2>&1 | grep -v 'warning' || :) + n=0 + while server_alive "$ppid" && [ "$n" -lt 100 ]; do sleep 0.1; n=$((n + 1)); done + stop_server "$ppid" + seen=$(cat "$TMP/peer.out") + ok=1 + case "$got" in *"$3"*) ;; *) ok=0 ;; esac + case "$seen" in *"$4"*) ;; *) ok=0 ;; esac + if [ "$ok" = 1 ]; then + echo " ok: $1" + else + echo " FAIL: $1: client said [$got], peer saw [$seen]; want [$3] and [$4]" + fails=$((fails + 1)) + fi +} + +run_case "a clean handshake" "" "OK" "FINISHED" + +# #3315: EncryptedExtensions. +run_case "EE: an unknown extension" "ee_ext=fafa0000" "ERR" "ALERT 110" +run_case "EE: extended_master_secret (TLS 1.2)" "ee_ext=00170000" "ERR" "ALERT 110" +run_case "EE: renegotiation_info (TLS 1.2)" "ee_ext=ff01000100" "ERR" "ALERT 110" +run_case "EE: ec_point_formats (TLS 1.2)" "ee_ext=000b00020100" "ERR" "ALERT 110" +run_case "EE: early_data, not offered" "ee_ext=002a0000" "ERR" "ALERT 110" +run_case "EE: key_share, offered but out of place" "ee_ext=00330000" "ERR" "ALERT 47" +run_case "EE: supported_versions, out of place" "ee_ext=002b00020304" "ERR" "ALERT 47" +run_case "EE: the same extension twice (#3364)" "ee_ext=000a00040002001d000a00040002001d" "ERR" "ALERT 50" +run_case "EE: an unknown extension twice, refused as a repeat (#3364)" "ee_ext=fafa0000fafa0000" "ERR" "ALERT 50" +run_case "EE: ALPN naming h2, never offered" "ee_ext=001000050003026832" "ERR" "ALERT 47" +run_case "EE: ALPN naming http/1.1" "ee_ext=0010000b000908687474702f312e31" "OK" "FINISHED" +run_case "EE: an empty server_name ack" "ee_ext=00000000" "OK" "FINISHED" +run_case "EE: a server_name ack with a body" "ee_ext=0000000100" "ERR" "ALERT 50" +run_case "EE: supported_groups" "ee_ext=000a00040002001d" "OK" "FINISHED" +run_case "EE: no extensions block at all" "ee_body=-" "ERR" "ALERT 50" + +# #3315: ServerHello. +run_case "SH: an unknown extension" "sh_ext=fafa0000" "ERR" "ALERT 110" +run_case "SH: server_name, offered but out of place" "sh_ext=00000000" "ERR" "ALERT 47" +run_case "SH: pre_shared_key in a full handshake" "sh_ext=002900020000" "ERR" "ALERT 110" +run_case "SH: supported_versions twice" "sh_ext=002b00020304" "ERR" "ALERT 47" +run_case "SH: an unknown extension, 32 KiB behind it (#3368)" "sh_ext=fafa0000;sh_tail=32768" "ERR" "ALERT 110" +run_case "SH: compression method 1 (#3364)" "sh_comp=1" "ERR" "ALERT 50" + + +# #3316: CertificateEntry extensions (RFC 8446 4.4.2). +run_case "Certificate: an unknown entry extension" "cert_ext=fafa0000" "ERR" "ALERT 110" +run_case "Certificate: an SCT, never offered" "cert_ext=00120000" "ERR" "ALERT 110" +run_case "Certificate: server_name, offered but out of place" "cert_ext=00000000" "ERR" "ALERT 47" +run_case "Certificate: status_request with no response" "cert_ext=00050000" "ERR" "ALERT 50" +run_case "Certificate: status_request twice" "cert_ext=0005000501000001000005000501000001ff" "ERR" "ALERT 47" +run_case "Certificate: one stapled response" "cert_ext=0005000501000001ff" "OK" "FINISHED" + + +# #3317: the record layer (RFC 8446 5). +run_case "flight: the one compatibility change_cipher_spec" "ccs=1" "OK" "FINISHED" +run_case "flight: two change_cipher_spec records" "ccs=2" "ERR" "ALERT 10" +run_case "flight: a change_cipher_spec of value 2" "ccs=bad" "ERR" "ALERT 10" +run_case "SH: the one change_cipher_spec before it" "pre_ccs=1" "OK" "FINISHED" +run_case "SH: two change_cipher_spec records before it (#3379)" "pre_ccs=2" "ERR" "ALERT 10" +run_case "SH: a change_cipher_spec of value 2 before it (#3379)" "pre_ccs=bad" "ERR" "ALERT 10" +run_case "SH: one change_cipher_spec before it and one after (#3379)" "pre_ccs=1;ccs=1" "ERR" "ALERT 10" +run_case "flight: an empty protected handshake record" "empty_hs=1" "ERR" "ALERT 10" +run_case "flight: EncryptedExtensions in the clear" "plain_ee=1" "ERR" "ALERT 10" +run_case "flight: bytes after the Finished in its record" "fin_trailing=1" "ERR" "ALERT 10" +run_case "after: a record of 2^14 + 1 bytes" "post=oversize" "RECVERR" "POST ALERT 22" +run_case "after: 8193 bytes padded past 2^14 + 1" "post=padded" "RECVERR" "POST ALERT 22" +run_case "after: a protected record over 2^14 + 256" "post=bigcipher" "RECVERR" "POST ALERT 22" +run_case "after: 40 empty records" "post=empty40" "RECVERR" "POST ALERT 10" +run_case "after: 10 empty records, then data" "post=empty10" "RECV 2" "POST" +run_case "after: a change_cipher_spec" "post=ccs" "RECVERR" "POST ALERT 10" +run_case "after: a record of unknown type" "post=badtype" "RECVERR" "POST ALERT 10" +run_case "after: a protected record of unknown inner type" "post=innertype" "RECVERR" "POST ALERT 10" +run_case "after: a record that does not authenticate" "post=badmac" "RECVERR" "POST ALERT 20" +run_case "after: an empty handshake record" "post=emptyhs" "RECVERR" "POST ALERT 10" + +# run_server_case +run_server_case() { + rm -f "$TMP/port" "$TMP/server.out" + PEER_PORT_OUT="$TMP/port" "$TMP/server" >"$TMP/server.out" 2>&1 & + spid=$! + PIDS="$PIDS $spid" + i=0 + while [ "$i" -lt 100 ] && [ ! -s "$TMP/port" ]; do sleep 0.1; i=$((i + 1)); done + [ -s "$TMP/port" ] || { cat "$TMP/server.out"; fail "$1: the server never listened"; } + got=$(RAW="$2" PEER_PORT="$(cat "$TMP/port")" "$TMP/rawclient" 2>&1 | grep -v 'warning' || :) + n=0 + while server_alive "$spid" && [ "$n" -lt 100 ]; do sleep 0.1; n=$((n + 1)); done + stop_server "$spid" + case "$got" in + *"$3"*) echo " ok: $1" ;; + *) echo " FAIL: $1: the server answered [$got], server said [$(cat "$TMP/server.out")]; want [$3]"; fails=$((fails + 1)) ;; + esac +} + +# #3317, the pure server's record layer: refused from the header alone. +run_server_case "server: a record of unknown type first" "180303000100" "ALERT 10" +run_server_case "server: a plaintext record over 2^14" "1603034001" "ALERT 22" +run_server_case "server: a change_cipher_spec before the ClientHello" "140303000101" "ALERT 10" +run_server_case "server: empty application_data records before the ClientHello (#3379)" "170303000017030300001703030000" "ALERT 10" + +# #3321: a handshake message split across records (RFC 8446 5.1). +run_case "SH: split into 8-byte records" "split_sh=8" "OK" "FINISHED" +run_case "SH: split into 1-byte records" "split_sh=1" "OK" "FINISHED" +run_case "flight: every message in 1-byte records (#3366)" "split_flight=1" "OK" "FINISHED" +run_case "flight: a Certificate header claiming 2^18 + 1 bytes (#3366)" "extra=0b040001" "ERR" "ALERT 10" +run_case "SH: more handshake bytes after it in its record" "pack_sh=1" "ERR" "ALERT 10" +run_server_case "server: a ClientHello split over two records" "split_ch" "SERVERHELLO" + +# #3322: middlebox compatibility (RFC 8446 D.4): the client's dummy +# change_cipher_spec before its second flight, an alert included, and the +# server's right after its ServerHello. +run_case "CCS: before the client's Finished" "" "OK" "CCS+FINISHED" +run_case "CCS: before the client's alert" "ee_ext=fafa0000" "ERR" "CCS+ALERT 110" +run_server_case "server: a change_cipher_spec after the ServerHello" "split_ch" "SERVERHELLO+CCS" + + +# #3324 and #3335: the server's CertificateVerify (RFC 8446 4.4.3): a +# scheme the client offered and one that fits the key, else +# illegal_parameter; a signature that verifies, else decrypt_error; +# Ed25519 among them. +run_case "CV: an Ed25519 certificate" "key=ed25519" "OK" "FINISHED" +run_case "CV: rsa_pkcs1_sha256, never offered" "cv_scheme=0401" "ERR" "ALERT 47" +run_case "CV: the P-384 scheme for a P-256 key" "cv_scheme=0503" "ERR" "ALERT 47" +run_case "CV: Ed25519 for a P-256 key" "cv_scheme=0807" "ERR" "ALERT 47" +run_case "CV: the P-256 scheme for an Ed25519 key" "key=ed25519;cv_scheme=0403" "ERR" "ALERT 47" +run_case "CV: a signature that does not verify" "cv_bad=1" "ERR" "ALERT 51" +run_case "CV: an Ed25519 signature that does not verify" "key=ed25519;cv_bad=1" "ERR" "ALERT 51" + +# #3324: trailing data is decode_error (RFC 8446 6.2), a message out of +# its place unexpected_message. +run_case "trailing: a byte after the CertificateVerify's signature" "cv_trailing=1" "ERR" "ALERT 50" +run_case "trailing: a Finished one byte long (4.4.4, #3357)" "fin_long=1" "ERR" "ALERT 51" +run_case "trailing: a byte after EncryptedExtensions' extensions" "ee_body=000000" "ERR" "ALERT 50" +run_case "order: no CertificateVerify" "skip_cv=1" "ERR" "ALERT 10" +run_case "order: a NewSessionTicket inside the flight" "extra=0400000d0000012c000000000000000000" "ERR" "ALERT 10" +run_case "order: an unknown message inside the flight" "extra=fe000000" "ERR" "ALERT 10" +run_case "order: the Finished sent as type 62 (#3367)" "fin_type=3e" "ERR" "ALERT 10" +run_case "order: the Finished sent as a NewSessionTicket (#3367)" "fin_type=04" "ERR" "ALERT 10" +run_case "CertificateRequest: accepted, an empty Certificate answers" "cr=000008000d000400020403" "OK" "HANDSHAKE" +run_case "CertificateRequest: a byte after its extensions" "cr=000008000d00040002040300" "ERR" "ALERT 50" +run_case "CertificateRequest: a certificate_request_context" "cr=01aa0008000d000400020403" "ERR" "ALERT 50" +run_case "CertificateRequest: no signature_algorithms" "cr=000000" "ERR" "ALERT 109" +run_case "CertificateRequest: an empty certificate_authorities (#3365)" "cr=00000e000d000400020403002f00020000" "ERR" "ALERT 50" +run_case "CertificateRequest: a byte after certificate_authorities' names (#3365)" "cr=000012000d000400020403002f000600030001aa00" "ERR" "ALERT 50" +run_case "CertificateRequest: certificate_authorities of one empty name (#3365)" "cr=000010000d000400020403002f000400020000" "ERR" "ALERT 50" +run_case "CertificateRequest: one name in certificate_authorities (#3365)" "cr=000011000d000400020403002f000500030001aa" "OK" "HANDSHAKE" +run_case "CertificateRequest: an odd signature_algorithms list (#3365)" "cr=000009000d00050003040300" "ERR" "ALERT 50" + +# run_cf_case ; SV names +# another server program, SENV adds its environment, POST is PEER_POST, +# HRR is PEER_HRR, GROUP PEER_GROUP, EARLY PEER_EARLY. +run_cf_case() { + rm -f "$TMP/port" "$TMP/server.out" + env $SENV PEER_PORT_OUT="$TMP/port" "$TMP/${SV:-server}" >"$TMP/server.out" 2>&1 & + spid=$! + PIDS="$PIDS $spid" + i=0 + while [ "$i" -lt 100 ] && [ ! -s "$TMP/port" ]; do sleep 0.1; i=$((i + 1)); done + [ -s "$TMP/port" ] || { cat "$TMP/server.out"; fail "$1: the server never listened"; } + got=$(PEER_GROUP="$GROUP" PEER_EARLY="$EARLY" PEER_HRR="$HRR" PEER_POST="$POST" PEER_CF="$2" PEER_PORT="$(cat "$TMP/port")" "$TMP/peerclient" 2>&1 | grep -v 'warning' || :) + n=0 + while server_alive "$spid" && [ "$n" -lt 100 ]; do sleep 0.1; n=$((n + 1)); done + stop_server "$spid" + said=$(cat "$TMP/server.out") + ok=1 + case "$got" in *"$3"*) ;; *) ok=0 ;; esac + case "$said" in *"$4"*) ;; *) ok=0 ;; esac + if [ "$ok" = 1 ]; then + echo " ok: $1" + else + echo " FAIL: $1: the server answered [$got] and said [$said]; want [$3] and [$4]" + fails=$((fails + 1)) + fi +} + +# #3324, the pure server: the client Finished, its alerts under the +# server's application keys. +run_cf_case "server: the right Finished" "ok" "CLOSED" "ACCEPTED" +run_cf_case "server: the Finished over two records" "split" "CLOSED" "ACCEPTED" +run_cf_case "server: a Finished MAC that does not match" "badmac" "ALERT 51 (app)" "REFUSED" +run_cf_case "server: verify_data one byte short (4.4.4, #3357)" "short" "ALERT 51 (app)" "REFUSED" +run_cf_case "server: verify_data one byte long (4.4.4, #3357)" "long" "ALERT 51 (app)" "REFUSED" +run_cf_case "server: a byte after the Finished in its record" "trailing" "ALERT 10 (app)" "REFUSED" +run_cf_case "server: a Certificate instead of the Finished" "wrongtype" "ALERT 10 (app)" "REFUSED" +run_cf_case "server: a record that does not authenticate" "badrecord" "ALERT 20 (app)" "REFUSED" +run_cf_case "server: the Finished in the clear" "plain" "ALERT 10 (app)" "REFUSED" +run_cf_case "server: application data before the Finished" "appdata" "ALERT 10 (app)" "REFUSED" + + +# #3320: KeyUpdate (RFC 8446 4.6.3). The client and the server move their +# receiving keys on at each KeyUpdate, answer update_requested once with +# their own before their next data however many asked, update their own +# keys on request (conn_key_update), and refuse a KeyUpdate that is not +# one byte of 0 or 1 or does not end its record, and more than 32 without +# data between them. +run_case "KeyUpdate: one, then data under the new keys" "post=ku1" "RECV 2" "POST ALERT 0" +run_case "KeyUpdate: 32 in a row" "post=ku32" "RECV 2" "POST ALERT 0" +run_case "KeyUpdate: 33 in a row" "post=kux33" "RECVERR" "POST ALERT 10" +run_case "KeyUpdate: split over two records" "post=kusplit" "RECV 2" "POST ALERT 0" +CENV="CLIENT_ECHO=1" run_case "KeyUpdate: update_requested is answered" "post=kureq1" "RECV 2" "POST KU DATA 2" +CENV="CLIENT_ECHO=1" run_case "KeyUpdate: five requests, one answer" "post=kureq5" "RECV 2" "POST KU DATA 2" +run_case "KeyUpdate: request_update 2" "post=kubad" "RECVERR" "POST ALERT 47" +run_case "KeyUpdate: a two-byte body" "post=kulong" "RECVERR" "POST ALERT 50" +run_case "KeyUpdate: another message after it in its record" "post=kutrail" "RECVERR" "POST ALERT 10" +CL=kuclient CENV="CLIENT_KU=0" run_case "KeyUpdate: the client updates its keys" "post=listen" "OK" "POST KU DATA 2" +CL=kuclient CENV="CLIENT_KU=1" run_case "KeyUpdate: the client asks for the server's" "post=listen" "OK" "POST KU DATA 2" +CL=kuclient CENV="CLIENT_KU=7" run_case "KeyUpdate: conn_key_update refuses request 7" "post=listen" "KUERR" "POST ALERT 0" +SENV="SERVER_READ=1" POST=ku1 run_cf_case "server KeyUpdate: one, then data" "ok" "POST DATA 2" "RECV 2" +SENV="SERVER_READ=1" POST=kux33 run_cf_case "server KeyUpdate: 33 in a row" "ok" "POST ALERT 10" "RECVERR" +SENV="SERVER_READ=1" POST=kureq5 run_cf_case "server KeyUpdate: five requests, one answer" "ok" "POST KU DATA 2" "RECV 2" +SENV="SERVER_READ=1" POST=kubad run_cf_case "server KeyUpdate: request_update 2" "ok" "POST ALERT 47" "RECVERR" +SENV="SERVER_READ=1" POST=kulong run_cf_case "server KeyUpdate: a two-byte body" "ok" "POST ALERT 50" "RECVERR" +SENV="SERVER_READ=1" POST=kutrail run_cf_case "server KeyUpdate: another message after it" "ok" "POST ALERT 10" "RECVERR" +SENV="SERVER_READ=1" POST=nstx run_cf_case "server: a NewSessionTicket from the client" "ok" "POST ALERT 10" "RECVERR" +SV=kuserver SENV="SERVER_KU=0" POST=listen run_cf_case "server KeyUpdate: the server updates its keys" "ok" "POST KU DATA 2" "ACCEPTED" +SV=kuserver SENV="SERVER_KU=1" POST=listen run_cf_case "server KeyUpdate: the server asks for the client's" "ok" "POST KU DATA 2" "ACCEPTED" + +# #3319: NewSessionTicket (RFC 8446 4.6.1): an empty ticket, bytes after +# the extensions, an early_data that is not four bytes and TLS flags that +# are empty are decode_error; a duplicate extension, one this client knows +# that a ticket may not carry, and flags ending in a zero byte are +# illegal_parameter; an extension it does not know is ignored. +NST=0000012c0102030401aa0004deadbeef +CENV="CLIENT_TICKET=1" run_case "ticket: one with early_data" "post=nst:${NST}0008002a000400004000" "TICKET early=16384 len=4" "POST ALERT 0" +CENV="CLIENT_TICKET=1" run_case "ticket: over two records" "post=nstsplit:${NST}0008002a000400004000" "TICKET early=16384 len=4" "POST ALERT 0" +CENV="CLIENT_TICKET=1" run_case "ticket: none behind 512 KiB of data, the wait bounded (#3356)" "post=stream:524288" "TICKETERR: no NewSessionTicket within the first 262144 bytes" "POST STREAMED" +run_case "ticket: an unknown extension" "post=nst:${NST}0004fafa0000" "RECV 2" "POST ALERT 0" +run_case "ticket: TLS flags" "post=nst:${NST}0006003e00020101" "RECV 2" "POST ALERT 0" +run_case "ticket: an empty ticket" "post=nstx:0000012c0102030401aa00000000" "RECVERR" "POST ALERT 50" +run_case "ticket: a byte after the extensions" "post=nstx:${NST}000000" "RECVERR" "POST ALERT 50" +run_case "ticket: early_data twice" "post=nstx:${NST}0010002a000400004000002a000400004000" "RECVERR" "POST ALERT 47" +run_case "ticket: early_data of three bytes" "post=nstx:${NST}0007002a0003000040" "RECVERR" "POST ALERT 50" +run_case "ticket: empty TLS flags" "post=nstx:${NST}0005003e000100" "RECVERR" "POST ALERT 50" +run_case "ticket: TLS flags ending in a zero byte" "post=nstx:${NST}0006003e00020100" "RECVERR" "POST ALERT 47" +run_case "ticket: a key_share" "post=nstx:${NST}000400330000" "RECVERR" "POST ALERT 47" +run_case "after: a CertificateRequest (no post-handshake auth offered)" "post=postcr" "RECVERR" "POST ALERT 10" + +# #3323: alerts (RFC 8446 6). user_canceled is read past (at most four in +# a row) and the close_notify after it ends the stream; TLS 1.3 has no +# other non-fatal alert, so one is answered with decode_error; a level +# that is neither 1 nor 2 is illegal_parameter; a fatal alert ends the +# connection unanswered. The same before the server's Finished, and on +# the pure server. +run_case "alert: four user_canceled, then data" "post=alerts:015a015a015a015a" "RECV 2" "POST ALERT 0" +run_case "alert: five user_canceled" "post=alertsx:015a015a015a015a015a" "RECVERR" "POST ALERT 10" +run_case "alert: user_canceled, then close_notify" "post=alertsx:015a0100" "RECV-EOF" "POST ALERT 0" +run_case "alert: unexpected_message at level 1" "post=alertsx:010a" "RECVERR" "POST ALERT 50" +run_case "alert: level 0x42" "post=alertsx:420a" "RECVERR" "POST ALERT 47" +run_case "alert: a fatal user_canceled" "post=alertsx:025a" "RECVERR" "POST CLOSED" +run_case "flight: user_canceled before EncryptedExtensions" "flight_alerts=015a" "OK" "FINISHED" +run_case "flight: unexpected_message at level 1" "flight_alerts=010a" "ERR" "ALERT 50" +run_case "flight: close_notify" "flight_alerts=0100" "ERR: the server closed the connection during the handshake" "CLOSED" +SENV="SERVER_READ=1" POST=alerts:015a015a015a015a run_cf_case "server alert: four user_canceled, then data" "ok" "POST DATA 2" "RECV 2" +SENV="SERVER_READ=1" POST=alertsx:015a015a015a015a015a run_cf_case "server alert: five user_canceled" "ok" "POST ALERT 10" "RECVERR" +SENV="SERVER_READ=1" POST=alertsx:010a run_cf_case "server alert: unexpected_message at level 1" "ok" "POST ALERT 50" "RECVERR" +SENV="SERVER_READ=1" POST=alertsx:420a run_cf_case "server alert: level 0x42" "ok" "POST ALERT 47" "RECVERR" +SENV="SERVER_READ=1" POST=alertsx:0100 run_cf_case "server alert: close_notify" "ok" "POST CLOSED" "EOF" + + +# #3325: HelloRetryRequest (RFC 8446 4.1.4, 4.2.8). The client follows one +# that asks for a cookie: the change_cipher_spec, then the same ClientHello +# with the cookie, in a record of version 0x0303, under the message_hash +# transcript. It refuses one naming a group it has a share for or did not +# offer, one asking for no change (illegal_parameter), an empty cookie +# (decode_error), a second one (unexpected_message), and a ServerHello +# after it with another suite (illegal_parameter). The pure server asks +# for a group it takes when the shares hold none, sends its +# change_cipher_spec after the HelloRetryRequest only, and refuses a second +# ClientHello without that share, with another session id or another +# suite (illegal_parameter) or after two change_cipher_spec records. +run_case "HRR: a cookie, followed" "hrr=cookie" "OK" "FINISHED" +run_case "HRR: x25519, which has a share" "hrr=group:001d" "ERR" "ALERT 47" +run_case "HRR: secp256r1, which has a share" "hrr=group:0017" "ERR" "ALERT 47" +run_case "HRR: secp521r1, never offered" "hrr=group:0019" "ERR" "ALERT 47" +run_case "HRR: secp384r1, named without a share, followed (#3369)" "hrr=group:0018" "OK" "FINISHED" +run_case "HRR: secp384r1, then a ServerHello for x25519 (#3369)" "hrr=group:0018;sh_group=x25519" "ERR" "ALERT 47" +run_case "HRR: asking for no change" "hrr=none" "ERR" "ALERT 47" +run_case "HRR: an empty cookie" "hrr=emptycookie" "ERR" "ALERT 50" +run_case "HRR: a second one" "hrr=cookie,twice" "ERR" "ALERT 10" +run_case "HRR: then a ServerHello with another suite" "hrr=cookie,suite" "ERR" "ALERT 47" +run_case "HRR: compression method 1 (#3364)" "hrr=cookie,comp" "ERR" "ALERT 50" +HRR=ok run_cf_case "server HRR: asks for x25519, then the handshake" "ok" "CLOSED" "ACCEPTED" +HRR=noshare run_cf_case "server HRR: the second ClientHello without the share" "ok" "HRR ALERT 47" "REFUSED" +HRR=sid run_cf_case "server HRR: the second ClientHello with another session id" "ok" "HRR ALERT 47" "REFUSED" +HRR=suite run_cf_case "server HRR: the second ClientHello with another suite" "ok" "HRR ALERT 47" "REFUSED" +HRR=ccs2 run_cf_case "server HRR: two change_cipher_spec records" "ok" "HRR ALERT 10" "REFUSED" + +# #3318: the ServerHello's fixed fields (RFC 8446 4.1.3): legacy_version +# 0x0303 and the session id echo, else illegal_parameter. (The PSK +# identity checks need a resumption: crypto_tls13_resume.) +run_case "SH: legacy_version 0x0302" "sh_version=0302" "ERR" "ALERT 47" +run_case "SH: a session id echo that is not the client's" "sh_sid=bad" "ERR" "ALERT 47" + + +# #3357: the pure server's ClientHello checks (RFC 8446 4.1.2, 4.2, 9.2), +# BoGo's server tests among them: compression other than null alone and +# a pre_shared_key not last are illegal_parameter, so are two key shares +# for one group; a duplicate extension, a server_name, key_share or +# certificate_authorities that does not parse, and bytes after the +# extensions are decode_error; no supported_groups or no key_share is +# missing_extension; another handshake type is unexpected_message. It +# takes a secp256r1 share (9.1 says it MUST), skips 0-RTT data it does not +# take (4.2.10), and sends its change_cipher_spec whatever the session id. +run_server_case "server CH: compression { 1, 0 }" "ch:comp_extra" "ALERT 47" +run_server_case "server CH: compression { 1 }" "ch:comp_nonull" "ALERT 47" +run_server_case "server CH: ALPN twice" "ch:dup_ext" "ALERT 50" +run_server_case "server CH: a byte after the server_name list" "ch:sni_trailing" "ALERT 50" +run_server_case "server CH: certificate_authorities empty" "ch:ca_empty" "ALERT 50" +run_server_case "server CH: two x25519 shares" "ch:ks_dup" "ALERT 47" +run_server_case "server CH: a byte after the key shares" "ch:ks_trailing" "ALERT 50" +run_server_case "server CH: no supported_groups" "ch:no_groups" "ALERT 109" +run_server_case "server CH: no key_share" "ch:no_keyshare" "ALERT 109" +run_server_case "server CH: pre_shared_key not last" "ch:psk_not_last" "ALERT 47" +run_server_case "server CH: handshake type 3" "ch:wrong_type" "ALERT 10" +run_server_case "server CH: a byte after the extensions" "ch:msg_trailing" "ALERT 50" +run_server_case "server CH: no session id, still a change_cipher_spec" "ch:empty_sid" "SERVERHELLO+CCS" +GROUP=p256 run_cf_case "server: a secp256r1 share alone" "ok" "CLOSED" "ACCEPTED" +EARLY=1 run_cf_case "server: 0-RTT data it does not take is skipped" "ok" "CLOSED" "ACCEPTED" + +[ "$fails" = 0 ] || fail "$fails case(s)" +echo " [PASS] $NAME" diff --git a/tests/integration/crypto_tls13_peer/test_tls13_stream.sh b/tests/integration/crypto_tls13_peer/test_tls13_stream.sh new file mode 100644 index 000000000..3207dc47b --- /dev/null +++ b/tests/integration/crypto_tls13_peer/test_tls13_stream.sh @@ -0,0 +1,54 @@ +#!/bin/sh +# A pure TLS connection's memory over a long stream (#3379): the pure client +# sends STREAM_MB MiB (200 by default) in 16 KiB records to the pure server, +# which then sends as much back. The record reader on each side keeps only +# what it has not consumed, so each reader's heap grows by less than +# GROWTH_MAX bytes (4 MiB) between its first 8 MiB and its last byte; a +# reader that kept everything it received grew by the whole stream. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +. "$ROOT/tests/lib/server_jobs.sh" +AE="$ROOT/build/ae" +NAME=crypto_tls13_stream +MB="${STREAM_MB:-200}" +GROWTH_MAX=4194304 + +[ -x "$AE" ] || { echo " [SKIP] $NAME: ae not built"; exit 0; } + +TMP="$(mktemp -d)" +SPID="" +cleanup() { + [ -n "$SPID" ] && stop_server "$SPID" + rm -rf "$TMP" || : + return 0 +} +trap cleanup EXIT +fail() { echo " [FAIL] $NAME: $1"; exit 1; } + +"$AE" build "$SCRIPT_DIR/stream.ae" -o "$TMP/stream" >"$TMP/b.log" 2>&1 \ + || { sed -n '1,12p' "$TMP/b.log"; fail "stream.ae did not build"; } + +ROLE=server STREAM_MB="$MB" PORT_OUT="$TMP/port" CERT_OUT="$TMP/cert.pem" "$TMP/stream" >"$TMP/server.out" 2>&1 & +SPID=$! +i=0 +while [ "$i" -lt 100 ] && [ ! -s "$TMP/port" ]; do sleep 0.1; i=$((i + 1)); done +[ -s "$TMP/port" ] || { cat "$TMP/server.out"; fail "the server never listened"; } +ROLE=client STREAM_MB="$MB" PORT="$(cat "$TMP/port")" CERT="$TMP/cert.pem" "$TMP/stream" >"$TMP/client.out" 2>&1 +n=0 +while server_alive "$SPID" && [ "$n" -lt 300 ]; do sleep 0.1; n=$((n + 1)); done +stop_server "$SPID" +SPID="" + +bytes=$((MB * 1048576)) +for who in server client; do + line=$(cat "$TMP/server.out" "$TMP/client.out" | grep "^$who read" | head -1) + case "$line" in + "$who read $bytes grew "*) ;; + *) echo " server: $(cat "$TMP/server.out")"; echo " client: $(cat "$TMP/client.out")"; fail "the $who did not read the whole stream" ;; + esac + grew=${line##* grew } + [ "$grew" -lt "$GROWTH_MAX" ] || fail "the $who's heap grew by $grew bytes over a $MB MiB stream" + echo " ok: the $who read $MB MiB, its heap grew by $grew bytes" +done +echo " [PASS] $NAME" diff --git a/tests/integration/crypto_tls13_resume/resumeclient.ae b/tests/integration/crypto_tls13_resume/resumeclient.ae new file mode 100644 index 000000000..42cf3e36d --- /dev/null +++ b/tests/integration/crypto_tls13_resume/resumeclient.ae @@ -0,0 +1,161 @@ +// The pure TLS client resuming a session (#3318, #3336, #3339): a full +// handshake with 127.0.0.1 RESUME_PORT for RESUME_HOST, trusting CAFILE, +// then the session ticket, then a resumption, with 0-RTT data EARLY when it +// is set. Prints one line per step: +// FULL / ERR: (not with FIXED_TICKET=1: see fixed_ticket) +// TICKET suite= early= / TICKETERR: +// RESUMED early=<1 when the server took the 0-RTT data> / RESUMEERR: +// SENT "AFTER" sent under the new connection's keys +// TICKET2 / TICKET2ERR: a ticket read under the server's keys +// With RESUME_READ=1 it reads one record after SENT instead of a ticket: +// RECV / RECV-EOF / RECVERR: . +// EARLY "X" sends X then CR LF; AFTER sets what goes after the handshake. + +import std.os +import std.tcp +import std.bytes +import std.string +import std.cryptography +import std.cryptography.tls13_client + +fn text_bytes(s: string) -> ptr { + n = string.length(s) + b = bytes.new(n) + i = 0 + while i < n { + bytes.set(b, i, string_char_at(s, i)) + i = i + 1 + } + return b +} + +extern string_char_at(str: string, index: int) -> int + +// The ticket resumepeer.ae knows (FIXED_TICKET=1): ticket abcd0001, nonce +// 00, a resumption secret of the byte 0x11, the suite RESUME_SUITE (hex), +// max_early_data_size 16384; TICKET_SUITE says another suite (0000 for none). +fn fixed_ticket() -> ptr { + // TICKET_SUITE, when set, is the suite the ticket says (0000: none). + suite_hex = os.getenv("RESUME_SUITE") + if os.getenv("TICKET_SUITE") != "" { suite_hex = os.getenv("TICKET_SUITE") } + suite = 0 + algo = "sha256" + hl = 32 + if suite_hex == "1301" { suite = 0x1301 } + if suite_hex == "1302" { + suite = 0x1302 + algo = "sha384" + hl = 48 + } + if suite_hex == "1303" { suite = 0x1303 } + st = malloc(sizeof(SessionTicket)) as *SessionTicket + tk = bytes.new(4) + bytes.set(tk, 0, 0xab) + bytes.set(tk, 1, 0xcd) + bytes.set(tk, 2, 0x00) + bytes.set(tk, 3, 0x01) + st.ticket = tk + st.ticket_len = 4 + st.nonce = bytes.new(1) + bytes.set(st.nonce, 0, 0) + st.nonce_len = 1 + st.age_add = 0 + st.lifetime = 300 + rm = bytes.new(hl) + k = 0 + while k < hl { + bytes.set(rm, k, 0x11) + k = k + 1 + } + st.res_master = rm + st.hash_algo = text_bytes(algo) + st.hash_len = hl + st.max_early_data = 16384 + st.cipher_suite = suite + return st as ptr +} + +extern malloc(size: int) -> ptr + +main() { + port, _perr = string.to_int(os.getenv("RESUME_PORT")) + host = os.getenv("RESUME_HOST") + t = null + if os.getenv("FIXED_TICKET") == "1" { + t = fixed_ticket() + } else { + sock, derr = tcp.connect("127.0.0.1", port) + if derr != "" { + println("ERR: connect: ${derr}") + return + } + conn, herr = tls13_client.connect_socket(sock, host, os.getenv("CAFILE")) + if herr != "" { + println("ERR: ${herr}") + return + } + println("FULL") + t1, terr = tls13_client.conn_get_ticket(conn) + tls13_client.close_conn(conn) + if terr != "" { + println("TICKETERR: ${terr}") + return + } + t = t1 + } + println("TICKET suite=${tls13_client.ticket_cipher_suite(t)} early=${tls13_client.ticket_max_early_data(t)}") + priv, _rerr = cryptography.random_buffer(32) + early = os.getenv("EARLY") + c2 = null + rerr2 = "" + if early != "" { + eb = text_bytes("${early}\r\n") + c2, rerr2 = tls13_client.connect_resume_0rtt("127.0.0.1", port, priv, t, bytes.view(eb)[0..bytes.length(eb)]) + bytes.free(eb) + } else { + c2, rerr2 = tls13_client.connect_resume("127.0.0.1", port, priv, t) + } + bytes.free(priv) + tls13_client.free_ticket(t) + if rerr2 != "" { + println("RESUMEERR: ${rerr2}") + return + } + println("RESUMED early=${tls13_client.conn_early_accepted(c2)}") + // AFTER: what to send under the new keys ("AFTER" by default); "GET" + // sends an HTTP/1.0 request line and the blank line after it. + what = os.getenv("AFTER") + if what == "" { what = "AFTER\n" } + if what == "GET" { what = "GET / HTTP/1.0\r\n\r\n" } + after = text_bytes(what) + serr = tls13_client.conn_send(c2, bytes.view(after)[0..bytes.length(after)]) + bytes.free(after) + if serr != "" { + println("SENDERR: ${serr}") + } else { + println("SENT") + } + if os.getenv("RESUME_READ") == "1" { + buf, n, rerr = tls13_client.conn_recv(c2) + if rerr != "" { + println("RECVERR: ${rerr}") + } else if buf == null { + println("RECV-EOF") + } else { + // The first line, up to 12 bytes of it: an HTTP status line. + head = 12 + if n < head { head = n } + println("RECV ${n} ${bytes.to_string(buf, head)}") + bytes.free(buf) + } + } else { + t2, t2err = tls13_client.conn_get_ticket(c2) + if t2err != "" { + println("TICKET2ERR: ${t2err}") + } else { + println("TICKET2") + tls13_client.free_ticket(t2) + } + } + tls13_client.close_conn(c2) +} diff --git a/tests/integration/crypto_tls13_resume/resumepeer.ae b/tests/integration/crypto_tls13_resume/resumepeer.ae new file mode 100644 index 000000000..55b482227 --- /dev/null +++ b/tests/integration/crypto_tls13_resume/resumepeer.ae @@ -0,0 +1,610 @@ +// A scripted TLS 1.3 server for the pure client's resumption (#3318, #3325, +// #3336, #3339): it takes the PSK of a ticket both sides know (TICKET below, +// its resumption secret RES_BYTE repeated, its suite RESUME_SUITE, hex), +// checks the client's binder, reads any 0-RTT data under the early traffic +// keys of the ticket's suite, and runs the abbreviated handshake, every +// secret computed here from the RFC 8446 7.1 schedule apart from +// tls13_client. PEER_CASE knobs (";"-separated): +// suite=HEX the ServerHello's cipher suite (default the ticket's) +// psk_id=N the selected_identity it names (default 0) +// early=accept take the 0-RTT data (early_data in EncryptedExtensions) +// hrr=group:HEX a HelloRetryRequest for that group first +// nokeyshare=1 no key_share in the ServerHello +// group=HEX the ServerHello's key_share names this group +// Writes its port to PEER_PORT_OUT, serves one connection, and prints a line +// per step: "CH binder= early=<0|1>", "EARLY ", then what the +// client sent: "ALERT ", "EOED", "FINISHED ", "DATA ", +// "CLOSED". After the client's data it sends "hi" under its own keys. + +import std.os +import std.fs +import std.tcp +import std.bytes +import std.string +import std.cryptography +import std.cryptography.x25519 +import std.cryptography.p256 +import std.cryptography.hmac +import std.cryptography.sha2 +import std.cryptography.tls13_hs +import std.cryptography.tls13_ks +import std.cryptography.tls13_kdf +import std.cryptography.tls13_record +import std.cryptography.tls13_client + +extern exit(code: int) +extern malloc(size: int) -> ptr +extern free(p: ptr) +extern string_char_at(str: string, index: int) -> int + +const TICKET = "abcd0001" +const NONCE = "00" +const RES_BYTE = 0x11 +const HRR_RANDOM = "cf21ad74e59a6111be1d8c021e65b891c2a211167abb8c5e079e09e2c8a8339c" + +// ---- knobs, hex, buffers, records (as peer.ae) ---- + +fn knob(name: string) -> string { + spec = os.getenv("PEER_CASE") + if spec == "" { return "" } + parts = string.split(spec, ";") + n = string.array_size(parts) + out = "" + i = 0 + while i < n { + p = string.array_get(parts, i) + pre = "${name}=" + if string.starts_with(p, pre) == 1 { out = string.substring(p, string.length(pre), string.length(p)) } + i = i + 1 + } + string.array_free(parts) + return out +} + +fn hexval(h: int) -> int { + if h >= 48 && h <= 57 { return h - 48 } + if h >= 97 && h <= 102 { return h - 87 } + return h - 55 +} + +fn from_hex(s: string) -> ptr { + n = string.length(s) / 2 + b = bytes.new(n) + i = 0 + while i < n { + bytes.set(b, i, hexval(string_char_at(s, 2 * i)) * 16 + hexval(string_char_at(s, 2 * i + 1))) + i = i + 1 + } + return b +} + +fn hex16(s: string) -> int { + b = from_hex(s) + v = ((bytes.get(b, 0) & 0xFF) << 8) | (bytes.get(b, 1) & 0xFF) + bytes.free(b) + return v +} + +struct Buf { + b: ptr + n: int +} + +fn buf_new() -> *Buf { + h = malloc(sizeof(Buf)) as *Buf + h.b = bytes.new(64) + h.n = 0 + return h +} + +fn put8(w: *Buf, v: int) { + bytes.set(w.b, w.n, v & 0xFF) + w.n = w.n + 1 +} + +fn put16(w: *Buf, v: int) { + put8(w, v >> 8) + put8(w, v) +} + +fn put24(w: *Buf, v: int) { + put8(w, v >> 16) + put8(w, v >> 8) + put8(w, v) +} + +fn put_bytes(w: *Buf, src: ptr, off: int, n: int) { + if n > 0 { bytes.copy_from_bytes(w.b, w.n, src, off, n) } + w.n = w.n + n +} + +fn buf_take(w: *Buf) -> ptr { + out = bytes.new(w.n) + if w.n > 0 { bytes.copy_from_bytes(out, 0, w.b, 0, w.n) } + bytes.free(w.b) + free(w as ptr) + return out +} + +struct Reader { + sock: ptr + buf: ptr + len: int + off: int +} + +fn next_record(r: *Reader) -> ptr { + while 1 == 1 { + avail = r.len - r.off + if avail >= 5 { + plen = ((bytes.get(r.buf, r.off + 3) & 0xFF) << 8) | (bytes.get(r.buf, r.off + 4) & 0xFF) + if avail >= 5 + plen { + rec = bytes.new(5 + plen) + bytes.copy_from_bytes(rec, 0, r.buf, r.off, 5 + plen) + r.off = r.off + 5 + plen + return rec + } + } + chunk = bytes.new(16384) + bytes.set(chunk, 16383, 0) + got, err = tcp.read_into(r.sock, bytes.view(chunk)[0..16384]) + if err != "" || got <= 0 { + bytes.free(chunk) + return null + } + bytes.copy_from_bytes(r.buf, r.len, chunk, 0, got) + r.len = r.len + got + bytes.free(chunk) + } +} + +fn send_raw(sock: ptr, rec: ptr) { + _n, _e = tcp.write_all(sock, bytes.view(rec)[0..bytes.length(rec)]) +} + +fn send_plain(sock: ptr, ct: int, msg: ptr) { + w = buf_new() + put8(w, ct) + put16(w, 0x0303) + put16(w, bytes.length(msg)) + put_bytes(w, msg, 0, bytes.length(msg)) + rec = buf_take(w) + send_raw(sock, rec) + bytes.free(rec) +} + +fn send_sealed(sock: ptr, ctx: ptr, ct: int, msg: ptr) { + rec = tls13_record.seal_record(ctx, ct, bytes.view(msg)[0..bytes.length(msg)]) + send_raw(sock, rec) + bytes.free(rec) +} + +fn hs_msg(typ: int, body: ptr) -> ptr { + w = buf_new() + put8(w, typ) + put24(w, bytes.length(body)) + put_bytes(w, body, 0, bytes.length(body)) + return buf_take(w) +} + +fn suite_params(cs: int) -> (int, int, string, int) { + if cs == tls13_hs.TLS_AES_256_GCM_SHA384 { return tls13_record.AEAD_AES_256_GCM, 32, "sha384", 48 } + if cs == tls13_hs.TLS_CHACHA20_POLY1305_SHA256 { return tls13_record.AEAD_CHACHA20_POLY1305, 32, "sha256", 32 } + return tls13_record.AEAD_AES_128_GCM, 16, "sha256", 32 +} + +fn ctx_of(algo: string, hl: int, aead: int, klen: int, s: ptr) -> ptr { + k = tls13_ks.write_key(algo, bytes.view(s)[0..hl], klen) + iv = tls13_ks.write_iv(algo, s) + ctx = tls13_record.new_aead(aead, bytes.view(k)[0..klen], iv) + bytes.free(k) + bytes.free(iv) + return ctx +} + +fn hash_of(algo: string, b: ptr, n: int) -> ptr { + ctx, _e = sha2.new(algo) + sha2.update(ctx, bytes.view(b)[0..n]) + return sha2.final_bytes(ctx) +} + +// ---- the ClientHello ---- + +// Where the extensions of ClientHello `ch` start, and end. +fn ch_exts(ch: ptr) -> (int, int) { + p = 4 + 2 + 32 + p = p + 1 + (bytes.get(ch, p) & 0xFF) + p = p + 2 + (((bytes.get(ch, p) & 0xFF) << 8) | (bytes.get(ch, p + 1) & 0xFF)) + p = p + 1 + (bytes.get(ch, p) & 0xFF) + el = ((bytes.get(ch, p) & 0xFF) << 8) | (bytes.get(ch, p + 1) & 0xFF) + return p + 2, p + 2 + el +} + +fn g16(b: ptr, p: int) -> int { + return ((bytes.get(b, p) & 0xFF) << 8) | (bytes.get(b, p + 1) & 0xFF) +} + +// The offset of extension `t`'s body in `ch`, and its length; (-1, 0) if absent. +fn find_ext(ch: ptr, t: int) -> (int, int) { + p, end = ch_exts(ch) + while p + 4 <= end { + et = g16(ch, p) + el = g16(ch, p + 2) + if et == t { return p + 4, el } + p = p + 4 + el + } + return -1, 0 +} + +// The key share of `group` in `ch`: (offset, length), or (-1, 0). +fn find_share(ch: ptr, group: int) -> (int, int) { + d, n = find_ext(ch, 51) + if d < 0 { return -1, 0 } + q = d + 2 + while q + 4 <= d + n { + g = g16(ch, q) + kl = g16(ch, q + 2) + if g == group { return q + 4, kl } + q = q + 4 + kl + } + return -1, 0 +} + +// 1 when the ClientHello's binder is HMAC(binder finished key, +// Transcript-Hash(`prefix`, then the ClientHello up to its binders)). +fn binder_ok(ch: ptr, prefix: ptr, algo: string, hl: int, early: ptr) -> int { + d, n = find_ext(ch, 41) + if d < 0 { return 0 } + il = g16(ch, d) + trunc = d + 2 + il + t = tls13_client.transcript_new() + if bytes.length(prefix) > 0 { tls13_client.transcript_add(t, bytes.view(prefix)[0..bytes.length(prefix)]) } + tls13_client.transcript_add(t, bytes.view(ch)[0..trunc]) + th = tls13_client.transcript_hash_algo(t, algo) + tls13_client.transcript_free(t) + bk = tls13_ks.binder_key(algo, early) + empty = bytes.new(0) + fk = tls13_kdf.expand_label(algo, bytes.view(bk)[0..hl], "finished", bytes.view(empty)[0..0], hl) + want = hmac.bytes(algo, bytes.view(fk)[0..hl], bytes.view(th)[0..hl]) + ok = 1 + if (bytes.get(ch, trunc + 2) & 0xFF) != hl { ok = 0 } + i = 0 + while i < hl && ok == 1 { + if (bytes.get(ch, trunc + 3 + i) & 0xFF) != (bytes.get(want, i) & 0xFF) { ok = 0 } + i = i + 1 + } + bytes.free(th); bytes.free(bk); bytes.free(empty); bytes.free(fk); bytes.free(want) + return ok +} + +// The next handshake record's payload, skipping change_cipher_spec, or null. +fn next_hello(r: *Reader) -> ptr { + rec = next_record(r) + while rec != null && (bytes.get(rec, 0) & 0xFF) == 0x14 { + bytes.free(rec) + rec = next_record(r) + } + if rec == null { return null } + if (bytes.get(rec, 0) & 0xFF) == 0x15 { + println("ALERT ${bytes.get(rec, 6) & 0xFF}") + exit(0) + } + n = bytes.length(rec) - 5 + m = bytes.new(n) + bytes.copy_from_bytes(m, 0, rec, 5, n) + bytes.free(rec) + return m +} + +// What the client sent next, in the clear or under `ctx`: "ALERT n", +// "CLOSED", or the opened record (content type, plaintext). +fn client_record(r: *Reader, ctx: ptr) -> (int, ptr) { + while 1 == 1 { + rec = next_record(r) + if rec == null { + println("CLOSED") + exit(0) + } + ct = bytes.get(rec, 0) & 0xFF + if ct == 0x15 { + println("ALERT ${bytes.get(rec, 6) & 0xFF}") + exit(0) + } + if ct == 0x17 { + o = tls13_record.OpenedRecord { content_type: 0, plaintext: null, plaintext_len: 0, alert: 0 } + oe = tls13_record.open_record(ctx, bytes.view(rec)[0..bytes.length(rec)], &o) + bytes.free(rec) + if oe != "" { + println("UNREADABLE: ${oe}") + exit(0) + } + if o.content_type == 21 { + println("ALERT ${bytes.get(o.plaintext, 1) & 0xFF}") + exit(0) + } + return o.content_type, o.plaintext + } + bytes.free(rec) + } +} + +fn text_of(b: ptr) -> string { + return bytes.to_string(b, bytes.length(b)) +} + +main() { + srv, lerr = tcp.listen_on("127.0.0.1", 0) + if lerr != "" { + println("FAIL: ${lerr}") + exit(1) + } + _pw = fs.write(os.getenv("PEER_PORT_OUT"), "${tcp.server_port(srv)}") + sock, aerr = tcp.accept(srv) + if aerr != "" { + println("FAIL: ${aerr}") + exit(1) + } + r = Reader { sock: sock, buf: bytes.new(4096), len: 0, off: 0 } + + tsuite = hex16(os.getenv("RESUME_SUITE")) + taead, tklen, talgo, thl = suite_params(tsuite) + // The PSK: HKDF-Expand-Label(resumption secret, "resumption", nonce). + rm = bytes.new(thl) + k0 = 0 + while k0 < thl { + bytes.set(rm, k0, RES_BYTE) + k0 = k0 + 1 + } + nonce = from_hex(NONCE) + psk = tls13_kdf.expand_label(talgo, bytes.view(rm)[0..thl], "resumption", bytes.view(nonce)[0..1], thl) + early = tls13_ks.early_secret_psk(talgo, bytes.view(psk)[0..thl]) + + ch = next_hello(&r) + if ch == null { + println("FAIL: no ClientHello") + exit(1) + } + empty = bytes.new(0) + ed_d, _edn = find_ext(ch, 42) + eflag = 0 + if ed_d >= 0 { eflag = 1 } + bflag = "bad" + if binder_ok(ch, empty, talgo, thl, early) == 1 { bflag = "ok" } + println("CH binder=${bflag} early=${eflag}") + + // 0-RTT data, under client_early_traffic_secret over the ClientHello, in + // the ticket's suite (#3339). + ectx = null + if eflag == 1 { + chh = hash_of(talgo, ch, bytes.length(ch)) + ets = tls13_ks.early_traffic_secret(talgo, early, bytes.view(chh)[0..thl]) + ectx = ctx_of(talgo, thl, taead, tklen, ets) + bytes.free(chh) + bytes.free(ets) + et, ep = client_record(&r, ectx) + if et == 23 { println("EARLY ${string.trim(text_of(ep))}") } + bytes.free(ep) + } + + tr = tls13_client.transcript_new() + tls13_client.transcript_add(tr, bytes.view(ch)[0..bytes.length(ch)]) + sid_off = 4 + 2 + 32 + sid_len = bytes.get(ch, sid_off) & 0xFF + sid = bytes.new(sid_len) + bytes.copy_from_bytes(sid, 0, ch, sid_off + 1, sid_len) + + suite = tsuite + sk = knob("suite") + if sk != "" { suite = hex16(sk) } + aead, klen, algo, hl = suite_params(suite) + group = tls13_hs.GROUP_X25519 + + hk = knob("hrr") + if hk != "" { + // A HelloRetryRequest asking for a share of the group (#3325), then + // the second ClientHello, whose binder is over message_hash and it. + group = hex16(string.substring(hk, 6, string.length(hk))) + w = buf_new() + put16(w, 0x0303) + rnd = from_hex(HRR_RANDOM) + put_bytes(w, rnd, 0, 32) + bytes.free(rnd) + put8(w, sid_len) + put_bytes(w, sid, 0, sid_len) + put16(w, suite) + put8(w, 0) + put16(w, 12) + put16(w, 0x002b) + put16(w, 2) + put16(w, 0x0304) + put16(w, 0x0033) + put16(w, 2) + put16(w, group) + hb = buf_take(w) + hrr = hs_msg(2, hb) + bytes.free(hb) + send_plain(sock, 22, hrr) + th1 = tls13_client.transcript_hash_algo(tr, algo) + tls13_client.transcript_free(tr) + mw = buf_new() + put8(mw, 254) + put24(mw, hl) + put_bytes(mw, th1, 0, hl) + put_bytes(mw, hrr, 0, bytes.length(hrr)) + prefix = buf_take(mw) + bytes.free(th1) + bytes.free(hrr) + tr = tls13_client.transcript_new() + tls13_client.transcript_add(tr, bytes.view(prefix)[0..bytes.length(prefix)]) + bytes.free(ch) + ch = next_hello(&r) + if ch == null { + println("CLOSED") + exit(0) + } + ed2, _edn2 = find_ext(ch, 42) + b2 = "bad" + if binder_ok(ch, prefix, talgo, thl, early) == 1 { b2 = "ok" } + e2 = 0 + if ed2 >= 0 { e2 = 1 } + println("CH2 binder=${b2} early=${e2}") + bytes.free(prefix) + tls13_client.transcript_add(tr, bytes.view(ch)[0..bytes.length(ch)]) + eflag = 0 + } + + // The ECDHE share: x25519, or secp256r1 after a HelloRetryRequest for it. + shared = null + ks = null + so, sl = find_share(ch, group) + if so < 0 { + println("FAIL: no key share for group ${group}") + exit(1) + } + cpub = bytes.new(sl) + bytes.copy_from_bytes(cpub, 0, ch, so, sl) + priv, _rerr = cryptography.random_buffer(32) + if group == tls13_hs.GROUP_SECP256R1 { + px, py = p256.scalar_mult_base(priv) + ks = bytes.new(65) + bytes.set(ks, 0, 4) + bytes.copy_from_bytes(ks, 1, px, 0, 32) + bytes.copy_from_bytes(ks, 33, py, 0, 32) + cx = bytes.new(32) + cy = bytes.new(32) + bytes.copy_from_bytes(cx, 0, cpub, 1, 32) + bytes.copy_from_bytes(cy, 0, cpub, 33, 32) + shared = p256.ecdh(priv, cx, cy) + bytes.free(px); bytes.free(py); bytes.free(cx); bytes.free(cy) + } else { + ks = x25519.base_point(priv) + s2, _xerr = x25519.agree(priv, cpub) + shared = s2 + } + + // The ServerHello: supported_versions, key_share, pre_shared_key. + sgroup = group + gk = knob("group") + if gk != "" { sgroup = hex16(gk) } + exts = buf_new() + put16(exts, 0x002b) + put16(exts, 2) + put16(exts, 0x0304) + if knob("nokeyshare") == "" { + put16(exts, 0x0033) + put16(exts, 4 + bytes.length(ks)) + put16(exts, sgroup) + put16(exts, bytes.length(ks)) + put_bytes(exts, ks, 0, bytes.length(ks)) + } + pid = 0 + pk = knob("psk_id") + if pk != "" { pid, _pe = string.to_int(pk) } + put16(exts, 0x0029) + put16(exts, 2) + put16(exts, pid) + e = buf_take(exts) + w2 = buf_new() + put16(w2, 0x0303) + srnd, _sr = cryptography.random_buffer(32) + put_bytes(w2, srnd, 0, 32) + put8(w2, sid_len) + put_bytes(w2, sid, 0, sid_len) + put16(w2, suite) + put8(w2, 0) + put16(w2, bytes.length(e)) + put_bytes(w2, e, 0, bytes.length(e)) + bytes.free(e) + shb = buf_take(w2) + sh = hs_msg(2, shb) + bytes.free(shb) + send_plain(sock, 22, sh) + tls13_client.transcript_add(tr, bytes.view(sh)[0..bytes.length(sh)]) + + // A client that refuses the ServerHello answers now, in the clear. + ready, _perr = tcp.poll(sock, 300) + if ready { + _ct, _cp = client_record(&r, null) + println("UNEXPECTED") + exit(0) + } + + // The handshake keys: the PSK's early secret, then the ECDHE. + th = tls13_client.transcript_hash_algo(tr, algo) + hsec = tls13_ks.handshake_secret(algo, early, bytes.view(shared)[0..bytes.length(shared)]) + c_hs = tls13_ks.traffic_secret(algo, hsec, "c hs traffic", bytes.view(th)[0..hl]) + s_hs = tls13_ks.traffic_secret(algo, hsec, "s hs traffic", bytes.view(th)[0..hl]) + srv_ctx = ctx_of(algo, hl, aead, klen, s_hs) + cli_ctx = ctx_of(algo, hl, aead, klen, c_hs) + + // EncryptedExtensions (early_data when taking the 0-RTT data), Finished. + accept = 0 + if eflag == 1 && knob("early") == "accept" { accept = 1 } + eew = buf_new() + if accept == 1 { + put16(eew, 4) + put16(eew, 0x002a) + put16(eew, 0) + } else { + put16(eew, 0) + } + eeb = buf_take(eew) + ee = hs_msg(8, eeb) + bytes.free(eeb) + send_sealed(sock, srv_ctx, 22, ee) + tls13_client.transcript_add(tr, bytes.view(ee)[0..bytes.length(ee)]) + th_ee = tls13_client.transcript_hash_algo(tr, algo) + svd = tls13_client.finished_verify_data_algo(s_hs, bytes.view(th_ee)[0..hl], algo, hl) + fin = tls13_hs.finished_msg(svd, hl) + send_sealed(sock, srv_ctx, 22, fin) + tls13_client.transcript_add(tr, bytes.view(fin)[0..bytes.length(fin)]) + + // The application traffic secrets, over ClientHello..server Finished + // (RFC 8446 7.1), EndOfEarlyData or not (#3336). + th_sf = tls13_client.transcript_hash_algo(tr, algo) + ms = tls13_ks.master_secret(algo, hsec) + c_ap = tls13_ks.traffic_secret(algo, ms, "c ap traffic", bytes.view(th_sf)[0..hl]) + s_ap = tls13_ks.traffic_secret(algo, ms, "s ap traffic", bytes.view(th_sf)[0..hl]) + + // EndOfEarlyData under the early traffic keys (4.5), then the Finished + // under the client's handshake keys, over the transcript with it. + if accept == 1 { + t1, p1 = client_record(&r, ectx) + if t1 == 22 && bytes.length(p1) == 4 && (bytes.get(p1, 0) & 0xFF) == 5 { + println("EOED") + tls13_client.transcript_add(tr, bytes.view(p1)[0..4]) + } else { + println("NO EOED (type ${t1})") + } + bytes.free(p1) + } + th_cf = tls13_client.transcript_hash_algo(tr, algo) + want = tls13_client.finished_verify_data_algo(c_hs, bytes.view(th_cf)[0..hl], algo, hl) + t2, p2 = client_record(&r, cli_ctx) + fok = "bad" + if t2 == 22 && bytes.length(p2) == 4 + hl && (bytes.get(p2, 0) & 0xFF) == 20 { + same = 1 + i = 0 + while i < hl { + if (bytes.get(p2, 4 + i) & 0xFF) != (bytes.get(want, i) & 0xFF) { same = 0 } + i = i + 1 + } + if same == 1 { fok = "ok" } + } + println("FINISHED ${fok}") + bytes.free(p2) + + // The client's data under its application keys, then ours. + actx = ctx_of(algo, hl, aead, klen, c_ap) + sctx = ctx_of(algo, hl, aead, klen, s_ap) + t3, p3 = client_record(&r, actx) + if t3 == 23 { println("DATA ${string.trim(text_of(p3))}") } else { println("RECORD ${t3}") } + bytes.free(p3) + hi = from_hex("6869") + send_sealed(sock, sctx, 23, hi) + bytes.free(hi) + nr = next_record(&r) + if nr != null { bytes.free(nr) } + tcp.close(sock) + exit(0) +} diff --git a/tests/integration/crypto_tls13_resume/test_tls13_resume.sh b/tests/integration/crypto_tls13_resume/test_tls13_resume.sh new file mode 100644 index 000000000..38a59b630 --- /dev/null +++ b/tests/integration/crypto_tls13_resume/test_tls13_resume.sh @@ -0,0 +1,261 @@ +#!/bin/sh +# The pure TLS client's session resumption and 0-RTT, against a scripted +# server (resumepeer.ae) that knows the ticket the client holds and +# computes every secret itself, and against `openssl s_server -early_data`. +# +# #3336 the application traffic secrets are over ClientHello..server +# Finished, whether or not the server took the early data: the +# EndOfEarlyData is the client Finished's only. +# #3352 the EndOfEarlyData goes under the early traffic keys. +# #3339 early data goes under the suite the ticket was issued under (a +# ChaCha20 ticket included), a ticket naming none sends none, and +# a server that takes it under another suite is refused. +# #3355 conn_early_accepted says whether the early data was taken. +# #3318 the server must take the one PSK identity offered, with a suite +# of the ticket's hash (illegal_parameter). +# #3353 a ServerHello without key_share (missing_extension) or naming a +# group the ClientHello has no share for (illegal_parameter). +# #3357 the pure server signs rsa_pss_rsae_sha384/512 and takes a +# secp256r1 share (openssl s_client checks both). +# #3325 a HelloRetryRequest asking for secp256r1 (also from s_server +# -groups P-256, and the pure server's to s_client): the second ClientHello +# carries its share, no early data, and a binder over the new +# transcript. +# +# The scripted part needs nothing beyond loopback. The OpenSSL part is +# skipped without an openssl whose s_server has -early_data. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +. "$ROOT/tests/lib/server_jobs.sh" +AE="$ROOT/build/ae" +NAME=crypto_tls13_resume + +[ -x "$AE" ] || { echo " [SKIP] $NAME: ae not built"; exit 0; } +unset SSL_CERT_FILE SSL_CERT_DIR || : + +TMP="$(mktemp -d)" +PIDS="" +cleanup() { + for p in $PIDS; do stop_server "$p"; done + rm -rf "$TMP" || : + return 0 +} +trap cleanup EXIT +fail() { echo " [FAIL] $NAME: $1"; exit 1; } + +for p in resumepeer resumeclient; do + "$AE" build "$SCRIPT_DIR/$p.ae" -o "$TMP/$p" >"$TMP/b_$p.log" 2>&1 \ + || { sed -n '1,12p' "$TMP/b_$p.log"; fail "$p did not build"; } +done +# The pure server of crypto_tls13_peer, for the s_client case. +"$AE" build "$ROOT/tests/integration/crypto_tls13_peer/server.ae" -o "$TMP/server" >"$TMP/b_server.log" 2>&1 \ + || { sed -n '1,12p' "$TMP/b_server.log"; fail "server did not build"; } + +fails=0 +# run_case +# TSUITE overrides the suite the client's ticket names. +run_case() { + rm -f "$TMP/port" "$TMP/peer.out" + RESUME_SUITE="$2" PEER_CASE="$3" PEER_PORT_OUT="$TMP/port" "$TMP/resumepeer" >"$TMP/peer.out" 2>&1 & + ppid=$! + PIDS="$PIDS $ppid" + i=0 + while [ "$i" -lt 100 ] && [ ! -s "$TMP/port" ]; do sleep 0.1; i=$((i + 1)); done + [ -s "$TMP/port" ] || { cat "$TMP/peer.out"; fail "$1: the peer never listened"; } + got=$(TICKET_SUITE="$TSUITE" RESUME_SUITE="$2" FIXED_TICKET=1 RESUME_READ=1 EARLY="$4" \ + RESUME_PORT="$(cat "$TMP/port")" RESUME_HOST=peer.example "$TMP/resumeclient" 2>&1 | tr '\n' '|') + n=0 + while server_alive "$ppid" && [ "$n" -lt 100 ]; do sleep 0.1; n=$((n + 1)); done + stop_server "$ppid" + seen=$(tr '\n' '|' <"$TMP/peer.out") + ok=1 + case "$got" in *"$5"*) ;; *) ok=0 ;; esac + case "$seen" in *"$6"*) ;; *) ok=0 ;; esac + if [ "$ok" = 1 ]; then + echo " ok: $1" + else + echo " FAIL: $1: client said [$got], peer saw [$seen]; want [$5] and [$6]" + fails=$((fails + 1)) + fi +} + +run_case "resume, no early data" 1301 "" "" "RESUMED early=0|SENT|RECV 2" "binder=ok early=0|FINISHED ok|DATA AFTER" +run_case "0-RTT taken, AES-128-GCM" 1301 "early=accept" HELLO "RESUMED early=1|SENT|RECV 2" "EARLY HELLO|EOED|FINISHED ok|DATA AFTER" +run_case "0-RTT taken, a ChaCha20-Poly1305 ticket" 1303 "early=accept" HELLO "RESUMED early=1|SENT|RECV 2" "EARLY HELLO|EOED|FINISHED ok|DATA AFTER" +run_case "0-RTT taken, AES-256-GCM-SHA384" 1302 "early=accept" HELLO "RESUMED early=1|SENT|RECV 2" "EARLY HELLO|EOED|FINISHED ok|DATA AFTER" +run_case "0-RTT refused" 1303 "" HELLO "RESUMED early=0|SENT|RECV 2" "EARLY HELLO|FINISHED ok|DATA AFTER" +TSUITE=0000 run_case "a ticket naming no suite sends no early data" 1301 "early=accept" HELLO "RESUMED early=0" "binder=ok early=0|FINISHED ok|DATA AFTER" +run_case "0-RTT taken under another suite than the ticket's" 1301 "suite=1303;early=accept" HELLO "RESUMEERR" "EARLY HELLO|ALERT 47" +run_case "a PSK identity never offered" 1301 "psk_id=1" "" "RESUMEERR" "ALERT 47" +run_case "a suite of another hash than the PSK's" 1301 "suite=1302" "" "RESUMEERR" "ALERT 47" +run_case "a ServerHello without key_share" 1301 "nokeyshare=1" "" "RESUMEERR" "ALERT 109" +run_case "a key_share for a group without a share" 1301 "group=0017" "" "RESUMEERR" "ALERT 47" +run_case "HRR for secp256r1" 1303 "hrr=group:0017" "" "RESUMED early=0|SENT|RECV 2" "CH2 binder=ok early=0|FINISHED ok|DATA AFTER" +run_case "HRR for secp256r1 after 0-RTT data" 1303 "hrr=group:0017;early=accept" HELLO "RESUMED early=0|SENT|RECV 2" "EARLY HELLO|CH2 binder=ok early=0|FINISHED ok|DATA AFTER" +run_case "HRR for x25519, which has a share" 1303 "hrr=group:001d" "" "RESUMEERR" "ALERT 47" + +# Against OpenSSL: a full handshake for the ticket, then a resumption with +# 0-RTT data that s_server -www takes as a line it ignores, then the +# request under the new application keys and its answer under the +# server's, in each suite. +if command -v openssl >/dev/null 2>&1 && openssl s_server -help 2>&1 | grep -q -- '-early_data'; then + . "$ROOT/tests/lib/test_openssl_conf.sh" + test_openssl_conf "$TMP" + MSYS2_ARG_CONV_EXCL='/CN=' openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \ + -keyout "$TMP/k.pem" -out "$TMP/c.pem" -days 2 -nodes -subj "/CN=localhost" \ + -addext "subjectAltName=DNS:localhost" -addext "basicConstraints=critical,CA:FALSE" \ + -addext "keyUsage=critical,digitalSignature" >"$TMP/ssl.log" 2>&1 \ + || { sed -n '1,12p' "$TMP/ssl.log"; fail "openssl could not make the certificate"; } + for cs in TLS_AES_128_GCM_SHA256:4865 TLS_CHACHA20_POLY1305_SHA256:4867 TLS_AES_256_GCM_SHA384:4866; do + suite=${cs%%:*} + num=${cs##*:} + SP="" + for try in 1 2 3 4 5; do + P=$(( 20000 + ($$ * 7 + try * 1009 + num) % 40000 )) + # -4: IPv4 only, as https_client_pure_tls; -naccept 2: the full + # handshake and the resumption, then it exits. + openssl s_server -4 -www -accept "$P" -tls1_3 -early_data -num_tickets 1 -naccept 2 \ + -ciphersuites "$suite" -cert "$TMP/c.pem" -key "$TMP/k.pem" >"$TMP/s.log" 2>&1 /dev/null; do sleep 0.1; w=$((w + 1)); done + if server_alive "$spid" && grep -q ACCEPT "$TMP/s.log" 2>/dev/null; then + SP=$P + break + fi + kill_server "$spid" + # OpenSSL 3.0 (Ubuntu 24.04's) refuses -early_data beside -www + # and exits saying so; 3.5 and 3.6 take it. No port changes that. + grep -q "Can't use -early_data" "$TMP/s.log" 2>/dev/null && break + done + if [ -z "$SP" ] && grep -q "Can't use -early_data" "$TMP/s.log" 2>/dev/null; then + echo " skip: openssl s_server -early_data, $suite: $(openssl version | cut -d' ' -f1-2) takes no -early_data beside -www" + continue + fi + [ -n "$SP" ] || { sed -n '1,8p' "$TMP/s.log"; fail "could not start openssl s_server -early_data"; } + got=$(RESUME_PORT="$SP" RESUME_HOST=localhost CAFILE="$TMP/c.pem" EARLY="X-Early: 1" AFTER=GET RESUME_READ=1 \ + "$TMP/resumeclient" 2>&1 | tr '\n' '|') + n=0 + while server_alive "$spid" && [ "$n" -lt 100 ]; do sleep 0.1; n=$((n + 1)); done + stop_server "$spid" + case "$got" in + *"TICKET suite=$num"*"RESUMED early=1|SENT|RECV "*"HTTP/1.0 200"*) echo " ok: openssl s_server -early_data, $suite" ;; + *) echo " FAIL: openssl s_server -early_data, $suite: client said [$got]; server [$(tr '\n' '|' <"$TMP/s.log")]"; fails=$((fails + 1)) ;; + esac + done + # A HelloRetryRequest from OpenSSL (#3325): s_server takes P-256 only, + # and a resuming ClientHello carries an x25519 share only, so the + # resumption needs a second ClientHello with a P-256 share and a binder + # over the new transcript. (No 0-RTT here: s_server's anti-replay spends + # a ticket offered with early data, so after the HelloRetryRequest it + # refuses the PSK, which it may.) + SP="" + for try in 1 2 3 4 5; do + P=$(( 20000 + ($$ * 7 + try * 1009 + 17) % 40000 )) + openssl s_server -4 -www -accept "$P" -tls1_3 -groups P-256 -num_tickets 1 -naccept 2 \ + -cert "$TMP/c.pem" -key "$TMP/k.pem" >"$TMP/s.log" 2>&1 /dev/null; do sleep 0.1; w=$((w + 1)); done + if server_alive "$spid" && grep -q ACCEPT "$TMP/s.log" 2>/dev/null; then + SP=$P + break + fi + kill_server "$spid" + done + [ -n "$SP" ] || fail "could not start openssl s_server -groups P-256" + got=$(RESUME_PORT="$SP" RESUME_HOST=localhost CAFILE="$TMP/c.pem" AFTER=GET RESUME_READ=1 \ + "$TMP/resumeclient" 2>&1 | tr '\n' '|') + n=0 + while server_alive "$spid" && [ "$n" -lt 100 ]; do sleep 0.1; n=$((n + 1)); done + stop_server "$spid" + case "$got" in + *"RESUMED early=0|SENT|RECV "*"HTTP/1.0 200"*) echo " ok: openssl s_server -groups P-256: a HelloRetryRequest on the resumption" ;; + *) echo " FAIL: openssl s_server -groups P-256: client said [$got]; server [$(tr '\n' '|' <"$TMP/s.log")]"; fails=$((fails + 1)) ;; + esac + + # And to the pure server (#3325): s_client sends a P-384 share only, the + # server asks for X25519, and the handshake completes on the second + # ClientHello. + rm -f "$TMP/port" "$TMP/server.out" + PEER_PORT_OUT="$TMP/port" "$TMP/server" >"$TMP/server.out" 2>&1 & + spid=$! + PIDS="$PIDS $spid" + i=0 + while [ "$i" -lt 100 ] && [ ! -s "$TMP/port" ]; do sleep 0.1; i=$((i + 1)); done + [ -s "$TMP/port" ] || fail "the pure server never listened" + openssl s_client -msg -connect "127.0.0.1:$(cat "$TMP/port")" -servername peer.example \ + -groups P-384:X25519 "$TMP/c.log" 2>&1 || : + n=0 + while server_alive "$spid" && [ "$n" -lt 100 ]; do sleep 0.1; n=$((n + 1)); done + stop_server "$spid" + hellos=$(grep -c '>>> TLS 1.3, Handshake .*ClientHello' "$TMP/c.log" || :) + # 'Peer Temp Key' from OpenSSL 3.2 on, 'Server Temp Key' before (Ubuntu's + # 3.0, macOS's LibreSSL). + if [ "$hellos" = 2 ] && grep -Eq '(Peer|Server) Temp Key: X25519' "$TMP/c.log" && grep -q ACCEPTED "$TMP/server.out"; then + echo " ok: openssl s_client -groups P-384:X25519: the pure server's HelloRetryRequest" + else + echo " FAIL: openssl s_client -groups P-384:X25519: $hellos ClientHellos, server said [$(tr '\n' '|' <"$TMP/server.out")]" + fails=$((fails + 1)) + fi + + # The pure server to s_client (#3357): an RSA key signing + # rsa_pss_rsae_sha384 and _sha512 when the client offers only those, + # and a secp256r1 share alone (RFC 8446 9.1: a TLS 1.3 implementation + # MUST support it). OpenSSL checks the signature and the key exchange. + MSYS2_ARG_CONV_EXCL='/CN=' openssl req -x509 -newkey rsa:2048 -keyout "$TMP/rk.pem" -out "$TMP/rc.pem" -days 2 -nodes \ + -subj "/CN=peer.example" -addext "subjectAltName=DNS:peer.example" >"$TMP/ssl.log" 2>&1 \ + || { sed -n '1,12p' "$TMP/ssl.log"; fail "openssl could not make the RSA certificate"; } + # s_client_case : both + # extended regular expressions must match s_client's output (#3429). + # OpenSSL 3.2 and later name the scheme and the group ("Peer signature + # type: rsa_pss_rsae_sha384", "Temp Key: ECDH, prime256v1"); 3.0 and + # LibreSSL name the signature's type and digest on two lines ("RSA-PSS", + # "Peer signing digest: SHA384") and the curve "P-256". With the flags + # naming one scheme or group, a completed handshake that matches both is + # that one. SCERT/SKEY name the server's PEM files. + s_client_case() { + sc_name="$1" + sc_want="$2" + sc_want2="$3" + shift 3 + rm -f "$TMP/port" "$TMP/server.out" + SERVER_CERT="$SCERT" SERVER_KEY="$SKEY" PEER_PORT_OUT="$TMP/port" "$TMP/server" >"$TMP/server.out" 2>&1 & + spid=$! + PIDS="$PIDS $spid" + i=0 + while [ "$i" -lt 100 ] && [ ! -s "$TMP/port" ]; do sleep 0.1; i=$((i + 1)); done + [ -s "$TMP/port" ] || fail "$sc_name: the pure server never listened" + openssl s_client -connect "127.0.0.1:$(cat "$TMP/port")" -servername peer.example "$@" "$TMP/c.log" 2>&1 || : + n=0 + while server_alive "$spid" && [ "$n" -lt 100 ]; do sleep 0.1; n=$((n + 1)); done + stop_server "$spid" + if grep -Eq "$sc_want" "$TMP/c.log" && grep -Eq "$sc_want2" "$TMP/c.log" && + grep -q ACCEPTED "$TMP/server.out"; then + echo " ok: $sc_name" + else + echo " FAIL: $sc_name: server said [$(tr '\n' '|' <"$TMP/server.out")], s_client [$(grep -i 'peer sig\|temp key\|alert\|error' "$TMP/c.log" | tr '\n' '|')]" + fails=$((fails + 1)) + fi + } + SCERT="$TMP/rc.pem" SKEY="$TMP/rk.pem" s_client_case "openssl s_client -sigalgs rsa_pss_rsae_sha384" \ + "Peer signature type: (rsa_pss_rsae_sha384|RSA-PSS)" \ + "Peer signature type: rsa_pss_rsae_sha384|Peer signing digest: SHA384" -sigalgs rsa_pss_rsae_sha384 + SCERT="$TMP/rc.pem" SKEY="$TMP/rk.pem" s_client_case "openssl s_client -sigalgs rsa_pss_rsae_sha512" \ + "Peer signature type: (rsa_pss_rsae_sha512|RSA-PSS)" \ + "Peer signature type: rsa_pss_rsae_sha512|Peer signing digest: SHA512" -sigalgs rsa_pss_rsae_sha512 + SCERT="" SKEY="" s_client_case "openssl s_client -groups P-256" \ + "Temp Key: ECDH, (prime256v1|P-256)" "Temp Key: ECDH, (prime256v1|P-256)" -groups P-256 +else + echo " skip: openssl s_server -early_data (no openssl, or no -early_data)" +fi + +[ "$fails" = 0 ] || fail "$fails case(s)" +echo " [PASS] $NAME" diff --git a/tests/integration/crypto_tls13_sig_algs/test_tls13_sig_algs.ae b/tests/integration/crypto_tls13_sig_algs/test_tls13_sig_algs.ae new file mode 100644 index 000000000..8ac0f02e3 --- /dev/null +++ b/tests/integration/crypto_tls13_sig_algs/test_tls13_sig_algs.ae @@ -0,0 +1,112 @@ +// Certificate signatures beyond SHA-256 RSA and P-256/P-384 ECDSA (#3307). +// +// tls13_cert verified a certificate's signature only for +// sha256WithRSAEncryption, ecdsa-with-SHA256 on a P-256 key and +// ecdsa-with-SHA384 on a P-384 key. Any other chain did not verify, so the +// pure TLS client refused real sites whose CAs sign with +// sha384WithRSAEncryption (www.bing.com, www.microsoft.com: Microsoft's and +// many DigiCert chains), and every chain through a P-521 or Ed25519 CA. +// Each chain below failed before the fix ("certificate does not chain to a +// trusted anchor") and verifies now, and the same chain with one bit of the +// leaf's signature flipped is refused. +// +// Fixtures, made once with openssl (`x509 -req -extfile`, valid 2026-01-01 +// to 2126-01-01), all under one P-256 root: +// rsaca RSA-3072 CA, signed ecdsa-with-SHA384 by the P-256 root (a hash +// longer than the curve); rsaleaf under it, sha384WithRSAEncryption +// p521ca P-521 CA; p521leaf under it, ecdsa-with-SHA512 +// edca Ed25519 CA; edleaf under it, Ed25519 + +import std.list +import std.bytes +import std.string +import std.cryptography.tls13_cert + +extern exit(code: int) + +fn pem_root() -> string { + return "-----BEGIN CERTIFICATE-----\nMIIBbTCCARSgAwIBAgIIAbG3Zcl3Rz4wCgYIKoZIzj0EAwIwGjEYMBYGA1UEAwwP\nQ2hhaW4gVGVzdCBSb290MCAXDTI2MDEwMTAwMDAwMFoYDzIxMjYwMTAxMDAwMDAw\nWjAaMRgwFgYDVQQDDA9DaGFpbiBUZXN0IFJvb3QwWTATBgcqhkjOPQIBBggqhkjO\nPQMBBwNCAAR9WijVAhsIJAknhWwdyIvLEtorzMadRjzVfyAQwKwOLawIHYNNOvPQ\nYmTm71x3tM534A0n3XtBlrp/j6L/QciLo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4G\nA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUkxMUuxXX9urrj1w+az33GyEVI20wCgYI\nKoZIzj0EAwIDRwAwRAIgKZ7Y0hdZRPElIh1HvcDAMb9XHTcp/okFAMtywwN7R1sC\nIBAyiGHEuLnkCWB476svZbEXO00Ct4B80nalck9pukIL\n-----END CERTIFICATE-----\n" +} + +fn pem_rsaca() -> string { + return "-----BEGIN CERTIFICATE-----\nMIIC3TCCAoKgAwIBAgIIJ08E2mjA6kYwCgYIKoZIzj0EAwMwGjEYMBYGA1UEAwwP\nQ2hhaW4gVGVzdCBSb290MCAXDTI2MDEwMTAwMDAwMFoYDzIxMjYwMTAxMDAwMDAw\nWjAcMRowGAYDVQQDDBFDaGFpbiBUZXN0IFJTQSBDQTCCAaIwDQYJKoZIhvcNAQEB\nBQADggGPADCCAYoCggGBAKBMhFYRaEO2114tfB1Jb8GlFhaGUqbdk/OZekv/dLOf\n9Tb6LG49svcdNYw4hp28pf/QUwh1nHNeTjFzxgYBhxbaytkO58VsetkJDoocCggM\nwXDEYMQp3L3RYQxiLpZvnR3cbAuQAIcnRoMs168vqzahKSrKp+FuVwzBFFF+XIGJ\nWJyhpa+r2Kdg/a5CCGqJqhF48/3QwRHbfWC6qTCBbaP423ZAxDBPNTxifkrLEdcF\nzd4Dt3eUxOpis6m557J2QcltCHluRi/zlcnOYGIPuCcv1gzJsrIPpgJDu2G2ZRaY\nM4JlJisH+nHjBWCTdg11MSyyIwuvseNX6/iSzMGZ1HwsROtxJ/+vkoXAbfvfY1H5\nzYApfhHpmgT+Z4guJN0nFJIwTENlEbYcjn3w+L+bUswHyYrDc9gzuK2E5UMyHDCA\nTt55rDnGW1Aw9nnsgp+lWHOY/fXkwo19dk8/IE9gaY5UwbCkRywf0c4lCoFGDM5E\nPp5OEasg7ZKrDYrdpXyYDwIDAQABo2MwYTAPBgNVHRMBAf8EBTADAQH/MA4GA1Ud\nDwEB/wQEAwIBBjAdBgNVHQ4EFgQUDnCPeVETvLxlQMa/WPiRQxKrwpAwHwYDVR0j\nBBgwFoAUkxMUuxXX9urrj1w+az33GyEVI20wCgYIKoZIzj0EAwMDSQAwRgIhAOIY\nE74i5QbjdPRm4LoxT2lMpysAgFCSMooaiKDD2QjCAiEAn5w3Ol4wOL22cuBoUmsG\nkvDwMdFR08wkWPQDcgSFamI=\n-----END CERTIFICATE-----\n" +} + +fn pem_rsaleaf() -> string { + return "-----BEGIN CERTIFICATE-----\nMIICxTCCAS2gAwIBAgIIK+vJ1jlCa44wDQYJKoZIhvcNAQEMBQAwHDEaMBgGA1UE\nAwwRQ2hhaW4gVGVzdCBSU0EgQ0EwIBcNMjYwMTAxMDAwMDAwWhgPMjEyNjAxMDEw\nMDAwMDBaMBYxFDASBgNVBAMMC3JzYS5leGFtcGxlMFkwEwYHKoZIzj0CAQYIKoZI\nzj0DAQcDQgAE3qiaig7tevoQ97qN9MfbVdyqM/u6+zRkL6orDuBUNb5RMHrSI/93\n5YPulg4EmJFoa7tXv+m44/tRseykHVv48qNaMFgwFgYDVR0RBA8wDYILcnNhLmV4\nYW1wbGUwHQYDVR0OBBYEFE9ilRo/qtPD8XwYdbcAZMjwpLFCMB8GA1UdIwQYMBaA\nFA5wj3lRE7y8ZUDGv1j4kUMSq8KQMA0GCSqGSIb3DQEBDAUAA4IBgQAEI2dW2NeV\nZm7mmKnsrlA5seQWCKV80FBsGOWgmqIHaw/5OShMx9DuMliVusvq7xG4LF+ZvOrY\n6EB14j1Tr2yMG828FInlBOgg14JG1yy42mr3kJPTpIQ3pLArRMe1iIXSI7SEAfWw\nS0P7rFm9QCwFzc+2MF55lVbchBNJtR2N4sc/rYg01EgvbiegVtbEHP93+Vz8MnLR\n5d1GSEXv9QKWZy/OhyoGZRuw7Aa5a7ChH6plmU10Y/z8yBSRjJaSkvSU58frlWgm\nwSt9HG20np/YoV9yMfYUcB+bIz47g09kFbulNbbzhfL90IktSw5kplFDctcSvgWJ\nfk7YRVtgn/4lmKH6zGBrmZj3lqQ2Crpc9LYogWyNwOM+iHPJCGiijXAlDhiXPdZu\nWaoQo3dGto7e0w037EomkTJByZz4rb4HO1oqd1N1//zuT2nHFrkvhtwAgHJP8Ig9\nUzCRJxRDW9UfZZ2ivCgCE+qqEuT5plI0ijfxs0UUfDdxHmdDALto+V0=\n-----END CERTIFICATE-----\n" +} + +fn pem_p521ca() -> string { + return "-----BEGIN CERTIFICATE-----\nMIIB1zCCAX2gAwIBAgIJAMykwDLCzrhhMAoGCCqGSM49BAMCMBoxGDAWBgNVBAMM\nD0NoYWluIFRlc3QgUm9vdDAgFw0yNjAxMDEwMDAwMDBaGA8yMTI2MDEwMTAwMDAw\nMFowHjEcMBoGA1UEAwwTQ2hhaW4gVGVzdCBQLTUyMSBDQTCBmzAQBgcqhkjOPQIB\nBgUrgQQAIwOBhgAEAT4StXYnHwW2ah7EDOE5I/pG/jFnvs4fcafuqJsGVyC7Vs7V\nZvIER++2LMOWHWs/0HAcB0BQYgA3UMgFnFmqtoQMAL3shp0Lj9Ubfdnyw1xdIBgL\ntCQJlpF84qIwKyGKoSRNGqv3EHH3N1AtygiDtraMC2FwteEIARbe4koRfuyJkcIh\no2MwYTAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQU\nE6UklSdj2oM3zJwnAY5nh5HuVoUwHwYDVR0jBBgwFoAUkxMUuxXX9urrj1w+az33\nGyEVI20wCgYIKoZIzj0EAwIDSAAwRQIhAL0tPfuy5L7+IA1684JUUD+mvmURIzkK\nqXgMSYKaXbCkAiAVG/W649QoYaf7ClVheoOulUEiKM8XKkowgZYcZ0SUzQ==\n-----END CERTIFICATE-----\n" +} + +fn pem_p521leaf() -> string { + return "-----BEGIN CERTIFICATE-----\nMIIBzjCCAS+gAwIBAgIJAM3bJ7OE0BrdMAoGCCqGSM49BAMEMB4xHDAaBgNVBAMM\nE0NoYWluIFRlc3QgUC01MjEgQ0EwIBcNMjYwMTAxMDAwMDAwWhgPMjEyNjAxMDEw\nMDAwMDBaMBcxFTATBgNVBAMMDHA1MjEuZXhhbXBsZTBZMBMGByqGSM49AgEGCCqG\nSM49AwEHA0IABDLjHsTfP3K9fygqVS+izCzpa5CPbj4iMJBkGmNzdDOurDgTUQ3N\n5iV8BELiTj/Q3xQ0N8CVrrGoz7O/IS1KQJyjWzBZMBcGA1UdEQQQMA6CDHA1MjEu\nZXhhbXBsZTAdBgNVHQ4EFgQUFlRW5+ioz2jLAjwWYSTbIdNhLbUwHwYDVR0jBBgw\nFoAUE6UklSdj2oM3zJwnAY5nh5HuVoUwCgYIKoZIzj0EAwQDgYwAMIGIAkIAkjKN\nRFv3HZtNwEsI1JDBiPWU1SQM4E/R/Q5lhU1n9Q82TcLiPbCwbTL3H1nFWDrPbNOk\nhoHkHieeWyJ+dcEN25MCQgGSUKBYyIt9QZ7EplwX9KhnxLWzhPEUf7DsahEvxARL\n6hIpxdRL0QS79nZT7FZfHKhPkScb7Vk7mBnkjUAGYFeKtg==\n-----END CERTIFICATE-----\n" +} + +fn pem_edca() -> string { + return "-----BEGIN CERTIFICATE-----\nMIIBaDCCAQ2gAwIBAgIJAI/1WnTBbkFhMAoGCCqGSM49BAMCMBoxGDAWBgNVBAMM\nD0NoYWluIFRlc3QgUm9vdDAgFw0yNjAxMDEwMDAwMDBaGA8yMTI2MDEwMTAwMDAw\nMFowIDEeMBwGA1UEAwwVQ2hhaW4gVGVzdCBFZDI1NTE5IENBMCowBQYDK2VwAyEA\nyxDV23LIWpJVGsNgmFy5yeogcDsH8KoenqIPaHEdEjWjYzBhMA8GA1UdEwEB/wQF\nMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBRXyp4cMkB+9hB6yhuK1eby\nS0JbwTAfBgNVHSMEGDAWgBSTExS7Fdf26uuPXD5rPfcbIRUjbTAKBggqhkjOPQQD\nAgNJADBGAiEAsXBPcihoJvR+VKBpvv61kxwRAHkIozw4Kk/UxMEU/bwCIQCXmB0s\nBV5idTGYFAprSmlO3TsCdgdYbzeqeGIXLUloAw==\n-----END CERTIFICATE-----\n" +} + +fn pem_edleaf() -> string { + return "-----BEGIN CERTIFICATE-----\nMIIBdjCCASigAwIBAgIJAKMYIsZsFQ6OMAUGAytlcDAgMR4wHAYDVQQDDBVDaGFp\nbiBUZXN0IEVkMjU1MTkgQ0EwIBcNMjYwMTAxMDAwMDAwWhgPMjEyNjAxMDEwMDAw\nMDBaMBUxEzARBgNVBAMMCmVkLmV4YW1wbGUwWTATBgcqhkjOPQIBBggqhkjOPQMB\nBwNCAARoNfqttJP/W/abMLnfE1WLfLQB3ywk9AsCZCX0EKFAqVTH/tuYpgjxej6n\nhI7YOkPNei98e6UJdpaHrG7DsVlOo1kwVzAVBgNVHREEDjAMggplZC5leGFtcGxl\nMB0GA1UdDgQWBBQ/syu84NbOSluGvOxthHQJfSmdaTAfBgNVHSMEGDAWgBRXyp4c\nMkB+9hB6yhuK1ebyS0JbwTAFBgMrZXADQQDjpeIgY2diOf/l5qYCTeGWnmm9usZ2\neNLehwsT8n48M5egIZweTT3vlc4nuv3l87ql/xKel05lj1wuSLWzruwB\n-----END CERTIFICATE-----\n" +} + +fn certs(pem: string) -> ptr { + l = list_new() + tls13_cert.trust_store_add_pem(l, pem) + return l +} + +// validate_chain(leaf, [ca], [root]) for a server in 2027, with the last +// byte of the leaf's signature flipped when `tamper` is 1. +fn chain_result(leaf_pem: string, ca_pem: string, tamper: int) -> string { + anchors = certs(pem_root()) + inters = certs(ca_pem) + leaves = certs(leaf_pem) + r = "the leaf fixture does not parse" + if list_size(leaves) == 1 { + leaf = list.at(leaves, 0) as *LeafCert + if tamper == 1 { + k = leaf.signature_len - 1 + bytes.set(leaf.signature, k, (bytes.get(leaf.signature, k) ^ 1) & 0xFF) + } + r = tls13_cert.validate_chain(leaf, inters, anchors, "20270101000000", tls13_cert.PURPOSE_SERVER, -1) + } + tls13_cert.free_anchors(anchors) + tls13_cert.free_anchors(inters) + tls13_cert.free_anchors(leaves) + return r +} + +// 0 when the chain verifies and its tampered copy does not, else 1. +fn check(label: string, leaf_pem: string, ca_pem: string) -> int { + fails = 0 + good = chain_result(leaf_pem, ca_pem, 0) + if string.equals(good, "") == 1 { + println("ok ${label}") + } else { + println("FAIL ${label}: refused (${good})") + fails = 1 + } + bad = chain_result(leaf_pem, ca_pem, 1) + if string.equals(bad, "") != 1 { + println("ok ${label}, tampered: refused") + } else { + println("FAIL ${label}, tampered: accepted") + fails = 1 + } + return fails +} + +main() { + fails = 0 + fails = fails + check("sha384WithRSAEncryption (RSA-3072 CA under ecdsa-with-SHA384 on P-256)", pem_rsaleaf(), pem_rsaca()) + fails = fails + check("ecdsa-with-SHA512 on a P-521 CA", pem_p521leaf(), pem_p521ca()) + fails = fails + check("Ed25519 CA", pem_edleaf(), pem_edca()) + if fails > 0 { + println("tls13_sig_algs: ${fails} FAILURES") + exit(1) + } + println("tls13_sig_algs: all checks pass") +} diff --git a/tests/integration/crypto_tls13_signature_check/test_tls13_signature_check.sh b/tests/integration/crypto_tls13_signature_check/test_tls13_signature_check.sh new file mode 100644 index 000000000..22e5a39c9 --- /dev/null +++ b/tests/integration/crypto_tls13_signature_check/test_tls13_signature_check.sh @@ -0,0 +1,145 @@ +#!/bin/sh +# The pure TLS server checks each CertificateVerify signature against its +# certificate's key before sending it (#3376). +# +# A signature computed wrongly (a fault in the arithmetic, a bit flipped in +# memory) can disclose the private key: one faulty RSA-CRT signature +# factors the modulus, and two of one message under a deterministic nonce +# give the ECDSA or Ed25519 key. The server never sends one that does not +# verify; it ends the handshake with internal_error instead. +# +# The pure server of crypto_tls13_peer (tls13_server.accept, which takes +# the chain and key as given, with no pair check) and openssl s_client, for +# each scheme the server signs: ECDSA P-256, P-384 and P-521, Ed25519, and +# RSA as rsa_pss_rsae_sha256, _sha384 and _sha512. +# +# 1. The certificate with its own key: s_client completes and names the +# scheme, so the check passes every good signature. +# 2. The certificate with another key of its type, standing in for a +# signature gone wrong: the server refuses to send it and says so, and +# s_client gets internal_error (80), not a signature it rejects (a +# server that sent it made s_client fail with decrypt_error or a bad +# signature). +# 3. The key with one bit of its shielded form flipped, for an EC, an +# Ed25519 and an RSA key: the server refuses to use it (#3376). +# +# Skipped without openssl. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +. "$ROOT/tests/lib/server_jobs.sh" +AE="$ROOT/build/ae" +NAME=crypto_tls13_signature_check + +[ -x "$AE" ] || { echo " [SKIP] $NAME: ae not built"; exit 0; } +command -v openssl >/dev/null 2>&1 || { echo " [SKIP] $NAME: no openssl"; exit 0; } +unset SSL_CERT_FILE SSL_CERT_DIR || : + +TMP="$(mktemp -d)" +. "$ROOT/tests/lib/test_openssl_conf.sh" +test_openssl_conf "$TMP" +PIDS="" +cleanup() { + for p in $PIDS; do stop_server "$p"; done + rm -rf "$TMP" || : + return 0 +} +trap cleanup EXIT +fail() { echo " [FAIL] $NAME: $1"; exit 1; } +ssl() { "$@" >"$TMP/ssl.log" 2>&1 || { sed -n '1,10p' "$TMP/ssl.log"; fail "openssl: $*"; }; } + +"$AE" build "$ROOT/tests/integration/crypto_tls13_peer/server.ae" -o "$TMP/server" >"$TMP/b_server.log" 2>&1 \ + || { sed -n '1,12p' "$TMP/b_server.log"; fail "server did not build"; } + +# A self-signed certificate for peer.example and a second key, of key +# type $1 (the genpkey arguments follow). +make_pair() { + t="$1" + shift + ssl openssl genpkey "$@" -out "$TMP/$t.key" + ssl openssl genpkey "$@" -out "$TMP/$t.other.key" + MSYS2_ARG_CONV_EXCL='/CN=' ssl openssl req -x509 -new -key "$TMP/$t.key" -out "$TMP/$t.pem" -days 2 \ + -subj "/CN=peer.example" -addext "subjectAltName=DNS:peer.example" +} +make_pair p256 -algorithm EC -pkeyopt ec_paramgen_curve:P-256 +make_pair p384 -algorithm EC -pkeyopt ec_paramgen_curve:P-384 +make_pair p521 -algorithm EC -pkeyopt ec_paramgen_curve:P-521 +make_pair ed25519 -algorithm ED25519 +make_pair rsa -algorithm RSA -pkeyopt rsa_keygen_bits:2048 + +fails=0 +# serve : one connection; the server's +# output lands in $TMP/server.out and s_client's in $TMP/c.log. +serve() { + rm -f "$TMP/port" "$TMP/server.out" + SERVER_CERT="$1" SERVER_KEY="$2" PEER_PORT_OUT="$TMP/port" "$TMP/server" >"$TMP/server.out" 2>&1 & + spid=$! + PIDS="$PIDS $spid" + i=0 + while [ "$i" -lt 100 ] && [ ! -s "$TMP/port" ]; do sleep 0.1; i=$((i + 1)); done + [ -s "$TMP/port" ] || { cat "$TMP/server.out"; fail "the pure server never listened"; } + openssl s_client -connect "127.0.0.1:$(cat "$TMP/port")" -servername peer.example -sigalgs "$3" \ + "$TMP/c.log" 2>&1 || : + n=0 + while server_alive "$spid" && [ "$n" -lt 100 ]; do sleep 0.1; n=$((n + 1)); done + stop_server "$spid" +} + +# The scheme s_client says the server signed with. OpenSSL 3.2 and later +# name it ("Peer signature type: ecdsa_secp256r1_sha256"); 3.0 (Ubuntu 24.04) +# gives its type and digest on two lines ("Peer signature type: ECDSA", +# "Peer signing digest: SHA256"). With -sigalgs naming one scheme, the type +# and digest of a completed handshake are that scheme. +# signed_with <3.0 type> <3.0 digest, or nothing> +signed_with() { + grep -q "Peer signature type: $1" "$TMP/c.log" && return 0 + grep -q "Peer signature type: $2" "$TMP/c.log" || return 1 + [ -z "$3" ] || grep -q "Peer signing digest: $3" "$TMP/c.log" +} + +# check <3.0 type> <3.0 digest, or nothing> +check() { + serve "$TMP/$1.pem" "$TMP/$1.key" "$2" + if signed_with "$2" "$3" "$4" && grep -q ACCEPTED "$TMP/server.out"; then + echo " ok: $2: its own key's signature is sent" + else + echo " FAIL: $2, its own key: server [$(tr '\n' '|' <"$TMP/server.out")], s_client [$(grep -i 'signature type\|signing digest\|alert\|error' "$TMP/c.log" | tr '\n' '|')]" + fails=$((fails + 1)) + fi + serve "$TMP/$1.pem" "$TMP/$1.other.key" "$2" + if grep -q "REFUSED: .*not sent" "$TMP/server.out" && grep -qi "alert internal error\|alert number 80" "$TMP/c.log"; then + echo " ok: $2: a signature that does not verify is not sent (internal_error)" + else + echo " FAIL: $2, another key: server [$(tr '\n' '|' <"$TMP/server.out")], s_client [$(grep -i 'alert\|error' "$TMP/c.log" | tr '\n' '|')]" + fails=$((fails + 1)) + fi +} + +check p256 ecdsa_secp256r1_sha256 ECDSA SHA256 +check p384 ecdsa_secp384r1_sha384 ECDSA SHA384 +check p521 ecdsa_secp521r1_sha512 ECDSA SHA512 +check ed25519 ed25519 Ed25519 +check rsa rsa_pss_rsae_sha256 RSA-PSS SHA256 +check rsa rsa_pss_rsae_sha384 RSA-PSS SHA384 +check rsa rsa_pss_rsae_sha512 RSA-PSS SHA512 + +# 3. The key is kept shielded (std.cryptography.shield): one bit flipped in +# its shielded form, as a bit flipped in memory would, and the server +# refuses to sign with it and ends the handshake with internal_error, for +# each kind of key the shield holds (an EC scalar, an Ed25519 seed, an RSA +# key's DER). +for c in "p256 ecdsa_secp256r1_sha256" "ed25519 ed25519" "rsa rsa_pss_rsae_sha256"; do + set -- $c + SERVER_CORRUPT_KEY=1 serve "$TMP/$1.pem" "$TMP/$1.key" "$2" + if grep -q "REFUSED: .*changed in memory" "$TMP/server.out" && grep -qi "alert internal error\|alert number 80" "$TMP/c.log"; then + echo " ok: $1: a shielded key with a bit changed is not used (internal_error)" + else + echo " FAIL: $1, corrupted shield: server [$(tr '\n' '|' <"$TMP/server.out")], s_client [$(grep -i 'alert\|error' "$TMP/c.log" | tr '\n' '|')]" + fails=$((fails + 1)) + fi +done + +[ "$fails" = 0 ] || fail "$fails case(s)" +echo " [PASS] $NAME" diff --git a/tests/integration/down_message_reserved/test_down_message_reserved.sh b/tests/integration/down_message_reserved/test_down_message_reserved.sh new file mode 100644 index 000000000..11b099295 --- /dev/null +++ b/tests/integration/down_message_reserved/test_down_message_reserved.sh @@ -0,0 +1,34 @@ +#!/bin/sh +# `Down` is the runtime's death notice (#2974): the compiler declares +# `message Down { ref: actor_ref, reason: string }` and the runtime writes its +# payload, so a program's own `message Down` is refused, naming the shape, +# before the C compiler could see two structs of one name. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +AE_TMP="$(mktemp -d)" +trap 'rm -rf "$AE_TMP"' EXIT + +cat > "$AE_TMP/bad.ae" << 'AE' +message Down { who: int } +actor Watcher { + state n = 0 + receive { Down(who) -> { n = n + who } } +} + +main() { + w = spawn(Watcher()) + w ! Down { who: 1 } + wait_for_idle() +} +AE +if out=$(AETHER_HOME="$ROOT" "$ROOT/build/ae" build "$AE_TMP/bad.ae" -o "$AE_TMP/bad" 2>&1); then + echo " [FAIL] down_message_reserved: a program's own message Down compiled" + exit 1 +fi +if ! echo "$out" | grep -q "'Down' is the runtime's death notice"; then + echo " [FAIL] down_message_reserved: the error does not say Down is the runtime's" + echo "$out" | sed 's/^/ /' | head -10 + exit 1 +fi +echo " [PASS] down_message_reserved" diff --git a/tests/integration/emit_lib_accounts/accts.ae b/tests/integration/emit_lib_accounts/accts.ae new file mode 100644 index 000000000..f85e0c0b2 --- /dev/null +++ b/tests/integration/emit_lib_accounts/accts.ae @@ -0,0 +1,95 @@ +// The library of the execution-accounts test (#2954): exports that allocate +// cap-aware memory (strings, a list, a JSON document), keep it for the host +// or let it go, loop, and call a host service that runs another export. +import std.string +import std.list +import std.json + +extern host_service(which: int) + +fn build(n: int) -> string { + s = "" + i = 0 + while i < n { + s = string.concat(s, "0123456789abcdef") + i = i + 1 + } + return s +} + +// A string of n * 16 bytes, handed to the host: still allocated when the +// call returns. +hand_over(n: int) -> string { + return build(n) +} + +// The length of a string of n * 16 bytes, which the call frees. +grow(n: int) -> int { + return string.length(build(n)) +} + +// A list of n strings, handed to the host. +keep_list(n: int) -> ptr { + l = list.new() + i = 0 + while i < n { + _ = list.add(l, string.concat("item-", "0123456789abcdef")) + i = i + 1 + } + return l +} + +// Frees what keep_list made. +drop_list(l: ptr) -> int { + n = list.size(l) + list.free(l) + return n +} + +// The array size of a JSON document, which the call frees. +parse_size(doc: string) -> int { + v, err = json.parse(doc) + if err != "" { + return -2 + } + n = json.array_size(v) + json.free(v) + return n +} + +// A loop a deadline cuts. +spin(n: int) -> int { + i = 0 + s = 0 + while i < n { + if i % 2 == 0 { + s = s + i + } else { + s = s - 1 + } + i = i + 1 + } + return s +} + +// Calls the host's service, which runs inner under an account of its own. +outer(which: int) -> int { + host_service(which) + return which + 1 +} + +inner(n: int) -> int { + return grow(n) +} + +// The size of a list of n strings, which the call frees, interrupted or not. +count_list(n: int) -> int { + l = list.new() + defer list.free(l) + i = 0 + while i < n { + _ = list.add(l, string.concat("item-", "0123456789abcdef")) + i = i + 1 + } + return list.size(l) +} diff --git a/tests/integration/emit_lib_accounts/app.ae b/tests/integration/emit_lib_accounts/app.ae new file mode 100644 index 000000000..063649962 --- /dev/null +++ b/tests/integration/emit_lib_accounts/app.ae @@ -0,0 +1,31 @@ +// An Aether host of the library in binary form (#2954): the account in use +// is set through this program's std.host, the library allocates through its +// own runtime when it is built with one, and the accounts are one per +// process either way. +import accts +import std.host + +// A call past the account's allowance, raised again here as a panic. Its +// reason is a string this program allocates under the account, freed when +// this function returns. +fn refused(a: ptr) { + got = "" + try { + _n = accts.grow(4000) + } catch e { + got = e + } + println("refused: ${got} (outcome ${host.last_outcome()}, tripped ${host.account_tripped(a)})") +} + +main() { + a = host.account_new(65536, 0, host.DEADLINE_ELAPSED, null) + _ = host.account_use(a) + l = accts.keep_list(20) + println("charged: ${host.account_used(a) > 0}") + refused(a) + _ = host.account_use(null) + dropped = accts.drop_list(l) + println("freed outside the account: ${dropped} items, the account holds ${host.account_used(a)}") + host.account_free(a) +} diff --git a/tests/integration/emit_lib_accounts/foreign_first.c b/tests/integration/emit_lib_accounts/foreign_first.c new file mode 100644 index 000000000..80f7dd8dd --- /dev/null +++ b/tests/integration/emit_lib_accounts/foreign_first.c @@ -0,0 +1,68 @@ +/* Accounts of another layout loaded first (#2954, #3437). A host with no + * runtime of its own loads argv[1] (foreign_rv: a rendezvous of another + * layout), then two copies of the accounts library, argv[2] and argv[3]. + * Each copy finds the foreign rendezvous first, refuses accounts and says so + * once, and its calls still work. + * + * The host has no runtime because a program that links the runtime is the + * first image a copy looks in where executables export their symbols + * (macOS): there two_copies, which this case was part of, found its own + * accounts first and never read the foreign ones. + * + * foreign: the first copy refused, the second refused; a call still works: 160 outcome 0 */ +#include +#include +#include +#ifdef _WIN32 +#include +static void* lib_open(const char* p) { return (void*)LoadLibraryA(p); } +static void* lib_sym(void* h, const char* n) { + FARPROC f = GetProcAddress((HMODULE)h, n); + void* v; + memcpy(&v, &f, sizeof(v)); + return v; +} +#else +#include +static void* lib_open(const char* p) { return dlopen(p, RTLD_NOW | RTLD_LOCAL); } +static void* lib_sym(void* h, const char* n) { return dlsym(h, n); } +#endif + +typedef struct AetherExecAccount AetherExecAccount; +typedef struct { + uint64_t memory_bytes; + int64_t deadline_ms; + int deadline_counts; + AetherExecAccount* parent; +} Limits; +typedef int (*int_fn)(int); +typedef int (*outcome_fn)(void); +typedef AetherExecAccount* (*new_fn)(const Limits*); + +int main(int argc, char** argv) { + if (argc < 4) { printf("usage: foreign_first \n"); return 2; } + if (!lib_open(argv[1])) { printf("cannot load %s\n", argv[1]); return 2; } + void* a = lib_open(argv[2]); + void* b = lib_open(argv[3]); + if (!a || !b) { printf("cannot load the libraries\n"); return 2; } + void* pa = lib_sym(a, "aether_exec_account_new"); + void* pb = lib_sym(b, "aether_exec_account_new"); + void* pg = lib_sym(b, "aether_grow"); + void* po = lib_sym(b, "aether_last_outcome"); + if (!pa || !pb || !pg || !po) { printf("missing a symbol\n"); return 2; } + new_fn new_a, new_b; + int_fn grow; + outcome_fn outcome; + memcpy(&new_a, &pa, sizeof(pa)); + memcpy(&new_b, &pb, sizeof(pb)); + memcpy(&grow, &pg, sizeof(pg)); + memcpy(&outcome, &po, sizeof(po)); + Limits l = { 64 * 1024, 0, 0, NULL }; + AetherExecAccount* first = new_a(&l); + AetherExecAccount* second = new_b(&l); + int r = grow(10); + printf("foreign: the first copy %s, the second %s; a call still works: %d outcome %d\n", + first ? "made one" : "refused", second ? "made one" : "refused", r, outcome()); + fflush(stdout); + return 0; +} diff --git a/tests/integration/emit_lib_accounts/foreign_rv.c b/tests/integration/emit_lib_accounts/foreign_rv.c new file mode 100644 index 000000000..9cd9db6f2 --- /dev/null +++ b/tests/integration/emit_lib_accounts/foreign_rv.c @@ -0,0 +1,14 @@ +/* A library with a rendezvous of another layout (#2954): a later version + * of the accounts, say, which its record names as a copy of another Aether + * names its own (aether_rv_mark.h, #3445). A copy that finds it first reads + * its version and size, refuses, and never reads or writes the rest. */ +#include +#include "aether_rv_mark.h" + +static const struct { uint32_t version, size, account_size, limits_size; } g_foreign = { 99, 16, 0, 0 }; + +AETHER_RV_FN const void* foreign_accounts_rendezvous(void) { + return &g_foreign; +} + +AETHER_RV_MARK(foreign_accounts_mark, AETHER_RV_KIND_ACCOUNTS, foreign_accounts_rendezvous); diff --git a/tests/integration/emit_lib_accounts/hooks.c b/tests/integration/emit_lib_accounts/hooks.c new file mode 100644 index 000000000..63fda08d3 --- /dev/null +++ b/tests/integration/emit_lib_accounts/hooks.c @@ -0,0 +1,12 @@ +/* The library's host_service extern, forwarded to the host, which sets it. */ +typedef void (*service_fn)(int which); + +static service_fn g_service; + +void hooks_set(service_fn service) { + g_service = service; +} + +void host_service(int which) { + if (g_service) g_service(which); +} diff --git a/tests/integration/emit_lib_accounts/host.c b/tests/integration/emit_lib_accounts/host.c new file mode 100644 index 000000000..feb8710c6 --- /dev/null +++ b/tests/integration/emit_lib_accounts/host.c @@ -0,0 +1,310 @@ +/* A C host of accts.ae (#2954): execution accounts. Each line is one of the + * issue's acceptance items or the design's tests: + * + * isolated: A past its allowance, B and the host still usable + * kept: what A handed over stays charged while B runs; resuming A + * does not reset its ceiling + * credit: A's blocks freed under B, and by the host, credit A + * narrow: a child of A cannot spend more than A has left; freed, both + * are back to zero + * std: a list and a JSON document charged and refused like strings + * inject: a deterministic allocation failure under an account + * deadline: A's deadline ends A's call only; B's next call runs + * active: an ACTIVE deadline does not count time suspended; an + * ELAPSED one does + * nested: an export's frame puts its account back after a host + * service ran another export under another account + * thread: an account in use on one thread is refused on another + * baseline: the process count is back where it started + * + * HOST_STATIC: the library's generated C is linked into this program (one + * runtime). Otherwise argv[1] is the library, loaded at run time, and the + * host API is the library's own runtime's, or the shared runtime's argv[2]. */ +#include +#include +#include +#include +#include +#ifdef _WIN32 +#include +#define sleep_ms(ms) Sleep(ms) +#else +#include +static void sleep_ms(int ms) { struct timespec t = { ms / 1000, (ms % 1000) * 1000000L }; nanosleep(&t, NULL); } +#endif + +#ifndef AETHER_EXEC_ACCOUNT_DEFINED +#define AETHER_EXEC_ACCOUNT_DEFINED +typedef struct AetherExecAccount AetherExecAccount; +typedef struct { + uint64_t memory_bytes; + int64_t deadline_ms; + int deadline_counts; + AetherExecAccount* parent; +} AetherExecLimits; +#define AETHER_DEADLINE_ELAPSED 0 +#define AETHER_DEADLINE_ACTIVE 1 +#endif + +typedef int (*int_fn)(int); +typedef const void* (*str_fn)(int); +typedef void* (*ptr_fn)(int); +typedef int (*drop_fn)(void*); +typedef int (*doc_fn)(const char*); +typedef void (*release_fn)(const void*); +typedef void (*service_fn)(int); +typedef void (*hooks_set_fn)(service_fn); +typedef AetherExecAccount* (*new_fn)(const AetherExecLimits*); +typedef void (*free_fn)(AetherExecAccount*); +typedef int (*use_fn)(AetherExecAccount*, AetherExecAccount**); +typedef uint64_t (*used_fn)(const AetherExecAccount*); +typedef int (*tripped_fn)(const AetherExecAccount*); +typedef int (*outcome_fn)(void); +typedef uint64_t (*caps_used_fn)(void); +typedef void (*fail_after_fn)(int); + +static str_fn hand_over; +static int_fn grow, spin, outer, inner, count_list; +static ptr_fn keep_list; +static drop_fn drop_list; +static doc_fn parse_size; +static release_fn str_release; +static hooks_set_fn hooks; +static new_fn acct_new; +static free_fn acct_free; +static use_fn acct_use; +static used_fn acct_used; +static tripped_fn acct_tripped; +static outcome_fn outcome; +static caps_used_fn caps_used; +static fail_after_fn fail_after; + +#ifdef HOST_STATIC +#include "libaether.h" +extern const void* aether_hand_over(int); +extern int aether_grow(int), aether_spin(int), aether_outer(int), aether_inner(int), aether_count_list(int); +extern void* aether_keep_list(int); +extern int aether_drop_list(void*); +extern int aether_parse_size(const char*); +extern void aether_accts_string_release(const void*); +extern void hooks_set(service_fn); +extern uint64_t aether_caps_used_bytes(void); +extern void aether_test_fail_alloc_after(int); + +static int load(int argc, char** argv) { + (void)argc; (void)argv; + hand_over = aether_hand_over; grow = aether_grow; spin = aether_spin; + outer = aether_outer; inner = aether_inner; keep_list = (ptr_fn)aether_keep_list; + count_list = aether_count_list; + drop_list = (drop_fn)aether_drop_list; parse_size = aether_parse_size; + str_release = aether_accts_string_release; hooks = hooks_set; + acct_new = aether_exec_account_new; acct_free = aether_exec_account_free; + acct_use = aether_exec_use; acct_used = aether_exec_account_used; + acct_tripped = aether_exec_account_tripped; outcome = aether_last_outcome; + caps_used = aether_caps_used_bytes; fail_after = aether_test_fail_alloc_after; + return 0; +} +#else +#ifdef _WIN32 +typedef HMODULE lib_t; +static lib_t lib_open(const char* path) { return LoadLibraryA(path); } +static void* lib_sym(lib_t h, const char* name) { + FARPROC p = GetProcAddress(h, name); + void* v; + memcpy(&v, &p, sizeof(v)); + return v; +} +#else +#include +typedef void* lib_t; +static lib_t lib_open(const char* path) { return dlopen(path, RTLD_NOW | RTLD_GLOBAL); } +static void* lib_sym(lib_t h, const char* name) { return dlsym(h, name); } +#endif +#define BIND(var, from, name) do { void* p_ = lib_sym(from, name); \ + if (!p_) { printf("missing %s\n", name); return 1; } memcpy(&var, &p_, sizeof(p_)); } while (0) + +static int load(int argc, char** argv) { + if (argc < 2) { printf("usage: host []\n"); return 1; } + lib_t lib = lib_open(argv[1]); + if (!lib) { printf("cannot load %s\n", argv[1]); return 1; } + lib_t rt = argc > 2 ? lib_open(argv[2]) : lib; + if (!rt) { printf("cannot load %s\n", argv[2]); return 1; } + BIND(hand_over, lib, "aether_hand_over"); BIND(grow, lib, "aether_grow"); + BIND(spin, lib, "aether_spin"); BIND(outer, lib, "aether_outer"); + BIND(inner, lib, "aether_inner"); BIND(keep_list, lib, "aether_keep_list"); + BIND(count_list, lib, "aether_count_list"); + BIND(drop_list, lib, "aether_drop_list"); BIND(parse_size, lib, "aether_parse_size"); + BIND(str_release, lib, "aether_accts_string_release"); BIND(hooks, lib, "hooks_set"); + BIND(acct_new, rt, "aether_exec_account_new"); BIND(acct_free, rt, "aether_exec_account_free"); + BIND(acct_use, rt, "aether_exec_use"); BIND(acct_used, rt, "aether_exec_account_used"); + BIND(acct_tripped, rt, "aether_exec_account_tripped"); BIND(outcome, rt, "aether_last_outcome"); + BIND(caps_used, rt, "aether_caps_used_bytes"); BIND(fail_after, rt, "aether_test_fail_alloc_after"); + return 0; +} +#endif + +static AetherExecAccount* make_account(uint64_t bytes, int64_t ms, int counts, AetherExecAccount* parent) { + AetherExecLimits l = { bytes, ms, counts, parent }; + return acct_new(&l); +} +static void use(AetherExecAccount* a) { acct_use(a, NULL); } + +/* nested: the service runs inner under account g_b and leaves it in use. */ +static AetherExecAccount* g_b; +static int g_inner_outcome; +static void service(int which) { + (void)which; + acct_use(g_b, NULL); + (void)inner(10); + g_inner_outcome = outcome(); +} + +static AetherExecAccount* g_shared; +static int g_thread_rc; +static void* other_thread(void* arg) { + (void)arg; + g_thread_rc = acct_use(g_shared, NULL); + return NULL; +} + +int main(int argc, char** argv) { + if (load(argc, argv) != 0) return 1; + hooks(service); + /* What each kind of call allocates once and keeps (a parser's caches), made + * before the count the last line compares with. */ + (void)grow(2); (void)count_list(2); (void)parse_size("[1]"); drop_list(keep_list(2)); + uint64_t base = caps_used(); + + /* isolated + kept + credit */ + AetherExecAccount* A = make_account(128 * 1024, 0, 0, NULL); + AetherExecAccount* B = make_account(0, 0, 0, NULL); + use(A); + const void* kept = hand_over(2048); /* 32 KiB, A's while the host holds it */ + int o1 = outcome(); + uint64_t a_after_kept = acct_used(A); + use(B); + int b1 = grow(100); + int ob1 = outcome(); + uint64_t a_during_b = acct_used(A); + use(A); + int a2 = grow(4000); /* 64 KiB more, 128 at its peak: past what A has left */ + int oa2 = outcome(); + int ta2 = acct_tripped(A); + use(B); + int b2 = grow(3000); + int ob2 = outcome(); + use(NULL); + int h = grow(10); + int oh = outcome(); + printf("isolated: A %d outcome %d tripped %d; B %d outcome %d; host %d outcome %d\n", + a2, oa2, ta2, b2, ob2, h, oh); + printf("kept: hand_over outcome %d, A holds it (%s), unchanged while B ran (%s); B %d outcome %d\n", + o1, a_after_kept >= 2048 * 16 ? "yes" : "no", a_during_b == a_after_kept ? "yes" : "no", b1, ob1); + use(B); + void* list = keep_list(50); /* B's list */ + use(A); + int dropped = drop_list(list); /* freed under A: credits B */ + uint64_t b_after_drop = acct_used(B); + use(NULL); + str_release(kept); /* freed by the host: credits A */ + printf("credit: B's list freed under A (%d items) leaves B %llu; A's string freed by the host leaves A %llu\n", + dropped, (unsigned long long)b_after_drop, (unsigned long long)acct_used(A)); + + /* narrow */ + AetherExecAccount* P = make_account(64 * 1024, 0, 0, NULL); + AetherExecAccount* C = make_account(1024 * 1024, 0, 0, P); + use(C); + const void* held = hand_over(1024); /* 16 KiB, charged to C and P */ + uint64_t p_after = acct_used(P); + int c2 = grow(2048); /* 32 KiB, 64 at its peak: C allows it, P has 48 left */ + int oc2 = outcome(); + use(NULL); + str_release(held); + printf("narrow: the child's 16 KiB counted against its parent (%s); past the parent's limit %d outcome %d; freed, parent %llu child %llu\n", + p_after >= 1024 * 16 ? "yes" : "no", c2, oc2, + (unsigned long long)acct_used(P), (unsigned long long)acct_used(C)); + + /* std */ + AetherExecAccount* S = make_account(8 * 1024, 0, 0, NULL); + size_t n = 20000; + char* doc = (char*)malloc(n * 2 + 2); + if (!doc) return 1; + doc[0] = '['; + for (size_t i = 0; i < n; i++) { doc[1 + 2 * i] = '0'; doc[2 + 2 * i] = ','; } + doc[2 * n] = ']'; + doc[2 * n + 1] = '\0'; + use(S); + int js = parse_size(doc); + int ojs = outcome(); + int big = count_list(2000); + int ol = outcome(); + use(NULL); + int small = parse_size("[1,2,3]"); + printf("std: JSON past the allowance %d outcome %d; list past it %d outcome %d; outside it %d\n", + js, ojs, big, ol, small); + free(doc); + + /* inject */ + use(B); + fail_after(2); + int fi = grow(10); + int ofi = outcome(); + use(NULL); + printf("inject: %d outcome %d\n", fi, ofi); + + /* deadline */ + AetherExecAccount* D = make_account(0, 30, AETHER_DEADLINE_ELAPSED, NULL); + use(D); + int d1 = spin(2000000000); + int od1 = outcome(); + use(B); + int d2 = spin(1000); + int od2 = outcome(); + use(NULL); + printf("deadline: D %d outcome %d tripped %d; B %d outcome %d tripped %d\n", + d1, od1, acct_tripped(D), d2, od2, acct_tripped(B)); + + /* active */ + AetherExecAccount* E = make_account(0, 200, AETHER_DEADLINE_ACTIVE, NULL); + AetherExecAccount* F = make_account(0, 200, AETHER_DEADLINE_ELAPSED, NULL); + sleep_ms(300); /* suspended: E's budget is untouched */ + use(E); + int e1 = spin(1000); + int oe1 = outcome(); + use(F); + int f1 = spin(1000); + int of1 = outcome(); + use(E); + int e2 = spin(2000000000); + int oe2 = outcome(); + use(NULL); + printf("active: after 300 ms away, ACTIVE %d outcome %d, ELAPSED %d outcome %d; ACTIVE spent %d outcome %d\n", + e1, oe1, f1, of1, e2, oe2); + + /* nested */ + g_b = B; + use(A); + int ou = outer(1); + int oo = outcome(); + AetherExecAccount* in_use = NULL; + acct_use(NULL, &in_use); + printf("nested: outer %d outcome %d, inner outcome %d; in use after: %s\n", ou, oo, g_inner_outcome, + in_use == A ? "A" : in_use == B ? "B" : "other"); + + /* thread */ + g_shared = A; + use(A); + pthread_t t; + if (pthread_create(&t, NULL, other_thread, NULL) != 0) return 1; + pthread_join(t, NULL); + use(NULL); + int again = acct_use(A, NULL); + use(NULL); + printf("thread: another thread %d; this thread later %d\n", g_thread_rc, again); + + acct_free(A); acct_free(B); acct_free(C); acct_free(P); acct_free(S); + acct_free(D); acct_free(E); acct_free(F); + printf("baseline: %s\n", caps_used() == base ? "yes" : "no"); + fflush(stdout); + return 0; +} diff --git a/tests/integration/emit_lib_accounts/many.c b/tests/integration/emit_lib_accounts/many.c new file mode 100644 index 000000000..065a4fb5d --- /dev/null +++ b/tests/integration/emit_lib_accounts/many.c @@ -0,0 +1,67 @@ +/* #3411: more runtime copies share the accounts than the list of them once + * held (64). argv[1] (A) is loaded first and holds the accounts; the rest + * are the same library under other names, each making one call, so each + * copy joins A's accounts. With an account in use, the last copy's + * allocation is charged to it: that copy too was told an account exists. + * + * many: 70 copies joined; the last one's string charged: yes, freed: yes */ +#include +#include +#ifdef _WIN32 +#include +static void* lib_open(const char* p) { return (void*)LoadLibraryA(p); } +static void* lib_sym(void* h, const char* n) { return (void*)GetProcAddress((HMODULE)h, n); } +#else +#include +static void* lib_open(const char* p) { return dlopen(p, RTLD_NOW | RTLD_LOCAL); } +static void* lib_sym(void* h, const char* n) { return dlsym(h, n); } +#endif + +typedef struct AetherExecAccount AetherExecAccount; +typedef struct { + uint64_t memory_bytes; + int64_t deadline_ms; + int deadline_counts; + AetherExecAccount* parent; +} Limits; +typedef int (*int_fn)(int); +typedef const void* (*str_fn)(int); +typedef void (*release_fn)(const void*); +typedef AetherExecAccount* (*new_fn)(const Limits*); +typedef void (*free_fn)(AetherExecAccount*); +typedef int (*use_fn)(AetherExecAccount*, AetherExecAccount**); +typedef uint64_t (*used_fn)(const AetherExecAccount*); + +int main(int argc, char** argv) { + if (argc < 3) { printf("usage: many ...\n"); return 2; } + void* a = lib_open(argv[1]); + if (!a) { printf("cannot load %s\n", argv[1]); return 2; } + new_fn account_new = (new_fn)lib_sym(a, "aether_exec_account_new"); + free_fn account_free = (free_fn)lib_sym(a, "aether_exec_account_free"); + use_fn use = (use_fn)lib_sym(a, "aether_exec_use"); + used_fn used = (used_fn)lib_sym(a, "aether_exec_account_used"); + void* last = NULL; + int joined = 0; + for (int i = 2; i < argc; i++) { + void* b = lib_open(argv[i]); + int_fn grow = b ? (int_fn)lib_sym(b, "aether_grow") : NULL; + if (!grow) { printf("cannot load %s\n", argv[i]); return 2; } + if (grow(1) == 16) joined++; + last = b; + } + str_fn hand_over = (str_fn)lib_sym(last, "aether_hand_over"); + release_fn release = (release_fn)lib_sym(last, "aether_accts_string_release"); + Limits l = { 1 << 20, 0, 0, NULL }; + AetherExecAccount* acc = account_new(&l); + use(acc, NULL); + const void* s = hand_over(4); + uint64_t held = used(acc); + release(s); + uint64_t after = used(acc); + use(NULL, NULL); + account_free(acc); + printf("many: %d copies joined; the last one's string charged: %s, freed: %s\n", joined + 1, + held > 0 ? "yes" : "no", held > 0 && after == 0 ? "yes" : "no"); + fflush(stdout); + return 0; +} diff --git a/tests/integration/emit_lib_accounts/test_emit_lib_accounts.sh b/tests/integration/emit_lib_accounts/test_emit_lib_accounts.sh new file mode 100644 index 000000000..cbd4e4fe2 --- /dev/null +++ b/tests/integration/emit_lib_accounts/test_emit_lib_accounts.sh @@ -0,0 +1,225 @@ +#!/bin/sh +# #2954: execution accounts, budgets a host owns for executions that outlive +# one call. host.c prints one line per acceptance item of the issue and per +# test of the design (each line's meaning is in host.c); app.ae is an Aether +# host. Both run against the library in every form it ships in: linked into +# the host (one runtime), as a library with its own runtime (two runtime +# copies, whose accounts are one per process through the rendezvous), and +# on the shared runtime. two_copies.c is a C host with a runtime of its own +# loading the library with another: one lock and one thread per account +# across the copies, and an account made before the copies found each other +# refused by the copy that does not hold it. foreign_first.c, a host with no +# runtime of its own, loads foreign_rv.c (a rendezvous of another layout) +# before two copies, which both refuse accounts (#3437). unload.c +# unloads a copy that shared the accounts, then makes the first account, +# and many.c has 70 copies share them, past the 64 the list once held +# (#3411). +# +# Fixtures, not tests: accts.ae is a library and app.ae its importer; the +# driver builds both. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +AE="$ROOT/build/ae" +AETHERC="$ROOT/build/aetherc" +CC="${CC:-cc}" +export AETHER_HOME="$ROOT" +export AETHER_STACK_TRACE=0 + +[ -x "$AE" ] || { echo " [SKIP] emit_lib_accounts: $AE not built"; exit 0; } + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP" || true' EXIT +fail=0 +want='isolated: A 0 outcome 4 tripped 2; B 48000 outcome 0; host 160 outcome 0 +kept: hand_over outcome 0, A holds it (yes), unchanged while B ran (yes); B 1600 outcome 0 +credit: B'"'"'s list freed under A (50 items) leaves B 0; A'"'"'s string freed by the host leaves A 0 +narrow: the child'"'"'s 16 KiB counted against its parent (yes); past the parent'"'"'s limit 0 outcome 4; freed, parent 0 child 0 +std: JSON past the allowance 0 outcome 4; list past it 0 outcome 4; outside it 3 +inject: 0 outcome 5 +deadline: D 0 outcome 2 tripped 1; B 249000 outcome 0 tripped 0 +active: after 300 ms away, ACTIVE 249000 outcome 0, ELAPSED 0 outcome 2; ACTIVE spent 0 outcome 2 +nested: outer 2 outcome 0, inner outcome 0; in use after: A +thread: another thread -1; this thread later 0 +baseline: yes' +app_want='charged: true +refused: the call went past the memory cap (outcome 4, tripped 2) +freed outside the account: 20 items, the account holds 0' + +case "$(uname -s 2>/dev/null)" in + MINGW*|MSYS*|CYGWIN*|Windows_NT) ext=dll; exe=.exe; dl="" ;; + Darwin) ext=dylib; exe=""; dl="" ;; + Linux) ext=so; exe=""; dl="-ldl" ;; + *) ext=so; exe=""; dl="" ;; +esac + +# $1 = label, $2 = output +check() { + if [ "$2" != "$want" ]; then + echo " [FAIL] emit_lib_accounts: $1, C host:" + echo " got:"; printf '%s\n' "$2" | sed 's/^/ /' + echo " want:"; printf '%s\n' "$want" | sed 's/^/ /' + fail=1 + fi +} + +# 1. one runtime +if ! "$AETHERC" --emit=lib --with=extern "$SCRIPT_DIR/accts.ae" "$TMP/accts.c" > "$TMP/accts.log" 2>&1; then + echo " [FAIL] emit_lib_accounts: aetherc --emit=lib failed" + tail -5 "$TMP/accts.log" | sed 's/^/ /' + exit 1 +fi +# shellcheck disable=SC2046 +if ! "$CC" -O1 -DHOST_STATIC -I"$ROOT/include" -I"$ROOT/runtime" $("$AE" cflags --cflags) \ + "$TMP/accts.c" "$SCRIPT_DIR/hooks.c" "$SCRIPT_DIR/host.c" -o "$TMP/host_static" \ + $("$AE" cflags --libs) -lpthread > "$TMP/cc.log" 2>&1; then + echo " [FAIL] emit_lib_accounts: the one-runtime host did not build" + head -10 "$TMP/cc.log" | sed 's/^/ /' + exit 1 +fi +check "one runtime" "$("$TMP/host_static" 2>"$TMP/static.err" | tr -d '\r')" + +if ! "$CC" -O1 "$SCRIPT_DIR/host.c" -o "$TMP/host_dyn" $dl -lpthread > "$TMP/cc_dyn.log" 2>&1; then + echo " [FAIL] emit_lib_accounts: the loading host did not build" + head -10 "$TMP/cc_dyn.log" | sed 's/^/ /' + exit 1 +fi + +# $1 = label, $2 = extra build flags +build_lib() { + mkdir -p "$TMP/$1" + # shellcheck disable=SC2086 + (cd "$TMP/$1" && "$AE" build --emit=lib $2 --with=extern "$SCRIPT_DIR/accts.ae" \ + --extra "$SCRIPT_DIR/hooks.c" -o "$TMP/$1/libaccts.$ext" > "$TMP/$1/build.log" 2>&1) +} + +# $1 = label, $2 = library dir: the Aether program importing it in binary form +run_app() { + cp "$SCRIPT_DIR/app.ae" "$2/app.ae" + got="$(cd "$2" && "$AE" build app.ae -o app > app_build.log 2>&1 && \ + PATH="$2:$ROOT/build/shared:$PATH" \ + LD_LIBRARY_PATH="$2:$ROOT/build/shared${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \ + DYLD_LIBRARY_PATH="$2:$ROOT/build/shared${DYLD_LIBRARY_PATH:+:$DYLD_LIBRARY_PATH}" \ + ./app$exe 2>"$2/app.err" | tr -d '\r')" + if [ "$got" != "$app_want" ]; then + echo " [FAIL] emit_lib_accounts: $1, Aether host:" + echo " got:"; printf '%s\n' "$got" | sed 's/^/ /' + echo " want:"; printf '%s\n' "$app_want" | sed 's/^/ /' + tail -5 "$2/app_build.log" "$2/app.err" 2>/dev/null | sed 's/^/ /' + fail=1 + fi +} + +# 2. the library's own runtime: two copies in the Aether host +if ! build_lib own ""; then + echo " [FAIL] emit_lib_accounts: ae build --emit=lib failed" + tail -5 "$TMP/own/build.log" | sed 's/^/ /' + exit 1 +fi +check "its own runtime" "$("$TMP/host_dyn" "$TMP/own/libaccts.$ext" 2>"$TMP/dyn.err" | tr -d '\r')" +run_app "its own runtime" "$TMP/own" + +# $1 = label, $2 = what it printed, $3 = what it should +expect() { + if [ "$2" != "$3" ]; then + echo " [FAIL] emit_lib_accounts: $1:" + echo " got:"; printf '%s\n' "$2" | sed 's/^/ /' + echo " want:"; printf '%s\n' "$3" | sed 's/^/ /' + fail=1 + fi +} + +# Two copies in a C host: the program's (linked in) and the library's. +# shellcheck disable=SC2046 +if ! "$CC" -O1 -I"$ROOT/include" -I"$ROOT/runtime" $("$AE" cflags --cflags) \ + "$SCRIPT_DIR/two_copies.c" -o "$TMP/two_copies" \ + $("$AE" cflags --libs) -lpthread $dl > "$TMP/cc_two.log" 2>&1 || + ! "$CC" -shared -fPIC -I"$ROOT/runtime" "$SCRIPT_DIR/foreign_rv.c" -o "$TMP/foreign_rv.$ext" > "$TMP/cc_rv.log" 2>&1; then + echo " [FAIL] emit_lib_accounts: the two-copies host did not build" + head -10 "$TMP/cc_two.log" "$TMP/cc_rv.log" | sed 's/^/ /' + exit 1 +fi +expect "two copies" "$("$TMP/two_copies" "$TMP/own/libaccts.$ext" 2>"$TMP/two.err" | tr -d '\r')" \ +"shared: this copy's 1000 bytes charged 1000; the library's past the allowance 0 outcome 4, tripped 2 +thread: in use here, refused on another through this copy -1, through the library's -1; holds 0" +expect "two copies, an account made before the library loaded" \ + "$("$TMP/two_copies" "$TMP/own/libaccts.$ext" early 2>"$TMP/early.err" | tr -d '\r')" \ + "early: the library's copy shares the account: yes" +# The program's copy, which exports nothing on Windows and on Linux here (no +# -rdynamic), found by the library's by its record (#3445). +expect "two copies, an account put in use before the library loaded" \ + "$("$TMP/two_copies" "$TMP/own/libaccts.$ext" in_use 2>"$TMP/in_use.err" | tr -d '\r')" \ + "in use: the library's allocations are charged to the account in use: yes" +# Accounts of another layout loaded first, then two copies, in a host with no +# runtime of its own (#3437): where executables export their symbols, a +# host's own accounts are the first a copy finds. +cp "$TMP/own/libaccts.$ext" "$TMP/libaccts_c.$ext" +# shellcheck disable=SC2086 +if ! "$CC" -O1 "$SCRIPT_DIR/foreign_first.c" -o "$TMP/foreign_first" $dl > "$TMP/cc_ff.log" 2>&1; then + echo " [FAIL] emit_lib_accounts: the foreign-first host did not build" + head -10 "$TMP/cc_ff.log" | sed 's/^/ /' + exit 1 +fi +expect "two copies, accounts of another layout loaded first" \ + "$("$TMP/foreign_first" "$TMP/foreign_rv.$ext" "$TMP/own/libaccts.$ext" "$TMP/libaccts_c.$ext" 2>"$TMP/foreign.err" | tr -d '\r')" \ + "foreign: the first copy refused, the second refused; a call still works: 160 outcome 0" +expect "two copies, each copy names the layout once" \ + "$(grep -c "cannot share execution accounts" "$TMP/foreign.err")" "2" + +# #3411: a copy that shared the accounts and is unloaded is not written +# into when the first account is made afterwards. +cp "$TMP/own/libaccts.$ext" "$TMP/libaccts_b.$ext" +# shellcheck disable=SC2086 +if ! "$CC" -O1 "$SCRIPT_DIR/unload.c" -o "$TMP/unload" $dl > "$TMP/cc_unload.log" 2>&1; then + echo " [FAIL] emit_lib_accounts: the unload host did not build" + head -10 "$TMP/cc_unload.log" | sed 's/^/ /' + exit 1 +fi +expect "a copy unloaded after it shared the accounts" \ + "$("$TMP/unload" "$TMP/own/libaccts.$ext" "$TMP/libaccts_b.$ext" 2>"$TMP/unload.err" | tr -d '\r')" \ + "unload: B's call 160, unloaded 0; an account made and freed after: yes" + +# #3411: more copies than the list once held (64), the last one charged. +mkdir -p "$TMP/many" +copies="" +i=1 +while [ $i -lt 70 ]; do + cp "$TMP/own/libaccts.$ext" "$TMP/many/libaccts_$i.$ext" + copies="$copies $TMP/many/libaccts_$i.$ext" + i=$((i + 1)) +done +# shellcheck disable=SC2086 +if ! "$CC" -O1 "$SCRIPT_DIR/many.c" -o "$TMP/many_host" $dl > "$TMP/cc_many.log" 2>&1; then + echo " [FAIL] emit_lib_accounts: the many-copies host did not build" + head -10 "$TMP/cc_many.log" | sed 's/^/ /' + exit 1 +fi +# shellcheck disable=SC2086 +expect "70 copies sharing the accounts" \ + "$("$TMP/many_host" "$TMP/own/libaccts.$ext" $copies 2>"$TMP/many.err" | tr -d '\r')" \ + "many: 70 copies joined; the last one's string charged: yes, freed: yes" +rm -rf "$TMP/many" + +# 3. one shared runtime +if ls "$ROOT/build/shared/" 2>/dev/null | grep -q "aether"; then + if ! build_lib shared "--shared-runtime"; then + echo " [FAIL] emit_lib_accounts: ae build --emit=lib --shared-runtime failed" + tail -5 "$TMP/shared/build.log" | sed 's/^/ /' + exit 1 + fi + rt="$(ls "$ROOT/build/shared/"aether.dll "$ROOT/build/shared/"libaether.dylib \ + "$ROOT/build/shared/"libaether.so 2>/dev/null | head -1)" + check "--shared-runtime" "$(PATH="$ROOT/build/shared:$PATH" \ + LD_LIBRARY_PATH="$ROOT/build/shared${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \ + DYLD_LIBRARY_PATH="$ROOT/build/shared${DYLD_LIBRARY_PATH:+:$DYLD_LIBRARY_PATH}" \ + "$TMP/host_dyn" "$TMP/shared/libaccts.$ext" "$rt" 2>"$TMP/shared.err" | tr -d '\r')" + run_app "--shared-runtime" "$TMP/shared" + shared_note=", and on the shared runtime" +else + shared_note=" (no shared runtime in build/shared: that leg skipped)" +fi + +if [ "$fail" = 0 ]; then + echo " [PASS] emit_lib_accounts: two accounts on one thread keep their allowances, deadlines and blocks apart, a child narrows its parent, frees credit the account charged, and an Aether or C host's account charges a library with its own runtime (one lock, one thread per account across the copies, a copy of another layout refused, an unloaded copy never written into, 70 copies charged): one runtime, the library's own$shared_note" +fi +exit $fail diff --git a/tests/integration/emit_lib_accounts/two_copies.c b/tests/integration/emit_lib_accounts/two_copies.c new file mode 100644 index 000000000..19aeaa010 --- /dev/null +++ b/tests/integration/emit_lib_accounts/two_copies.c @@ -0,0 +1,135 @@ +/* Two runtime copies in one C host (#2954): this program links the runtime + * (one copy) and loads the library built with a runtime of its own (the + * other). A copy finds the accounts once, in the first loaded image that + * has them: this program, first in load order, by its record, though it + * exports nothing on Windows, nor on Linux without -rdynamic (#3445). + * + * two_copies the library loaded before the first account + * call: an account made and put in use through + * this program's copy is charged what either + * copy allocates, and is refused on another + * thread whichever copy asks. + * two_copies early an account made before the library loaded: + * the library's copy finds this program's + * accounts and shares it (#3445: it found its + * own, and refused it). + * two_copies in_use an account made and put in use before the + * library loaded: what the library allocates is + * charged to it (#3445: to nothing). + * + * Accounts of another layout loaded first are foreign_first.c's (#3437): + * where an executable exports its symbols (macOS), this program's own + * accounts are the first a copy finds, and a foreign one is never read. */ +#include +#include +#include +#include "libaether.h" +#include "aether_resource_caps.h" +#ifdef _WIN32 +#include +typedef HMODULE lib_t; +static lib_t lib_open(const char* path) { return LoadLibraryA(path); } +static void* lib_sym(lib_t h, const char* name) { + FARPROC p = GetProcAddress(h, name); + void* v; + memcpy(&v, &p, sizeof(v)); + return v; +} +#else +#include +typedef void* lib_t; +static lib_t lib_open(const char* path) { return dlopen(path, RTLD_NOW | RTLD_LOCAL); } +static void* lib_sym(lib_t h, const char* name) { return dlsym(h, name); } +#endif + +typedef int (*int_fn)(int); +typedef int (*use_fn)(AetherExecAccount*, AetherExecAccount**); +typedef int (*outcome_fn)(void); + +static int_fn grow; +static use_fn lib_use; +static outcome_fn lib_outcome; + +static int bind_lib(lib_t lib) { + void* p; + if (!(p = lib_sym(lib, "aether_grow"))) return 0; + memcpy(&grow, &p, sizeof(p)); + if (!(p = lib_sym(lib, "aether_exec_use"))) return 0; + memcpy(&lib_use, &p, sizeof(p)); + if (!(p = lib_sym(lib, "aether_last_outcome"))) return 0; + memcpy(&lib_outcome, &p, sizeof(p)); + return 1; +} + +static AetherExecAccount* g_a; +static int g_mine, g_theirs; +static void* other(void* arg) { + (void)arg; + g_mine = aether_exec_use(g_a, NULL); + g_theirs = lib_use(g_a, NULL); + return NULL; +} + +int main(int argc, char** argv) { + if (argc < 2) { printf("usage: two_copies [early|in_use]\n"); return 1; } + int early = argc > 2 && strcmp(argv[2], "early") == 0; + int in_use = argc > 2 && strcmp(argv[2], "in_use") == 0; + AetherExecLimits l = { 64 * 1024, 0, AETHER_DEADLINE_ELAPSED, NULL }; + if (early || in_use) g_a = aether_exec_account_new(&l); + if (in_use) aether_exec_use(g_a, NULL); + lib_t lib = lib_open(argv[1]); + if (!lib || !bind_lib(lib)) { printf("cannot load %s\n", argv[1]); return 1; } + + if (early) { + /* Shared: one account in use for the thread, so this copy sees the + * one the library's copy put in use, and charged what the library + * allocates past it. */ + int rc = lib_use(g_a, NULL); + int r = grow(4000); + int out = lib_outcome(); + int charged = aether_exec_account_tripped(g_a) != 0; + AetherExecAccount* seen = NULL; + if (rc == 0) aether_exec_use(NULL, &seen); + int ok = rc == 0 && seen == g_a && out == AETHER_MEMORY_CAP && charged; + printf("early: the library's copy shares the account: %s\n", ok ? "yes" : "no"); + if (!ok) printf(" (use %d, seen here %s, grow %d outcome %d, tripped %d)\n", + rc, seen == g_a ? "yes" : "no", r, out, charged); + if (rc == 0) lib_use(NULL, NULL); + aether_exec_account_free(g_a); + return 0; + } + if (in_use) { + /* The library's first call finds the accounts: this program's, with + * the account it put in use, which the library's allocations past + * the allowance trip. */ + int r = grow(4000); + int out = lib_outcome(); + int charged = aether_exec_account_tripped(g_a) != 0; + int ok = out == AETHER_MEMORY_CAP && charged && r == 0; + printf("in use: the library's allocations are charged to the account in use: %s\n", + ok ? "yes" : "no"); + if (!ok) printf(" (grow %d outcome %d, tripped %d)\n", r, out, charged); + aether_exec_use(NULL, NULL); + aether_exec_account_free(g_a); + return 0; + } + + g_a = aether_exec_account_new(&l); + aether_exec_use(g_a, NULL); + uint64_t before = aether_exec_account_used(g_a); + void* p = aether_caps_malloc(1000); + uint64_t mine = aether_exec_account_used(g_a) - before; + aether_caps_free(p, 1000); + int r = grow(4000); + printf("shared: this copy's 1000 bytes charged %llu; the library's past the allowance %d outcome %d, tripped %d\n", + (unsigned long long)mine, r, lib_outcome(), aether_exec_account_tripped(g_a)); + pthread_t t; + if (pthread_create(&t, NULL, other, NULL) != 0) return 1; + pthread_join(t, NULL); + printf("thread: in use here, refused on another through this copy %d, through the library's %d; holds %llu\n", + g_mine, g_theirs, (unsigned long long)aether_exec_account_used(g_a)); + aether_exec_use(NULL, NULL); + aether_exec_account_free(g_a); + fflush(stdout); + return 0; +} diff --git a/tests/integration/emit_lib_accounts/unload.c b/tests/integration/emit_lib_accounts/unload.c new file mode 100644 index 000000000..08d80285a --- /dev/null +++ b/tests/integration/emit_lib_accounts/unload.c @@ -0,0 +1,56 @@ +/* #3411: a library with a runtime of its own that is unloaded leaves + * nothing of it in the accounts it shared. argv[1] (A) is loaded first and + * holds the accounts; argv[2] (B, the same library under another name) + * makes one call, so its copy finds A's accounts and registers its + * allocation fast-path word with them, and is unloaded. Then A makes and + * frees the first account, which marks the word of every copy registered + * with it: B's no more, its image gone. + * + * unload: B's call 160, unloaded 0; an account made and freed after: yes */ +#include +#include +#ifdef _WIN32 +#include +static void* lib_open(const char* p) { return (void*)LoadLibraryA(p); } +static void* lib_sym(void* h, const char* n) { return (void*)GetProcAddress((HMODULE)h, n); } +static int lib_close(void* h) { return FreeLibrary((HMODULE)h) ? 0 : -1; } +#else +#include +static void* lib_open(const char* p) { return dlopen(p, RTLD_NOW | RTLD_LOCAL); } +static void* lib_sym(void* h, const char* n) { return dlsym(h, n); } +static int lib_close(void* h) { return dlclose(h); } +#endif + +typedef struct AetherExecAccount AetherExecAccount; +typedef struct { + uint64_t memory_bytes; + int64_t deadline_ms; + int deadline_counts; + AetherExecAccount* parent; +} Limits; +typedef int (*int_fn)(int); +typedef AetherExecAccount* (*new_fn)(const Limits*); +typedef void (*free_fn)(AetherExecAccount*); +typedef int (*use_fn)(AetherExecAccount*, AetherExecAccount**); + +int main(int argc, char** argv) { + if (argc < 3) { printf("usage: unload \n"); return 2; } + void* a = lib_open(argv[1]); + void* b = lib_open(argv[2]); + if (!a || !b) { printf("cannot load the libraries\n"); return 2; } + new_fn account_new = (new_fn)lib_sym(a, "aether_exec_account_new"); + free_fn account_free = (free_fn)lib_sym(a, "aether_exec_account_free"); + use_fn use = (use_fn)lib_sym(a, "aether_exec_use"); + int_fn grow_b = (int_fn)lib_sym(b, "aether_grow"); + int_fn grow_a = (int_fn)lib_sym(a, "aether_grow"); + if (!account_new || !account_free || !use || !grow_b || !grow_a) { printf("missing a symbol\n"); return 2; } + int got = grow_b(10); + int closed = lib_close(b); + Limits l = { 1 << 20, 0, 0, NULL }; + AetherExecAccount* acc = account_new(&l); + int ok = acc != NULL && use(acc, NULL) == 0 && grow_a(10) == 160 && use(NULL, NULL) == 0; + account_free(acc); + printf("unload: B's call %d, unloaded %d; an account made and freed after: %s\n", got, closed, ok ? "yes" : "no"); + fflush(stdout); + return 0; +} diff --git a/tests/integration/emit_lib_actor_step_context/host.c b/tests/integration/emit_lib_actor_step_context/host.c new file mode 100644 index 000000000..65f16d2f2 --- /dev/null +++ b/tests/integration/emit_lib_actor_step_context/host.c @@ -0,0 +1,98 @@ +/* A C host of steps.ae (#3392, #3396), the library linked in. Run on the + * runtime the tree was built with: threads, or the cooperative scheduler + * (make ci-coop), where an export's ask steps the actor on this thread, + * inside the call. + * + * spin: 0 outcome 2, the spinner finished: yes + * catch: 1 outcome 0 + * teller: "" outcome 0 + * a kill: the call's outcome is its drop's: yes + * after a kill: catch 1 outcome 0 + * a cancelled kill: the call's outcome is its drop's: yes + * an interrupted kill: outcome 2, in a try: outcome 2, caught 0 + */ +#include +#include +#include "libaether.h" +#include "aether_host.h" /* aether_set_caller */ +#ifdef _WIN32 +#include +#define host_sleep_ms(ms) Sleep(ms) +#else +#include +#define host_sleep_ms(ms) usleep((ms) * 1000) +#endif + +extern int32_t aether_ask_spin(int32_t ms); +extern int32_t aether_spun(void); +extern int32_t aether_catches(void); +extern const void* aether_teller_sees(void); +extern int32_t aether_kill_holder(void); +extern int32_t aether_slow_dropped_in_call(void); +extern int32_t aether_interrupted_kill(void); +extern int32_t aether_interrupted_kill_in_try(void); +extern int32_t aether_swallowed(void); +extern const char* aether_string_data(const void* s); +extern void aether_steps_string_release(const void* s); + +int main(void) { + aether_set_caller("alice", NULL, NULL, 0, 0); + aether_set_call_deadline(20); + int v = aether_ask_spin(60); + int out = aether_last_outcome(); + printf("spin: %d outcome %d, the spinner finished: %s\n", v, out, aether_spun() == 1 ? "yes" : "no"); + aether_set_call_deadline(0); + + int c = aether_catches(); + printf("catch: %d outcome %d\n", c, aether_last_outcome()); + + const void* s = aether_teller_sees(); + printf("teller: \"%s\" outcome %d\n", s ? aether_string_data(s) : "(null)", aether_last_outcome()); + if (s) aether_steps_string_release(s); + + /* A kill whose drop runs past the call's deadline (on the cooperative + * runtime it finalizes there and then): the call ends with the deadline + * when the drop ran inside it, and normally when the drop ran on a + * core's thread; the next call's try catches its own panic. */ + aether_set_call_deadline(1); + host_sleep_ms(5); + (void)aether_kill_holder(); + int ko = aether_last_outcome(); + aether_set_call_deadline(0); + int in_call = aether_slow_dropped_in_call(); + fprintf(stderr, "a kill: outcome %d, its drop inside the call: %d\n", ko, in_call); + printf("a kill: the call's outcome is its drop's: %s\n", + ko == (in_call ? AETHER_DEADLINE : AETHER_OK) ? "yes" : "no"); + int c2 = aether_catches(); + printf("after a kill: catch %d outcome %d\n", c2, aether_last_outcome()); + + /* #3415: the same kill in a call cancelled before it starts, so the + * drop's loop is the call's first safe point. A passed deadline also + * trips a flag the call's end reads; a cancel does not, so the call ends + * cancelled only through the stop the drop's containment frame notes for + * it, and a fix that dropped the interruption ended it normally. Where + * the drop runs on a core's thread the call reaches no safe point and + * ends normally. */ + (void)aether_exec_cancel(aether_exec_token()); + (void)aether_kill_holder(); + int kc = aether_last_outcome(); + int kc_in_call = aether_slow_dropped_in_call(); + fprintf(stderr, "a cancelled kill: outcome %d, its drop inside the call: %d\n", kc, kc_in_call); + printf("a cancelled kill: the call's outcome is its drop's: %s\n", + kc == (kc_in_call ? AETHER_CANCELLED : AETHER_OK) ? "yes" : "no"); + + /* #3415: a defer the deadline's unwind runs kills an actor, and the + * drop of its queued message panics: the call still ends with its + * deadline, and a try on the way passes the deadline on. */ + aether_set_call_deadline(1); + host_sleep_ms(5); + (void)aether_interrupted_kill(); + int ik = aether_last_outcome(); + (void)aether_interrupted_kill_in_try(); + int ikt = aether_last_outcome(); + aether_set_call_deadline(0); + printf("an interrupted kill: outcome %d, in a try: outcome %d, caught %d\n", ik, ikt, + (int)aether_swallowed()); + fflush(stdout); + return 0; +} diff --git a/tests/integration/emit_lib_actor_step_context/steps.ae b/tests/integration/emit_lib_actor_step_context/steps.ae new file mode 100644 index 000000000..be430206e --- /dev/null +++ b/tests/integration/emit_lib_actor_step_context/steps.ae @@ -0,0 +1,179 @@ +// The library of the actor step context test (#3392, #3396): exports that +// ask an actor. On the cooperative runtime the ask polls, so the actor is +// stepped inside the export's call, on its thread; with threads it runs on +// a core's. Either way the actor's handler runs outside the call: the +// call's deadline does not interrupt it, an interruption is never left on +// its way for a later call, and it reads no caller info the host set for +// the call. +import std.actors +import std.bytes +import std.host +import std.time + +message Spin { ms: int } +message Who { tag: string } + +var g_spun = 0 + +actor Spinner { + receive { + Spin(ms) -> { + t0 = time.now_ms() + while time.now_ms() - t0 < ms { + } + g_spun = g_spun + 1 + reply 7 + } + } +} + +actor Teller { + receive { + Who(tag) -> { reply host.caller_identity() } + } +} + +fn next_of(i: int) -> int { + return i + 1 +} + +// Asks a Spinner to spin `ms` milliseconds, then loops once: the call's +// deadline, passed by then, ends the call at that loop head. +ask_spin(ms: int) -> int { + sp = spawn(Spinner()) + v, err = sp ? Spin { ms: ms } + if err != "" { + return -1 + } + i = 0 + while i < 1 { // a call in the step, so the loop runs, safe point and all + i = next_of(i) + } + return v +} + +// How many spins ran to their end. +spun() -> int { + return g_spun +} + +// An ordinary panic, which the try catches. +catches() -> int { + got = 0 + try { + panic("ordinary") + } catch e { + got = 1 + } + return got +} + +// The caller identity an actor this call asks reads. +teller_sees() -> string { + t = spawn(Teller()) + s, err = t ? Who { tag: "x" } + if err != "" { + return "error: ${err}" + } + return s +} + +// #3404: an actor ended inside a call (on the cooperative runtime a kill +// finalizes it there and then) has its queued message dropped there, and +// the field's drop runs a loop past the call's deadline. The interruption is +// the call's: it ends the call, and nothing of it is left for the next one. +// The drop notes whether it ran inside the call (it reads the caller info +// the host set for the call), which says which outcome the call has (#3415). +var g_slow_in_call = 0 + +fn slow_drop(p: @frees ptr) { + if host.caller_identity() != "" { + g_slow_in_call = 1 + } + i = 0 + while i < 3 { + i = next_of(i) + } + bytes.free(p) +} + +type Slow = owned ptr drop slow_drop + +message Keep { s: Slow } + +actor Holder { + receive { + Keep(s) -> { _n = bytes.length(s) } + } +} + +// Queues a Slow for a Holder and kills it before it runs. +kill_holder() -> int { + h = spawn(Holder()) + h ! Keep { s: bytes.new(4) as Slow } + actors.kill(h) + return 1 +} + +// 1 when a Slow's drop ran inside a call that killed its Holder since this +// was last asked. +slow_dropped_in_call() -> int { + was = g_slow_in_call + g_slow_in_call = 0 + return was +} + +// #3415: an actor ended while the call's interruption unwinds the call (a +// defer of the call's kills it; on the cooperative runtime the kill +// finalizes it there and then) has its queued message dropped there, and +// the field's drop panics, ordinarily. That panic is the drop's; the +// interruption is the call's, and goes on to end it: a `try` it meets +// passes it on, and the call ends with its deadline. +fn bomb_drop(p: @frees ptr) { + bytes.free(p) + panic("the bomb went off in its drop") +} + +type Bomb = owned ptr drop bomb_drop + +message Arm { b: Bomb } + +actor Fuse { + receive { + Arm() -> {} + } +} + +var g_swallowed = 0 + +// Arms a Fuse, kills it in a defer, and loops: the call's deadline, passed +// by then, cuts the loop at its head, and the unwind runs the defer. +fn armed_spin() -> int { + fuse = spawn(Fuse()) + fuse ! Arm { b: bytes.new(4) as Bomb } + defer actors.kill(fuse) + i = 0 + while i < 1000000000 { + i = next_of(i) + } + return i +} + +interrupted_kill() -> int { + return armed_spin() +} + +interrupted_kill_in_try() -> int { + got = 0 + try { + got = armed_spin() + } catch e { + g_swallowed = 1 + } + return got +} + +// 1 when a try caught the call's interruption as a panic of its own. +swallowed() -> int { + return g_swallowed +} diff --git a/tests/integration/emit_lib_actor_step_context/test_emit_lib_actor_step_context.sh b/tests/integration/emit_lib_actor_step_context/test_emit_lib_actor_step_context.sh new file mode 100644 index 000000000..f2a4da7ce --- /dev/null +++ b/tests/integration/emit_lib_actor_step_context/test_emit_lib_actor_step_context.sh @@ -0,0 +1,67 @@ +#!/bin/sh +# #3392, #3396, #3404: an actor stepped inside a library call runs outside it. On +# the cooperative runtime an export's ask polls, stepping the asked actor on +# the call's thread: the call's deadline was raised inside the actor's loop +# and caught by the actor's barrier (the actor died, "the call's deadline +# passed"), the interruption stayed marked for the next call (whose try then +# could not catch an ordinary panic), and the actor read the caller info the +# host set for the call. Run on threads by the suite and on the cooperative +# scheduler by make ci-coop; the output is the same on both. Last, a kill +# whose message drop runs a loop past the call's deadline (on the cooperative +# runtime the kill finalizes there and then): the interruption was caught +# as the drop's panic and left on its way, so the next call's try could not +# catch an ordinary panic (#3404). The kill's own call must end with what +# its drop raised inside it, and normally where the drop runs on a core's +# thread (#3415): with the deadline, and with a cancel, which trips no flag +# of its own, so that a fix that dropped the interruption (the call then +# ended normally, which the next call's try alone could not tell) fails +# here. Then a kill in a defer that the deadline's unwind runs, whose drop +# panics ordinarily: the panic was taken for the interruption, which was +# then lost, so the call ended PANICKED and a try on the way ran its handler +# (#3415). +# +# Fixtures, not tests: steps.ae is a library the driver builds and links +# into host.c. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +AE="$ROOT/build/ae" +AETHERC="$ROOT/build/aetherc" +CC="${CC:-cc}" +export AETHER_HOME="$ROOT" +export AETHER_STACK_TRACE=0 + +[ -x "$AE" ] || { echo " [SKIP] emit_lib_actor_step_context: $AE not built"; exit 0; } + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP" || true' EXIT +want='spin: 0 outcome 2, the spinner finished: yes +catch: 1 outcome 0 +teller: "" outcome 0 +a kill: the call'"'"'s outcome is its drop'"'"'s: yes +after a kill: catch 1 outcome 0 +a cancelled kill: the call'"'"'s outcome is its drop'"'"'s: yes +an interrupted kill: outcome 2, in a try: outcome 2, caught 0' + +if ! "$AETHERC" --emit=lib --with=extern,os "$SCRIPT_DIR/steps.ae" "$TMP/steps.c" > "$TMP/steps.log" 2>&1; then + echo " [FAIL] emit_lib_actor_step_context: aetherc --emit=lib failed" + tail -5 "$TMP/steps.log" | sed 's/^/ /' + exit 1 +fi +# shellcheck disable=SC2046 +if ! "$CC" -O1 -I"$ROOT/include" -I"$ROOT/runtime" $("$AE" cflags --cflags) \ + "$TMP/steps.c" "$SCRIPT_DIR/host.c" -o "$TMP/host" \ + $("$AE" cflags --libs) > "$TMP/cc.log" 2>&1; then + echo " [FAIL] emit_lib_actor_step_context: the host did not build" + head -10 "$TMP/cc.log" | sed 's/^/ /' + exit 1 +fi +got="$("$TMP/host" 2>"$TMP/host.err" | tr -d '\r')" +if [ "$got" != "$want" ]; then + echo " [FAIL] emit_lib_actor_step_context:" + echo " got:"; printf '%s\n' "$got" | sed 's/^/ /' + echo " want:"; printf '%s\n' "$want" | sed 's/^/ /' + { grep -E "^a (cancelled )?kill:" "$TMP/host.err"; grep -vE "^a (cancelled )?kill:" "$TMP/host.err" | head -5; } | sed 's/^/ /' + exit 1 +fi +echo " [PASS] emit_lib_actor_step_context: an actor a call's ask steps runs outside the call: not cut by its deadline, no interruption left for the next call, no caller info of the call; a kill whose drop the deadline or a cancel cuts ends its call with it and leaves the next call's try its own; an ordinary panic in a drop during the deadline's unwind leaves the deadline the call's" diff --git a/tests/integration/emit_lib_alloc_failure/alloc.ae b/tests/integration/emit_lib_alloc_failure/alloc.ae new file mode 100644 index 000000000..e5993adf3 --- /dev/null +++ b/tests/integration/emit_lib_alloc_failure/alloc.ae @@ -0,0 +1,33 @@ +// The library of the allocation-failure test (#2956): a call whose +// allocation fails, past the memory cap or with the allocator out of +// memory, ends with that outcome at its next safe point. +import std.string + +extern host_defer_ran() + +fn grow(n: int) -> int { + defer host_defer_ran() + s = "" + i = 0 + while i < n { + s = string.concat(s, "0123456789abcdef") + i = i + 1 + } + return string.length(s) +} + +// A string grown n times: past the cap, the call ends, the defer has run. +build(n: int) -> int { + return grow(n) +} + +// The same inside a user `try`: the failure is not the try's to catch. +guarded_build(n: int) -> int { + got = 7 + try { + got = grow(n) + } catch e { + got = -1 + } + return got +} diff --git a/tests/integration/emit_lib_alloc_failure/host.c b/tests/integration/emit_lib_alloc_failure/host.c new file mode 100644 index 000000000..f70880828 --- /dev/null +++ b/tests/integration/emit_lib_alloc_failure/host.c @@ -0,0 +1,43 @@ +/* A C host of alloc.ae (#2956). Each call's allocation fails and the call + * must come back with the zero value and the outcome, its defers run, and + * the library takes the next call: + * + * cap 0 outcome 4 "..." defers 1 past a memory cap + * guarded 0 outcome 4 defers 2 the same inside a user try + * oom 0 outcome 5 "..." defers 3 the allocator out of memory + * after 160 outcome 0 defers 4 a fresh call, no cap + * + * The library's C is linked into this program (one runtime). */ +#include +#include +#include "libaether.h" + +extern int aether_build(int); +extern int aether_guarded_build(int); +/* Runtime-internal: the bytes the cap counts now, and the test hook that + * fails the n-th cap-aware allocation from now on this thread. */ +extern uint64_t aether_caps_used_bytes(void); +extern void aether_test_fail_alloc_after(int n); + +static int defers; +void host_defer_ran(void) { defers++; } + +int main(void) { + aether_set_memory_cap(aether_caps_used_bytes() + 65536); + int r = aether_build(1000000); + printf("cap %d outcome %d \"%s\" defers %d\n", r, aether_last_outcome(), aether_last_outcome_text(), + defers); + r = aether_guarded_build(1000000); + printf("guarded %d outcome %d defers %d\n", r, aether_last_outcome(), defers); + aether_set_memory_cap(0); + + aether_test_fail_alloc_after(3); + r = aether_build(100); + printf("oom %d outcome %d \"%s\" defers %d\n", r, aether_last_outcome(), aether_last_outcome_text(), + defers); + + r = aether_build(10); + printf("after %d outcome %d defers %d\n", r, aether_last_outcome(), defers); + fflush(stdout); + return 0; +} diff --git a/tests/integration/emit_lib_alloc_failure/test_emit_lib_alloc_failure.sh b/tests/integration/emit_lib_alloc_failure/test_emit_lib_alloc_failure.sh new file mode 100755 index 000000000..0f74cf176 --- /dev/null +++ b/tests/integration/emit_lib_alloc_failure/test_emit_lib_alloc_failure.sh @@ -0,0 +1,51 @@ +#!/bin/sh +# #2956: an allocation that fails inside a library call ends the call. +# +# Past the memory cap, or with the allocator out of memory, a cap-aware +# allocation returned NULL, the std operation returned its error (a string +# operation, NULL), and the script went on with it: the export returned +# whatever it reached, with AETHER_OK. Now the failure is noted on the call, +# and its next safe point ends it with AETHER_MEMORY_CAP or +# AETHER_OUT_OF_MEMORY and the zero value; its defers run, a `try` in it +# does not catch it, and the library takes the next call. +# +# Fixtures, not tests: alloc.ae is a library; the driver builds it. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +AE="$ROOT/build/ae" +AETHERC="$ROOT/build/aetherc" +CC="${CC:-cc}" +export AETHER_HOME="$ROOT" + +[ -x "$AE" ] || { echo " [SKIP] emit_lib_alloc_failure: $AE not built"; exit 0; } + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP" || true' EXIT +want='cap 0 outcome 4 "the call went past the memory cap" defers 1 +guarded 0 outcome 4 defers 2 +oom 0 outcome 5 "the call ran out of memory" defers 3 +after 160 outcome 0 defers 4' + +if ! "$AETHERC" --emit=lib --with=extern "$SCRIPT_DIR/alloc.ae" "$TMP/alloc.c" > "$TMP/alloc.log" 2>&1; then + echo " [FAIL] emit_lib_alloc_failure: aetherc --emit=lib failed" + tail -5 "$TMP/alloc.log" | sed 's/^/ /' + exit 1 +fi +# shellcheck disable=SC2046 +if ! "$CC" -O1 -I"$ROOT/include" -I"$ROOT/runtime" $("$AE" cflags --cflags) \ + "$TMP/alloc.c" "$SCRIPT_DIR/host.c" -o "$TMP/host" \ + $("$AE" cflags --libs) > "$TMP/cc.log" 2>&1; then + echo " [FAIL] emit_lib_alloc_failure: the host did not build" + head -10 "$TMP/cc.log" | sed 's/^/ /' + exit 1 +fi +got="$("$TMP/host" 2>"$TMP/host.err" | tr -d '\r')" +if [ "$got" != "$want" ]; then + echo " [FAIL] emit_lib_alloc_failure:" + echo " got:"; printf '%s\n' "$got" | sed 's/^/ /' + echo " want:"; printf '%s\n' "$want" | sed 's/^/ /' + head -5 "$TMP/host.err" | sed 's/^/ /' + exit 1 +fi +echo " [PASS] emit_lib_alloc_failure: a call past the memory cap ends with AETHER_MEMORY_CAP, one whose allocator runs out with AETHER_OUT_OF_MEMORY, the zero value and its defers run, a try does not catch it; the next call works" diff --git a/tests/integration/emit_lib_deadline/test_emit_lib_deadline.sh b/tests/integration/emit_lib_deadline/test_emit_lib_deadline.sh index 84210106c..2b6f48125 100755 --- a/tests/integration/emit_lib_deadline/test_emit_lib_deadline.sh +++ b/tests/integration/emit_lib_deadline/test_emit_lib_deadline.sh @@ -9,9 +9,9 @@ # the deadline window — even though the loop would otherwise # run for many seconds. # 3. `aetherc --emit=exe` on the same source emits ZERO -# references to aether_caps_deadline_tripped — proving the -# gate elides the tripwire on exe builds (`--emit=exe` pays -# nothing). +# references to aether_caps_deadline_tripped or aether_exec_poll +# (the loop-head safe point since #2956), proving the gate +# elides the tripwire on exe builds (`--emit=exe` pays nothing). set -e @@ -30,9 +30,10 @@ trap 'rm -rf "$TMPDIR" || true' EXIT exit 1 } -# Tripwire symbol must appear ≥2 times: the extern decl in the -# program prelude + at least one call at a loop head. -TRIPWIRE_COUNT=$(grep -c "aether_caps_deadline_tripped" "$TMPDIR/loop_lib.c" || true) +# The loop-head safe point must appear >=2 times: the extern decl in the +# program prelude + at least one call at a loop head (#2956: the +# safe point asks aether_exec_poll(), which also sees a cancel). +TRIPWIRE_COUNT=$(grep -c "aether_exec_poll" "$TMPDIR/loop_lib.c" || true) [ "$TRIPWIRE_COUNT" -ge 2 ] || { echo " [FAIL] tripwire emission: expected ≥2 (extern decl + ≥1 loop site), got $TRIPWIRE_COUNT" exit 1 @@ -123,7 +124,7 @@ ELAPSED=$(grep -o 'ELAPSED_MS=[0-9]*' "$TMPDIR/run.log" | cut -d= -f2) head -30 "$TMPDIR/build_exe.log" exit 1 } -EXE_COUNT=$(grep -c "aether_caps_deadline_tripped" "$TMPDIR/loop_exe.c" || true) +EXE_COUNT=$(grep -c -e "aether_caps_deadline_tripped" -e "aether_exec_poll" "$TMPDIR/loop_exe.c" || true) [ "$EXE_COUNT" = "0" ] || { echo " [FAIL] --emit=exe contains $EXE_COUNT tripwire references (expected 0)" exit 1 diff --git a/tests/integration/emit_lib_execution_frame/app.ae b/tests/integration/emit_lib_execution_frame/app.ae new file mode 100644 index 000000000..3d96c8726 --- /dev/null +++ b/tests/integration/emit_lib_execution_frame/app.ae @@ -0,0 +1,17 @@ +// An Aether host of the library in binary form (#2956): a failed call is +// raised again as a panic with the same reason, and std.host reads its +// outcome. +import frames +import std.host + +main() { + got = "" + try { + _v = frames.boom(1) + } catch e { + got = e + } + println("caught: ${got}, outcome ${host.last_outcome()} \"${host.last_outcome_text()}\"") + v = frames.boom(0) + println("then: ${v}, outcome ${host.last_outcome()}") +} diff --git a/tests/integration/emit_lib_execution_frame/frames.ae b/tests/integration/emit_lib_execution_frame/frames.ae new file mode 100644 index 000000000..48a4c3592 --- /dev/null +++ b/tests/integration/emit_lib_execution_frame/frames.ae @@ -0,0 +1,68 @@ +// The library of the execution-frame test (#2955, #2956). +import std.host + +extern host_service(which: int) +extern aether_sandbox_depth() -> int + +fn count_to(n: int) -> int { + i = 0 + odd = 0 + while i < n { + if i % 2 == 1 { + odd = odd + 1 + } + i = i + 1 + } + return i +} + +// An uncaught panic in an export. +boom(n: int) -> int { + if n > 0 { + panic("boom ${n}") + } + return n + 1 +} + +// Arithmetic only: nothing can end it from outside its code, so its stub +// has no frame and records the OK outcome alone. +plain(n: int) -> int { + return n * 2 + 1 +} + +// The inner call of a host service: returns 7 (which 0), refuses with -1 +// (1), panics (2), or loops until the deadline the service set for it +// ends it (3). It leaves a sandbox level of its own pushed, which its +// frame takes off. +inner(which: int) -> int { + sandbox_push(null) + if which == 2 { + panic("inner panicked") + } + if which == 3 { + return count_to(2000000000) + } + if which == 1 { + return -1 + } + return 7 +} + +// Calls the host service, then says what it still sees, one bit each: +// 1 its caller is "outer"; 2 its own loop is not cut by the deadline the +// service set for the inner call; 4 its sandbox levels are as they were. +outer(which: int) -> int { + levels = aether_sandbox_depth() + host_service(which) + bits = 0 + if host.caller_identity() == "outer" { + bits = bits + 1 + } + if count_to(5000000) == 5000000 { + bits = bits + 2 + } + if aether_sandbox_depth() == levels { + bits = bits + 4 + } + return bits +} diff --git a/tests/integration/emit_lib_execution_frame/hooks.c b/tests/integration/emit_lib_execution_frame/hooks.c new file mode 100644 index 000000000..89387b54a --- /dev/null +++ b/tests/integration/emit_lib_execution_frame/hooks.c @@ -0,0 +1,13 @@ +/* The library's host_service extern, forwarded to the host, which sets it + * (a DLL or shared object has its own copy of this file). */ +typedef void (*service_fn)(int which); + +static service_fn g_service; + +void hooks_set(service_fn service) { + g_service = service; +} + +void host_service(int which) { + if (g_service) g_service(which); +} diff --git a/tests/integration/emit_lib_execution_frame/host.c b/tests/integration/emit_lib_execution_frame/host.c new file mode 100644 index 000000000..84eb7ef6d --- /dev/null +++ b/tests/integration/emit_lib_execution_frame/host.c @@ -0,0 +1,138 @@ +/* A C host of frames.ae (#2955, #2956). Prints, for an export that panics + * and for an outer export whose host service runs an inner export that + * returns, refuses, panics or loops past the deadline the service set for + * it, what each call returned and how it ended: + * + * boom 0 outcome 1 "boom 1"; plain 7 outcome 0; again 1 outcome 0 + * which W: inner R outcome O "TEXT"; outer B outcome O + * + * B is the outer export's view after the service: 1 its caller info is its + * own, 2 its loop is not cut by the inner call's deadline, 4 its sandbox + * levels are as they were; 7 is all three. + * + * HOST_STATIC: the library's generated C is linked into this program, one + * runtime. Otherwise argv[1] is the library, loaded at run time, and the + * host API is the library's own runtime's: in the library, or in the shared + * runtime argv[2] names. */ +#include +#include +#include + +typedef int (*int_fn)(int); +typedef int (*outcome_fn)(void); +typedef const char* (*text_fn)(void); +typedef int (*set_caller_fn)(const char*, const char**, const char**, size_t, int64_t); +typedef void (*deadline_fn)(int64_t); +typedef void (*service_fn)(int); +typedef void (*hooks_set_fn)(service_fn); + +static int_fn boom_fn, plain_fn, inner_fn, outer_fn; +static outcome_fn last_outcome_fn; +static text_fn last_text_fn; +static set_caller_fn set_caller_fn_; +static deadline_fn set_deadline_fn; + +static int inner_ret, inner_outcome; +static char inner_text[128]; + +/* The trusted host service the outer export calls: an inner call for + * another caller, with a deadline of its own. */ +static void service(int which) { + set_caller_fn_("inner", NULL, NULL, 0, 0); + set_deadline_fn(1); + inner_ret = inner_fn(which); + inner_outcome = last_outcome_fn(); + snprintf(inner_text, sizeof(inner_text), "%s", last_text_fn()); +} + +#ifdef HOST_STATIC +#include "libaether.h" +#include "aether_host.h" +extern int aether_boom(int); +extern int aether_plain(int); +extern int aether_inner(int); +extern int aether_outer(int); +extern void hooks_set(service_fn); + +static int load(int argc, char** argv) { + (void)argc; (void)argv; + boom_fn = aether_boom; + plain_fn = aether_plain; + inner_fn = aether_inner; + outer_fn = aether_outer; + last_outcome_fn = aether_last_outcome; + last_text_fn = aether_last_outcome_text; + set_caller_fn_ = aether_set_caller; + set_deadline_fn = aether_set_call_deadline; + hooks_set(service); + return 0; +} +#else +#ifdef _WIN32 +#include +typedef HMODULE lib_t; +static lib_t lib_open(const char* path) { return LoadLibraryA(path); } +static void* lib_sym(lib_t h, const char* name) { + FARPROC p = GetProcAddress(h, name); + void* v; + memcpy(&v, &p, sizeof(v)); + return v; +} +#else +#include +typedef void* lib_t; +static lib_t lib_open(const char* path) { return dlopen(path, RTLD_NOW | RTLD_GLOBAL); } +static void* lib_sym(lib_t h, const char* name) { return dlsym(h, name); } +#endif + +#define BIND(var, from, name) do { void* p_ = lib_sym(from, name); \ + if (!p_) { printf("missing %s\n", name); return 1; } memcpy(&var, &p_, sizeof(p_)); } while (0) + +/* argv[2], when given, is the shared runtime the library runs on: the host + * API is there. */ +static int load(int argc, char** argv) { + if (argc < 2) { printf("usage: host []\n"); return 1; } + lib_t lib = lib_open(argv[1]); + if (!lib) { printf("cannot load %s\n", argv[1]); return 1; } + lib_t rt = argc > 2 ? lib_open(argv[2]) : lib; + if (!rt) { printf("cannot load %s\n", argv[2]); return 1; } + hooks_set_fn hooks; + BIND(boom_fn, lib, "aether_boom"); + BIND(plain_fn, lib, "aether_plain"); + BIND(inner_fn, lib, "aether_inner"); + BIND(outer_fn, lib, "aether_outer"); + BIND(last_outcome_fn, rt, "aether_last_outcome"); + BIND(last_text_fn, rt, "aether_last_outcome_text"); + BIND(set_caller_fn_, rt, "aether_set_caller"); + BIND(set_deadline_fn, rt, "aether_set_call_deadline"); + BIND(hooks, lib, "hooks_set"); + hooks(service); + return 0; +} +#endif + +int main(int argc, char** argv) { + if (load(argc, argv) != 0) return 1; + int b = boom_fn(1); + int bo = last_outcome_fn(); + char bt[128]; + snprintf(bt, sizeof(bt), "%s", last_text_fn()); + /* A frameless export right after a failed call: its own outcome. */ + int p = plain_fn(3); + int po = last_outcome_fn(); + int again = boom_fn(0); + printf("boom %d outcome %d \"%s\"; plain %d outcome %d; again %d outcome %d\n", b, bo, bt, p, po, + again, last_outcome_fn()); + for (int which = 0; which < 4; which++) { + set_caller_fn_("outer", NULL, NULL, 0, 0); + set_deadline_fn(0); + inner_ret = -9; + inner_outcome = -9; + inner_text[0] = '\0'; + int bits = outer_fn(which); + printf("which %d: inner %d outcome %d \"%s\"; outer %d outcome %d\n", + which, inner_ret, inner_outcome, inner_text, bits, last_outcome_fn()); + } + fflush(stdout); + return 0; +} diff --git a/tests/integration/emit_lib_execution_frame/test_emit_lib_execution_frame.sh b/tests/integration/emit_lib_execution_frame/test_emit_lib_execution_frame.sh new file mode 100644 index 000000000..36d21ef2e --- /dev/null +++ b/tests/integration/emit_lib_execution_frame/test_emit_lib_execution_frame.sh @@ -0,0 +1,135 @@ +#!/bin/sh +# #2955 (D3) and #2956 (D4): every export runs in an execution frame. +# +# A panic in an export a C host called printed "panic outside any +# try/catch or actor" and aborted the host. An export that called a host +# service, which set another caller and deadline and called a second export, +# saw the second one's caller and deadline from then on, and a sandbox level +# the second left pushed stayed in force. Now a panic ends the call with +# AETHER_PANICKED and its reason, the export returns its zero value, the +# library takes the next call, and the outer call's caller info, deadline +# and sandbox levels are back when the inner one returns, however it ends. +# +# 1. a C host with the library's C linked in (one runtime); +# 2. a C host loading the library built the default way (its own runtime); +# 3. the same with --shared-runtime (skipped where build/shared has none); +# 4. an Aether program importing the library in binary form: a failed call +# is raised again as a panic with the same reason, a `try` catches it, +# and std.host's last_outcome() says how the call ended. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +AE="$ROOT/build/ae" +AETHERC="$ROOT/build/aetherc" +CC="${CC:-cc}" +export AETHER_HOME="$ROOT" +export AETHER_STACK_TRACE=0 + +[ -x "$AE" ] || { echo " [SKIP] emit_lib_execution_frame: $AE not built"; exit 0; } + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP" || true' EXIT +fail=0 +want='boom 0 outcome 1 "boom 1"; plain 7 outcome 0; again 1 outcome 0 +which 0: inner 7 outcome 0 ""; outer 7 outcome 0 +which 1: inner -1 outcome 0 ""; outer 7 outcome 0 +which 2: inner 0 outcome 1 "inner panicked"; outer 7 outcome 0 +which 3: inner 0 outcome 2 "the call'"'"'s deadline passed"; outer 7 outcome 0' + +case "$(uname -s 2>/dev/null)" in + MINGW*|MSYS*|CYGWIN*|Windows_NT) ext=dll; exe=.exe; dl="" ;; + Darwin) ext=dylib; exe=""; dl="" ;; + Linux) ext=so; exe=""; dl="-ldl" ;; + *) ext=so; exe=""; dl="" ;; +esac + +# $1 = label, $2 = output +check() { + if [ "$2" != "$want" ]; then + echo " [FAIL] emit_lib_execution_frame: $1:" + echo " got:"; printf '%s\n' "$2" | sed 's/^/ /' + echo " want:"; printf '%s\n' "$want" | sed 's/^/ /' + fail=1 + fi +} + +# 1. one runtime +if ! "$AETHERC" --emit=lib --with=extern "$SCRIPT_DIR/frames.ae" "$TMP/frames.c" > "$TMP/frames.log" 2>&1; then + echo " [FAIL] emit_lib_execution_frame: aetherc --emit=lib failed" + tail -5 "$TMP/frames.log" | sed 's/^/ /' + exit 1 +fi +# shellcheck disable=SC2046 +if ! "$CC" -O1 -DHOST_STATIC -I"$ROOT/include" -I"$ROOT/runtime" $("$AE" cflags --cflags) \ + "$TMP/frames.c" "$SCRIPT_DIR/hooks.c" "$SCRIPT_DIR/host.c" -o "$TMP/host_static" \ + $("$AE" cflags --libs) > "$TMP/cc.log" 2>&1; then + echo " [FAIL] emit_lib_execution_frame: the one-runtime host did not build" + head -10 "$TMP/cc.log" | sed 's/^/ /' + exit 1 +fi +check "one runtime" "$("$TMP/host_static" 2>"$TMP/static.err" | tr -d '\r')" + +if ! "$CC" -O1 "$SCRIPT_DIR/host.c" -o "$TMP/host_dyn" $dl > "$TMP/cc_dyn.log" 2>&1; then + echo " [FAIL] emit_lib_execution_frame: the loading host did not build" + head -10 "$TMP/cc_dyn.log" | sed 's/^/ /' + exit 1 +fi + +# $1 = label, $2 = extra build flags +build_lib() { + mkdir -p "$TMP/$1" + # shellcheck disable=SC2086 + (cd "$TMP/$1" && "$AE" build --emit=lib $2 --with=extern "$SCRIPT_DIR/frames.ae" \ + --extra "$SCRIPT_DIR/hooks.c" -o "$TMP/$1/libframes.$ext" > "$TMP/$1/build.log" 2>&1) +} + +# $1 = label, $2 = library dir: the Aether program importing it in binary form +run_app() { + cp "$SCRIPT_DIR/app.ae" "$2/app.ae" + got="$(cd "$2" && "$AE" build app.ae -o app > app_build.log 2>&1 && \ + PATH="$2:$ROOT/build/shared:$PATH" \ + LD_LIBRARY_PATH="$2:$ROOT/build/shared${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \ + DYLD_LIBRARY_PATH="$2:$ROOT/build/shared${DYLD_LIBRARY_PATH:+:$DYLD_LIBRARY_PATH}" \ + ./app$exe 2>"$2/app.err" | tr -d '\r')" + app_want='caught: boom 1, outcome 1 "boom 1" +then: 1, outcome 0' + if [ "$got" != "$app_want" ]; then + echo " [FAIL] emit_lib_execution_frame: $1, Aether host: got:" + printf '%s\n' "$got" | sed 's/^/ /' + tail -5 "$2/app_build.log" "$2/app.err" 2>/dev/null | sed 's/^/ /' + fail=1 + fi +} + +# 2. the library's own runtime +if ! build_lib static ""; then + echo " [FAIL] emit_lib_execution_frame: ae build --emit=lib failed" + tail -5 "$TMP/static/build.log" | sed 's/^/ /' + exit 1 +fi +check "its own runtime" "$("$TMP/host_dyn" "$TMP/static/libframes.$ext" 2>"$TMP/dyn.err" | tr -d '\r')" +run_app "its own runtime" "$TMP/static" + +# 3. one shared runtime +if ls "$ROOT/build/shared/" 2>/dev/null | grep -q "aether"; then + if ! build_lib shared "--shared-runtime"; then + echo " [FAIL] emit_lib_execution_frame: ae build --emit=lib --shared-runtime failed" + tail -5 "$TMP/shared/build.log" | sed 's/^/ /' + exit 1 + fi + rt="$(ls "$ROOT/build/shared/"aether.dll "$ROOT/build/shared/"libaether.dylib \ + "$ROOT/build/shared/"libaether.so 2>/dev/null | head -1)" + check "--shared-runtime" "$(PATH="$ROOT/build/shared:$PATH" \ + LD_LIBRARY_PATH="$ROOT/build/shared${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \ + DYLD_LIBRARY_PATH="$ROOT/build/shared${DYLD_LIBRARY_PATH:+:$DYLD_LIBRARY_PATH}" \ + "$TMP/host_dyn" "$TMP/shared/libframes.$ext" "$rt" 2>"$TMP/shared.err" | tr -d '\r')" + run_app "--shared-runtime" "$TMP/shared" + shared_note=", and on the shared runtime" +else + shared_note=" (no shared runtime in build/shared: that leg skipped)" +fi + +if [ "$fail" = 0 ]; then + echo " [PASS] emit_lib_execution_frame: a panicking export returns AETHER_PANICKED to its C host and the next call works; the outer call's caller, deadline and sandbox levels are back after an inner call that returns, refuses or panics; an Aether importer catches the failure as a panic: one runtime, the library's own$shared_note" +fi +exit $fail diff --git a/tests/integration/emit_lib_failed_result/app.ae b/tests/integration/emit_lib_failed_result/app.ae new file mode 100644 index 000000000..766217379 --- /dev/null +++ b/tests/integration/emit_lib_failed_result/app.ae @@ -0,0 +1,21 @@ +// An Aether importer of words.ae in binary form, built with its own +// runtime (#3388): a failed call is raised again as a panic here, and the +// string the library would have returned is not kept by either runtime. +// +// The reason is printed in the handler and not kept: on ELF, where the +// program is linked with -rdynamic, the library's runtime calls bind to the +// program's runtime (one runtime, not two), so words.used() reads the +// program's count, which a kept reason string would be part of (#3437). +import words + +main() { + base = words.used() + words.fail_next(1) + try { + _s = words.greet(7) + } catch e { + println("raised: ${e}") + } + println("the library holds what it held before: ${words.used() == base}") + println("then: ${words.greet(7)}") +} diff --git a/tests/integration/emit_lib_failed_result/host.c b/tests/integration/emit_lib_failed_result/host.c new file mode 100644 index 000000000..70031d64e --- /dev/null +++ b/tests/integration/emit_lib_failed_result/host.c @@ -0,0 +1,60 @@ +/* A C host of results.ae (#3388), the library linked in (one runtime). Each + * call has its first allocation fail and ends at its return with + * AETHER_OUT_OF_MEMORY: it must return its zero value, and what it would + * have returned must be released by the library, since a host releases only + * what an AETHER_OK call returned. Then the same calls with nothing failed. + * + * string NULL outcome 5, released yes + * int 0 outcome 5 + * struct 0 (null) outcome 5, released yes + * closure (null) outcome 5, released yes + * ok: hello7 42 1 p7 outcome 0 + */ +#include +#include +#include "libaether.h" + +typedef struct Pair { int32_t a; const void* s; } Pair; +typedef struct { void (*fn)(void); void* env; } Closure; + +extern const void* aether_greet(int32_t n); +extern int32_t aether_answer(int32_t n); +extern Pair aether_pair(int32_t n); +extern Closure aether_greeter(int32_t n); +extern const char* aether_string_data(const void* s); +extern void aether_results_string_release(const void* s); +extern uint64_t aether_caps_used_bytes(void); +extern void aether_test_fail_alloc_after(int n); + +int main(void) { + uint64_t base = aether_caps_used_bytes(); + aether_test_fail_alloc_after(1); + const void* s = aether_greet(7); + printf("string %s outcome %d, released %s\n", s ? aether_string_data(s) : "NULL", + aether_last_outcome(), aether_caps_used_bytes() == base ? "yes" : "no"); + + aether_test_fail_alloc_after(1); + int32_t n = aether_answer(7); + printf("int %d outcome %d\n", n, aether_last_outcome()); + + base = aether_caps_used_bytes(); + aether_test_fail_alloc_after(1); + Pair p = aether_pair(7); + printf("struct %d %s outcome %d, released %s\n", p.a, p.s ? aether_string_data(p.s) : "(null)", + aether_last_outcome(), aether_caps_used_bytes() == base ? "yes" : "no"); + + base = aether_caps_used_bytes(); + aether_test_fail_alloc_after(1); + Closure c = aether_greeter(7); + printf("closure %s outcome %d, released %s\n", c.fn ? "a function" : "(null)", aether_last_outcome(), + aether_caps_used_bytes() == base ? "yes" : "no"); + + const void* ok = aether_greet(7); + int okn = aether_answer(7); + Pair okp = aether_pair(7); + printf("ok: %s %d %d %s outcome %d\n", aether_string_data(ok), okn, okp.a, aether_string_data(okp.s), + aether_last_outcome()); + aether_results_string_release(ok); + fflush(stdout); + return 0; +} diff --git a/tests/integration/emit_lib_failed_result/results.ae b/tests/integration/emit_lib_failed_result/results.ae new file mode 100644 index 000000000..7600ceb62 --- /dev/null +++ b/tests/integration/emit_lib_failed_result/results.ae @@ -0,0 +1,44 @@ +// The library of the failed-result test (#3388): exports whose call returns +// normally, but with a stop noted after its last safe point. The first +// allocation each makes is the one the test hook fails; the code survives +// it (the std operation returns its error) and has no loop after it, so +// the call ends at its return with AETHER_OUT_OF_MEMORY. It must return its +// zero value, and what it would have returned is not the caller's. The +// strings are made from `n`, so none is a literal the compiler folds. +import std.string + +extern aether_test_fail_alloc_after(n: int) +extern aether_caps_used_bytes() -> long + +struct Pair { a: int, s: string } + +// The cap-aware bytes this library's runtime holds, and its test hook: for +// an importer, which has a runtime of its own. +used() -> long { + return aether_caps_used_bytes() +} + +fail_next(n: int) { + aether_test_fail_alloc_after(n) +} + +greet(n: int) -> string { + _x = string.from_int(n) + return string.concat("hello", string.from_int(n)) +} + +answer(n: int) -> int { + _x = string.from_int(n) + return 42 +} + +pair(n: int) -> Pair { + _x = string.from_int(n) + return Pair { a: 1, s: string.concat("p", string.from_int(n)) } +} + +greeter(n: int) -> fn { + _x = string.from_int(n) + tail = string.concat("closure ", string.from_int(n)) + return || -> string.concat("hello ", tail) +} diff --git a/tests/integration/emit_lib_failed_result/test_emit_lib_failed_result.sh b/tests/integration/emit_lib_failed_result/test_emit_lib_failed_result.sh new file mode 100644 index 000000000..3e2149199 --- /dev/null +++ b/tests/integration/emit_lib_failed_result/test_emit_lib_failed_result.sh @@ -0,0 +1,89 @@ +#!/bin/sh +# #3388: a call that returns normally but ends with an outcome other than +# AETHER_OK (a stop noted after its last safe point: here an allocation the +# code survived, with no loop after it) returns its zero value, and the +# library releases what it would have returned. It returned its real result +# ("hello7" with outcome 5, 42 with outcome 5), which a host following the +# docs (release only what an AETHER_OK call returned) leaked, and so did an +# Aether importer, whose stub raised the failure before it gave the string +# back to the library. +# +# Fixtures, not tests: results.ae and words.ae are libraries, and app.ae +# imports words.ae (a static library with a struct of strings is refused by +# an importer); the driver builds them. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +AE="$ROOT/build/ae" +AETHERC="$ROOT/build/aetherc" +CC="${CC:-cc}" +export AETHER_HOME="$ROOT" +export AETHER_STACK_TRACE=0 + +[ -x "$AE" ] || { echo " [SKIP] emit_lib_failed_result: $AE not built"; exit 0; } + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP" || true' EXIT +fail=0 +want='string NULL outcome 5, released yes +int 0 outcome 5 +struct 0 (null) outcome 5, released yes +closure (null) outcome 5, released yes +ok: hello7 42 1 p7 outcome 0' +app_want='raised: the call ran out of memory +the library holds what it held before: true +then: hello7' + +case "$(uname -s 2>/dev/null)" in + MINGW*|MSYS*|CYGWIN*|Windows_NT) ext=dll; exe=.exe ;; + Darwin) ext=dylib; exe="" ;; + *) ext=so; exe="" ;; +esac + +if ! "$AETHERC" --emit=lib --with=extern "$SCRIPT_DIR/results.ae" "$TMP/results.c" > "$TMP/results.log" 2>&1; then + echo " [FAIL] emit_lib_failed_result: aetherc --emit=lib failed" + tail -5 "$TMP/results.log" | sed 's/^/ /' + exit 1 +fi +# shellcheck disable=SC2046 +if ! "$CC" -O1 -I"$ROOT/include" -I"$ROOT/runtime" $("$AE" cflags --cflags) \ + "$TMP/results.c" "$SCRIPT_DIR/host.c" -o "$TMP/host" \ + $("$AE" cflags --libs) > "$TMP/cc.log" 2>&1; then + echo " [FAIL] emit_lib_failed_result: the host did not build" + head -10 "$TMP/cc.log" | sed 's/^/ /' + exit 1 +fi +got="$("$TMP/host" 2>"$TMP/host.err" | tr -d '\r')" +if [ "$got" != "$want" ]; then + echo " [FAIL] emit_lib_failed_result: C host:" + echo " got:"; printf '%s\n' "$got" | sed 's/^/ /' + echo " want:"; printf '%s\n' "$want" | sed 's/^/ /' + head -5 "$TMP/host.err" | sed 's/^/ /' + fail=1 +fi + +# The importer, the library with a runtime of its own. +mkdir -p "$TMP/imp" +cp "$SCRIPT_DIR/app.ae" "$TMP/imp/app.ae" +if ! (cd "$TMP/imp" && "$AE" build --emit=lib --with=extern "$SCRIPT_DIR/words.ae" \ + -o "$TMP/imp/libwords.$ext" > build.log 2>&1); then + echo " [FAIL] emit_lib_failed_result: ae build --emit=lib failed" + tail -5 "$TMP/imp/build.log" | sed 's/^/ /' + exit 1 +fi +got="$(cd "$TMP/imp" && "$AE" build app.ae -o app > app_build.log 2>&1 && \ + PATH="$TMP/imp:$PATH" LD_LIBRARY_PATH="$TMP/imp${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \ + DYLD_LIBRARY_PATH="$TMP/imp${DYLD_LIBRARY_PATH:+:$DYLD_LIBRARY_PATH}" \ + ./app$exe 2>app.err | tr -d '\r')" +if [ "$got" != "$app_want" ]; then + echo " [FAIL] emit_lib_failed_result: Aether importer:" + echo " got:"; printf '%s\n' "$got" | sed 's/^/ /' + echo " want:"; printf '%s\n' "$app_want" | sed 's/^/ /' + tail -5 "$TMP/imp/app_build.log" "$TMP/imp/app.err" 2>/dev/null | sed 's/^/ /' + fail=1 +fi + +if [ "$fail" = 0 ]; then + echo " [PASS] emit_lib_failed_result: a call that ends at its return with a stop noted returns its zero value, its string, struct and closure released by the library; an importer raises it and keeps nothing" +fi +exit $fail diff --git a/tests/integration/emit_lib_failed_result/words.ae b/tests/integration/emit_lib_failed_result/words.ae new file mode 100644 index 000000000..868ce99b3 --- /dev/null +++ b/tests/integration/emit_lib_failed_result/words.ae @@ -0,0 +1,20 @@ +// The library app.ae imports in binary form (#3388), built with a runtime +// of its own: results.ae's string export and test hooks, without the +// struct of strings an importer of a static library refuses. +import std.string + +extern aether_test_fail_alloc_after(n: int) +extern aether_caps_used_bytes() -> long + +used() -> long { + return aether_caps_used_bytes() +} + +fail_next(n: int) { + aether_test_fail_alloc_after(n) +} + +greet(n: int) -> string { + _x = string.from_int(n) + return string.concat("hello", string.from_int(n)) +} diff --git a/tests/integration/emit_lib_generated_alloc/host.c b/tests/integration/emit_lib_generated_alloc/host.c new file mode 100644 index 000000000..8f260bd01 --- /dev/null +++ b/tests/integration/emit_lib_generated_alloc/host.c @@ -0,0 +1,34 @@ +/* A C host of kinds.ae (#3377). Each export fails one allocation the + * generated code makes and must come back with the zero value and + * AETHER_OUT_OF_MEMORY instead of dereferencing NULL; the program's own + * malloc returns NULL to the script and the call ends when it returns; the + * library takes the next call. The library's C is linked into this program + * (one runtime). */ +#include +#include "libaether.h" + +extern int aether_k_cell(int); +extern int aether_k_ref(int); +extern int aether_k_lazy(int); +extern int aether_k_env(int); +extern int aether_k_box(int); +extern int aether_k_heap(int); +extern int aether_k_malloc(int); +extern int aether_plain(int); + +typedef int (*export_fn)(int); + +int main(void) { + static const struct { const char* name; export_fn fn; } kinds[] = { + { "cell", aether_k_cell }, { "ref", aether_k_ref }, { "lazy", aether_k_lazy }, + { "env", aether_k_env }, { "box", aether_k_box }, { "heap", aether_k_heap }, + { "malloc", aether_k_malloc }, { "plain", aether_plain }, + }; + for (size_t i = 0; i < sizeof(kinds) / sizeof(kinds[0]); i++) { + int r = kinds[i].fn(3); + printf("%s %d outcome %d \"%s\"\n", kinds[i].name, r, aether_last_outcome(), + aether_last_outcome_text()); + } + fflush(stdout); + return 0; +} diff --git a/tests/integration/emit_lib_generated_alloc/kinds.ae b/tests/integration/emit_lib_generated_alloc/kinds.ae new file mode 100644 index 000000000..2fa1d8687 --- /dev/null +++ b/tests/integration/emit_lib_generated_alloc/kinds.ae @@ -0,0 +1,87 @@ +// The library of the generated-allocation test (#3377): each export makes +// one kind of allocation the generated code makes for itself, with the +// runtime's test hook set to fail exactly that one. Each call must end with +// AETHER_OUT_OF_MEMORY, not dereference NULL. +import std.list + +extern aether_test_fail_alloc_after(n: int) +extern malloc(n: int) -> ptr +extern free(p: ptr) + +struct Point { + x: int +} + +// A captured variable a closure writes: its cell. +k_cell(n: int) -> int { + aether_test_fail_alloc_after(1) + x = n + bump = || { x = x + 1 } + bump() + return x +} + +// ref(value): its cell. +k_ref(n: int) -> int { + aether_test_fail_alloc_after(1) + r = ref(n) + v = ref_get(r) + ref_free(r) + return v +} + +// lazy(closure): its thunk. +k_lazy(n: int) -> int { + c = || { return 42 } + aether_test_fail_alloc_after(1) + l = lazy(c) + v = force(l) + thunk_free(l) + return v + n +} + +// A closure that captures a value: its environment. +k_env(n: int) -> int { + y = n + aether_test_fail_alloc_after(1) + f = || { return y + 1 } + return f() +} + +// A closure kept in a list: its box. +k_box(n: int) -> int { + l = list.new() + f = || { return 1 } + aether_test_fail_alloc_after(1) + _ = list.add(l, f) + s = list.size(l) + list.free(l) + return s + n +} + +// heap.new(T): the struct. +k_heap(n: int) -> int { + aether_test_fail_alloc_after(1) + p = heap.new(Point) + p.x = n + v = p.x + heap.free(p) + return v +} + +// The program's own malloc: NULL, as C's is, and the call ends when it +// returns, with its zero value: the -1 this returns is not a result (#3388). +k_malloc(n: int) -> int { + aether_test_fail_alloc_after(1) + p = malloc(16) + if p == null { + return -1 + } + free(p) + return n +} + +// Nothing fails. +plain(n: int) -> int { + return n * 2 +} diff --git a/tests/integration/emit_lib_generated_alloc/outside.ae b/tests/integration/emit_lib_generated_alloc/outside.ae new file mode 100644 index 000000000..56a399a6f --- /dev/null +++ b/tests/integration/emit_lib_generated_alloc/outside.ae @@ -0,0 +1,25 @@ +// Outside a library call (#3377): a generated allocation that fails is a +// panic naming what ran out, which a try catches; with no try, the program +// stops with that message instead of dereferencing NULL. +extern aether_test_fail_alloc_after(n: int) + +struct Point { + x: int +} + +main() { + try { + aether_test_fail_alloc_after(1) + p = heap.new(Point) + p.x = 1 + heap.free(p) + println("not refused") + } catch e { + println("caught: ${e}") + } + aether_test_fail_alloc_after(1) + q = heap.new(Point) + q.x = 2 + println("not reached ${q.x}") + heap.free(q) +} diff --git a/tests/integration/emit_lib_generated_alloc/test_emit_lib_generated_alloc.sh b/tests/integration/emit_lib_generated_alloc/test_emit_lib_generated_alloc.sh new file mode 100755 index 000000000..ea05e84cf --- /dev/null +++ b/tests/integration/emit_lib_generated_alloc/test_emit_lib_generated_alloc.sh @@ -0,0 +1,76 @@ +#!/bin/sh +# #3377: every allocation the generated code makes is checked. +# +# The helpers the C codegen emits (a captured variable's cell, ref, lazy, +# a closure's environment and box, heap.new) used malloc's result +# unchecked: out of memory, the program dereferenced NULL, a crash with no +# message, inside a library call or not. They now go through one checked +# allocation: inside a library call the call ends with AETHER_OUT_OF_MEMORY, +# elsewhere it is a panic naming what ran out (caught by a try, or the +# program stops with it). The program's own malloc still returns NULL, and +# ends a library call when it returns. The runtime's test hook fails each +# kind once. +# +# Fixtures, not tests: kinds.ae is a library and outside.ae a program; the +# driver builds both. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +AE="$ROOT/build/ae" +AETHERC="$ROOT/build/aetherc" +CC="${CC:-cc}" +export AETHER_HOME="$ROOT" +export AETHER_STACK_TRACE=0 + +[ -x "$AE" ] || { echo " [SKIP] emit_lib_generated_alloc: $AE not built"; exit 0; } + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP" || true' EXIT +oom='outcome 5 "the call ran out of memory"' +want="cell 0 $oom +ref 0 $oom +lazy 0 $oom +env 0 $oom +box 0 $oom +heap 0 $oom +malloc 0 $oom +plain 6 outcome 0 \"\"" + +if ! "$AETHERC" --emit=lib --with=extern "$SCRIPT_DIR/kinds.ae" "$TMP/kinds.c" > "$TMP/kinds.log" 2>&1; then + echo " [FAIL] emit_lib_generated_alloc: aetherc --emit=lib failed" + tail -5 "$TMP/kinds.log" | sed 's/^/ /' + exit 1 +fi +# shellcheck disable=SC2046 +if ! "$CC" -O1 -I"$ROOT/include" -I"$ROOT/runtime" $("$AE" cflags --cflags) \ + "$TMP/kinds.c" "$SCRIPT_DIR/host.c" -o "$TMP/host" \ + $("$AE" cflags --libs) > "$TMP/cc.log" 2>&1; then + echo " [FAIL] emit_lib_generated_alloc: the host did not build" + head -10 "$TMP/cc.log" | sed 's/^/ /' + exit 1 +fi +got="$("$TMP/host" 2>"$TMP/host.err" | tr -d '\r')" +if [ "$got" != "$want" ]; then + echo " [FAIL] emit_lib_generated_alloc: library calls:" + echo " got:"; printf '%s\n' "$got" | sed 's/^/ /' + echo " want:"; printf '%s\n' "$want" | sed 's/^/ /' + head -5 "$TMP/host.err" | sed 's/^/ /' + exit 1 +fi + +if ! "$AE" build "$SCRIPT_DIR/outside.ae" -o "$TMP/outside" > "$TMP/outside_build.log" 2>&1; then + echo " [FAIL] emit_lib_generated_alloc: ae build outside.ae failed" + tail -5 "$TMP/outside_build.log" | sed 's/^/ /' + exit 1 +fi +"$TMP/outside" > "$TMP/outside.out" 2> "$TMP/outside.err" +rc=$? +out="$(tr -d '\r' < "$TMP/outside.out")" +err="$(tr -d '\r' < "$TMP/outside.err")" +if [ "$out" != "caught: out of memory in heap.new" ] || [ $rc -eq 0 ] || + ! printf '%s\n' "$err" | grep -q "out of memory in heap.new"; then + echo " [FAIL] emit_lib_generated_alloc: outside a library call (exit $rc):" + printf '%s\n' "$out" "$err" | head -8 | sed 's/^/ /' + exit 1 +fi +echo " [PASS] emit_lib_generated_alloc: a failed cell, ref, lazy, closure environment, closure box or heap.new allocation ends a library call with AETHER_OUT_OF_MEMORY, and outside one is a panic naming it; the program's own malloc still returns NULL" diff --git a/tests/integration/emit_lib_interrupt/host.c b/tests/integration/emit_lib_interrupt/host.c new file mode 100644 index 000000000..84bd8aa88 --- /dev/null +++ b/tests/integration/emit_lib_interrupt/host.c @@ -0,0 +1,97 @@ +/* A C host of work.ae (#2956, D5). Each call is ended from outside its code + * and must come back with the zero value and the outcome, its defers run, + * and the library takes the next call: + * + * spin 0 outcome 2 defers 1 a loop cut by a 5 ms deadline + * guarded 0 outcome 2 defers 2 the same inside a user try + * parse outcome 2 a large JSON parse, 5 ms deadline + * parse 3 outcome 0 a small one, no deadline + * cancel 0 outcome 3 defers 3 cancelled from another thread + * after -5 outcome 0 defers 4 a fresh call + * + * With the argument cancel-first, a fresh process cancels a call before + * anything armed a deadline (the loop heads' flag was 0 when the loop + * began): + * + * cancel 0 outcome 3 defers 1 + * + * The library's C is linked into this program (one runtime). */ +#include +#include +#include +#include +#include +#include "libaether.h" + +extern int aether_spin(int); +extern int aether_guarded(int); +extern int aether_parse_size(const char*); + +static int defers; +void host_defer_ran(void) { defers++; } + +/* The cancel: the worker takes the token of the call it is about to make, + * says so, and makes it; this thread cancels once it has heard. */ +static atomic_int g_ready; +static AetherExecToken g_token; +static int g_cancel_ret, g_cancel_outcome; + +static void* worker(void* arg) { + (void)arg; + aether_set_call_deadline(0); + g_token = aether_exec_token(); + atomic_store(&g_ready, 1); + g_cancel_ret = aether_spin(2000000000); + g_cancel_outcome = aether_last_outcome(); + return NULL; +} + +/* A call on another thread, cancelled from this one. */ +static void run_cancel(void) { + pthread_t t; + atomic_store(&g_ready, 0); + if (pthread_create(&t, NULL, worker, NULL) != 0) { printf("no thread\n"); return; } + while (!atomic_load(&g_ready)) { } + if (aether_exec_cancel(g_token) != 0) printf("cancel refused\n"); + pthread_join(t, NULL); + printf("cancel %d outcome %d defers %d\n", g_cancel_ret, g_cancel_outcome, defers); +} + +int main(int argc, char** argv) { + if (argc > 1 && strcmp(argv[1], "cancel-first") == 0) { + run_cancel(); + fflush(stdout); + return 0; + } + aether_set_call_deadline(5); + int r = aether_spin(2000000000); + printf("spin %d outcome %d defers %d\n", r, aether_last_outcome(), defers); + + aether_set_call_deadline(5); + r = aether_guarded(2000000000); + printf("guarded %d outcome %d defers %d\n", r, aether_last_outcome(), defers); + + /* [0,0,...]: a few million elements, far more than 5 ms of parsing. */ + size_t n = 4000000; + char* doc = (char*)malloc(n * 2 + 2); + if (!doc) return 1; + doc[0] = '['; + for (size_t i = 0; i < n; i++) { doc[1 + 2 * i] = '0'; doc[2 + 2 * i] = ','; } + doc[2 * n] = ']'; + doc[2 * n + 1] = '\0'; + aether_set_call_deadline(5); + (void)aether_parse_size(doc); + printf("parse outcome %d\n", aether_last_outcome()); + free(doc); + + aether_set_call_deadline(0); + r = aether_parse_size("[1,2,3]"); + printf("parse %d outcome %d\n", r, aether_last_outcome()); + + run_cancel(); + + r = aether_spin(10); + printf("after %d outcome %d defers %d\n", r, aether_last_outcome(), defers); + fflush(stdout); + return 0; +} diff --git a/tests/integration/emit_lib_interrupt/test_emit_lib_interrupt.sh b/tests/integration/emit_lib_interrupt/test_emit_lib_interrupt.sh new file mode 100755 index 000000000..2825f8809 --- /dev/null +++ b/tests/integration/emit_lib_interrupt/test_emit_lib_interrupt.sh @@ -0,0 +1,66 @@ +#!/bin/sh +# #2956 (D5): a call's deadline, or a cancel from another thread, ends the +# call instead of cutting a loop short. +# +# A tripped deadline broke the loop it was checked in and the export +# returned what it had, an ordinary-looking value that only +# aether_deadline_tripped() said was partial; a C-backed std operation (a +# large JSON parse) had no check at all and ran to the end; nothing could +# cancel a call from another thread. Now the call ends at a safe point (a +# loop head, or every few thousand units of a long std operation) with +# AETHER_DEADLINE or AETHER_CANCELLED and the zero value; its defers and +# owned locals are released on the way, a `try` in it does not catch the +# interruption, and the library takes the next call. +# +# Fixtures, not tests: work.ae is a library; the driver builds it. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +AE="$ROOT/build/ae" +AETHERC="$ROOT/build/aetherc" +CC="${CC:-cc}" +export AETHER_HOME="$ROOT" + +[ -x "$AE" ] || { echo " [SKIP] emit_lib_interrupt: $AE not built"; exit 0; } + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP" || true' EXIT +want='spin 0 outcome 2 defers 1 +guarded 0 outcome 2 defers 2 +parse outcome 2 +parse 3 outcome 0 +cancel 0 outcome 3 defers 3 +after -5 outcome 0 defers 4' + +if ! "$AETHERC" --emit=lib --with=extern "$SCRIPT_DIR/work.ae" "$TMP/work.c" > "$TMP/work.log" 2>&1; then + echo " [FAIL] emit_lib_interrupt: aetherc --emit=lib failed" + tail -5 "$TMP/work.log" | sed 's/^/ /' + exit 1 +fi +# shellcheck disable=SC2046 +if ! "$CC" -O1 -I"$ROOT/include" -I"$ROOT/runtime" $("$AE" cflags --cflags) \ + "$TMP/work.c" "$SCRIPT_DIR/host.c" -o "$TMP/host" \ + $("$AE" cflags --libs) -lpthread > "$TMP/cc.log" 2>&1; then + echo " [FAIL] emit_lib_interrupt: the host did not build" + head -10 "$TMP/cc.log" | sed 's/^/ /' + exit 1 +fi +got="$("$TMP/host" 2>"$TMP/host.err" | tr -d '\r')" +if [ "$got" != "$want" ]; then + echo " [FAIL] emit_lib_interrupt:" + echo " got:"; printf '%s\n' "$got" | sed 's/^/ /' + echo " want:"; printf '%s\n' "$want" | sed 's/^/ /' + head -5 "$TMP/host.err" | sed 's/^/ /' + exit 1 +fi +# A cancel in a process where nothing was armed when the loop began: the +# loop heads must see the flag another thread sets while they run. +got="$("$TMP/host" cancel-first 2>"$TMP/host.err" | tr -d '\r')" +if [ "$got" != "cancel 0 outcome 3 defers 1" ]; then + echo " [FAIL] emit_lib_interrupt: a cancel before any deadline was armed:" + echo " got:"; printf '%s\n' "$got" | sed 's/^/ /' + echo " want:"; echo " cancel 0 outcome 3 defers 1" + head -5 "$TMP/host.err" | sed 's/^/ /' + exit 1 +fi +echo " [PASS] emit_lib_interrupt: a deadline ends a loop's call with AETHER_DEADLINE and the zero value, defers run, a try does not catch it; a large JSON parse stops early; a cancel from another thread ends the call with AETHER_CANCELLED, armed before or not; the next call works" diff --git a/tests/integration/emit_lib_interrupt/work.ae b/tests/integration/emit_lib_interrupt/work.ae new file mode 100644 index 000000000..50b4f27c0 --- /dev/null +++ b/tests/integration/emit_lib_interrupt/work.ae @@ -0,0 +1,48 @@ +// The library of the interruption test (#2956, D5): calls a host deadline +// or a cancel from another thread ends. +import std.json + +extern host_defer_ran() + +fn churn(n: int) -> int { + defer host_defer_ran() + i = 0 + s = 0 + while i < n { + if i % 2 == 0 { + s = s + i + } else { + s = s - i + } + i = i + 1 + } + return s +} + +// A loop the deadline cuts: the zero value, not what the loop had, and the +// defer has run. +spin(n: int) -> int { + return churn(n) +} + +// The same loop inside a user `try`: the interruption is not the try's. +guarded(n: int) -> int { + got = 7 + try { + got = churn(n) + } catch e { + got = -1 + } + return got +} + +// A parse that runs long on its input, in C: it stops early, cleanly. +parse_size(doc: string) -> int { + v, err = json.parse(doc) + if err != "" { + return -2 + } + n = json.array_size(v) + json.free(v) + return n +} diff --git a/tests/integration/f32_value_type/test_f32_value_type.sh b/tests/integration/f32_value_type/test_f32_value_type.sh index b103e5cbc..e7aea5dba 100644 --- a/tests/integration/f32_value_type/test_f32_value_type.sh +++ b/tests/integration/f32_value_type/test_f32_value_type.sh @@ -65,7 +65,7 @@ fi # The C: a `float` field, a `float*` view with plain stores, a float local. AETHER_HOME="$ROOT" "$AETHERC" "$tmp/main.ae" "$tmp/out.c" >/dev/null 2>&1 -for want_c in ' float x;' 'AetherSlice verts = aether_slice_view((void\*)(calloc(1, (size_t)(32))));' 'float z = 1.25;' 'float scale(float x, double k)'; do +for want_c in ' float x;' 'AetherSlice verts = aether_slice_view((void\*)(_aether_alloc_or_null((size_t)(32))));' 'float z = 1.25;' 'float scale(float x, double k)'; do if ! grep -q "$want_c" "$tmp/out.c"; then echo " [FAIL] f32_value_type: expected '$want_c' in the C" fail=1 diff --git a/tests/integration/fatal_flushes_stdout/held.c b/tests/integration/fatal_flushes_stdout/held.c new file mode 100644 index 000000000..79dffb6de --- /dev/null +++ b/tests/integration/fatal_flushes_stdout/held.c @@ -0,0 +1,41 @@ +/* A fatal error while another thread holds stdout (#3435). The program + * prints two lines (stdout is a file, so they wait in its buffer), a second + * thread takes stdout's lock, and the program fails a bounds check, which + * flushes stdout and aborts. + * + * held the thread lets go after milliseconds, as one in the + * middle of a println does: the flush waits for it, and both + * lines reach the file (a single try of the lock lost them); + * held -1 the thread never lets go, as one blocked writing to a pipe + * nobody reads: the flush gives up, and the abort comes at + * once (a plain fflush waited for ever). */ +#include +#include +#include +#include +#include "aether_bounds_check.h" + +static int g_hold_ms; +static volatile int g_held; + +static void* holder(void* arg) { + (void)arg; + flockfile(stdout); + g_held = 1; + if (g_hold_ms < 0) + for (;;) pause(); + usleep((useconds_t)g_hold_ms * 1000); + funlockfile(stdout); + return NULL; +} + +int main(int argc, char** argv) { + if (argc < 2) return 2; + g_hold_ms = atoi(argv[1]); + printf("first line\nsecond line\n"); + pthread_t t; + if (pthread_create(&t, NULL, holder, NULL) != 0) return 2; + while (!g_held) usleep(1000); + aether_check_array_access(5, 3, "xs", "held.c", 1); + return 0; +} diff --git a/tests/integration/fatal_flushes_stdout/test_fatal_flushes_stdout.sh b/tests/integration/fatal_flushes_stdout/test_fatal_flushes_stdout.sh new file mode 100644 index 000000000..6b6f36ba5 --- /dev/null +++ b/tests/integration/fatal_flushes_stdout/test_fatal_flushes_stdout.sh @@ -0,0 +1,89 @@ +#!/bin/sh +# #3435: what a program printed before a fatal error reaches its stdout. +# +# The runtime ends the process on an error it cannot return (a panic outside +# any try, a failed runtime check, a fatal std.log record, an +# out-of-memory it cannot report) with its message on stderr and abort(). +# stdout is fully buffered when it is a file or a pipe, and glibc's abort() +# has flushed no stdio stream since 2.27, so the program's last lines were +# lost (emit_lib_generated_alloc's "caught:" line, on every Linux leg). +# macOS's abort() flushes, so this failed on Linux only. +# +# Each program prints two lines and then fails; its stdout goes to a file, +# which must hold both lines. +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +AE="$ROOT/build/ae" +NAME=fatal_flushes_stdout +[ -x "$AE" ] || [ -x "$AE.exe" ] || { echo " [SKIP] $NAME: ae not built"; exit 0; } + +T="$(mktemp -d)" +trap 'rm -rf "$T"' EXIT +fails=0 + +# check