diff --git a/Docs/04-advanced-features/web-servers.md b/Docs/04-advanced-features/web-servers.md index 0e1cf37d..55a80a08 100644 --- a/Docs/04-advanced-features/web-servers.md +++ b/Docs/04-advanced-features/web-servers.md @@ -1194,6 +1194,56 @@ curl -k https://localhost:8443/ ⚠️ Self-signed certificates are for development only. In production, use a certificate from a real authority (e.g. Let's Encrypt via certbot). +### Multiple domains on one HTTPS port + +Add `for "hostname"` to a certificate/key pair, then join additional pairs with +`and certificate`. WFL selects the certificate using the DNS name sent by the +client in its TLS handshake (Server Name Indication, or SNI): + +```wfl +// CI-SKIP: Requires certificate and key files covering the configured domains. +listen on port 8443 secured with + certificate "one.pem" and key "one.key" for "one.example.com" + and certificate "two.pem" and key "two.key" for "two.example.com" as secure_server +``` + +Hostnames and paths may also be text variables or parenthesized expressions. +Each clause evaluates its certificate path, key path, then hostname exactly once +in source order, stopping at the first invalid operand. +The listener supports up to 128 named entries. Names are matched exactly and +case-insensitively. Use DNS names (international names in ASCII/Punycode form), +without a URL scheme, port, trailing dot, IP address, or wildcard. A wildcard +certificate can cover a configured exact name, but `for "*.example.com"` is not +a wildcard routing rule. Repeat the pair for each exact name it should serve. + +WFL loads and validates every pair before opening the listening socket. Invalid +DNS names, duplicate names (including different capitalization), unreadable or +malformed files, mismatched keys, and certificates that do not cover their +configured hostname stop startup with an error. Certificates are loaded once; +restart the listener after replacing files to pick up renewals. WFL does not +issue or automatically renew certificates. + +A listener containing only named pairs rejects clients with unknown or missing +SNI during the TLS handshake. It does not silently use the `.wflcfg` certificate +as a fallback. To provide a fallback explicitly, put an unnamed pair first: + +```wfl +// CI-SKIP: Requires certificate and key files covering the configured domains. +listen on port 8443 secured with + certificate "default.pem" and key "default.key" + and certificate "one.pem" and key "one.key" for "one.example.com" as secure_server +``` + +The default pair is used when SNI is absent or does not match a named entry. +Clients still verify that the selected certificate covers the name they used. +Existing single-certificate statements and bare `secured` configuration behave +as before. + +SNI selects the TLS certificate; it does not dispatch application handlers or +authorize access to a site. Route requests by `header "Host" of req` and their +path in your WFL application, and reject unknown HTTP hosts. An HTTP Host header +can differ from the TLS name; WFL does not enforce equality between them. + ### Production notes - The private key file must be readable by the WFL process — protect it with file permissions (`chmod 600 key.pem`). diff --git a/Docs/reference/configuration-reference.md b/Docs/reference/configuration-reference.md index 96d5075e..7e8eeb18 100644 --- a/Docs/reference/configuration-reference.md +++ b/Docs/reference/configuration-reference.md @@ -628,6 +628,11 @@ listen on port 8443 secured as s A plain `listen` (without `secured`) **always** serves HTTP — putting cert paths in `.wflcfg` never silently upgrades HTTP to HTTPS. +For multiple certificates on the same port, use the named certificate clauses +on `listen` described in [Multiple domains on one HTTPS port](../04-advanced-features/web-servers.md#multiple-domains-on-one-https-port). +These default configuration paths apply to bare `secured` statements only; +named-only listeners do not inherit a fallback certificate from configuration. + #### `web_server_tls_key_file` Default TLS private key (PEM) for bare `listen … secured`. diff --git a/Docs/reference/keyword-reference.md b/Docs/reference/keyword-reference.md index 889ac455..88c7b4dd 100644 --- a/Docs/reference/keyword-reference.md +++ b/Docs/reference/keyword-reference.md @@ -370,6 +370,10 @@ connect to database at "sqlite://app.db" as db - 5 appear contextual but are actually always reserved ### "What about `secured`, `certificate`, `key`, `redirecting`, `content_type`, `transaction`, `schema`, `changes`, `without following redirects`?" → Not keywords + +For HTTPS, `certificate ... and key ... for "example.com"` associates a pair +with a DNS name using the existing `for` keyword. Join more named pairs with +`and certificate`. See [multiple-domain HTTPS](../04-advanced-features/web-servers.md#multiple-domains-on-one-https-port). These words are recognized purely by position — inside `listen` / `respond` statements, in `in transaction on db:` / `in transaction on db for schema changes:` / `end transaction`, or as the `and without following redirects` clause in `open url` — and are **never reserved**. Use them as variable names freely. `raise_error` is a standard-library function, not a keyword. See [Marker Words That Are Not Keywords](reserved-keywords.md#marker-words-that-are-not-keywords-at-all). --- diff --git a/Docs/reference/reserved-keywords.md b/Docs/reference/reserved-keywords.md index d09d03b2..03aaf0e8 100644 --- a/Docs/reference/reserved-keywords.md +++ b/Docs/reference/reserved-keywords.md @@ -104,6 +104,11 @@ A few words have special meaning in exactly one statement position but are **not - `schema`, `changes` - optional SQLite transaction mode in `in transaction on db for schema changes:` - `without following redirects` - per-request redirect clause in `open url at address and without following redirects and read response as reply`; the individual words and the complete phrase remain ordinary variable names outside that clause position +TLS pairs can also use the existing `for` keyword to select a DNS name: +`certificate "cert.pem" and key "key.pem" for "example.com"`. Additional named +pairs begin with `and certificate`; this adds no reserved words. See +[multiple-domain HTTPS](../04-advanced-features/web-servers.md#multiple-domains-on-one-https-port). + `raise_error` is an ordinary standard-library function called with `call raise_error with message` or `raise_error of message`; it is not a keyword. diff --git a/Engineering/evidence/2026-09-23-tls-sni.md b/Engineering/evidence/2026-09-23-tls-sni.md new file mode 100644 index 00000000..3fd8158b --- /dev/null +++ b/Engineering/evidence/2026-09-23-tls-sni.md @@ -0,0 +1,156 @@ +# TLS SNI certificate selection + +## Scope and risk + +Risk class **R3**: TLS certificate selection, untrusted ClientHello input, +language syntax, and backward compatibility. One secured listener can select +among at most 128 named certificate/key pairs, with an optional explicit default +pair. Named-only listeners reject missing and unknown SNI. No HTTP routing, +client authentication, certificate issuance, renewal, or configuration-file +format changes are included. + +The existing connection transport, queues, request budgets, streaming behavior, +and shutdown ownership are unchanged. Rustls handles ClientHello decoding and +hostname/certificate verification. Selection performs an immutable map lookup; +there is no file I/O or configuration mutation during a handshake. + +## Test-first history + +- Published base: `9c7c8f55057789c1c5f0a275989b433ed0d8438b` (`main`). +- Published Red: `6ad75a47` (`test: specify multi-certificate TLS SNI behavior (red)`). +- Published Green: `b1f8d747` (`feat: select HTTPS certificates by TLS server name`), + a direct descendant of the Red commit. +- The original development lineage was base `9bf3fa48`, Red `43b4889b`, + Green `341099ee`. Before publication, only the three SNI commits were rebased + onto current `main` to exclude the unrelated, unmerged ES256 feature present + in the starting checkout. The original validation below describes that local + development lineage; post-rebase validation and PR CI are recorded separately. +- Before any production change, `cargo test --test web_server_sni_test -- --nocapture` + compiled and failed all five initial tests: `for` and additional certificate + clauses were rejected by the existing parser; the real binary exited before + creating a listener. These are the missing language capability under test. +- During implementation, the multiline case exposed the need to consume line + boundaries between certificate clauses. The operand test also needed its + fixture variable changed from reserved `server` to `secure_server` before its + analyzer/typechecker assertions could execute. That fixture failure is not + counted as evidence of an operand-validation defect. +- The final suite broadens the initial contracts to seven tests, including + malformed/oversized lists and runtime operand checks. + +## Acceptance criteria and coverage + +| Contract | Evidence | +| --- | --- | +| Two DNS names share one TLS port and receive the correct distinct certificate | `sni_binary_selects_two_certs_rejects_unknown_and_survives_stalled_clients`: real WFL child process, generated self-signed certificates added to the client trust store, hostname verification enabled, exact peer certificate bytes and HTTP response asserted | +| Unknown/missing SNI is rejected without a default | Same test; unknown SNI must return a server TLS alert, rather than merely fail client hostname verification | +| An explicit unnamed first pair handles unknown/missing SNI; named entries take precedence | `sni_explicit_default_handles_unknown_and_missing_sni` | +| Configuration does not silently add a fallback to named-only listeners | `sni_runtime_checks_dynamic_operands_and_ignores_implicit_default` | +| Invalid DNS names, case-insensitive duplicates, SAN mismatch, missing files and key mismatch fail startup | `sni_invalid_configuration_fails_before_binding`; existing malformed PEM/key cases in `web_server_tls_test` | +| Literals, variables, multiline declarations, semantic/type checks and runtime text checks work | `sni_syntax_accepts_named_certificates_and_variables`, `sni_operands_are_analyzed_and_typechecked`, and runtime operand test | +| Incomplete, ambiguous and oversized lists are rejected; 128 entries parse | `sni_parser_rejects_incomplete_ambiguous_and_oversized_lists`; retained `fuzz/seeds/fuzz_parser/seed_tls_sni*.wfl` inputs | +| Slow/failed handshakes do not stop either domain; closing the listener cancels idle established/partial connections | Real-binary SNI test runs two domain requests concurrently beside partial and idle clients, checks close/error rather than post-close bytes, and verifies the port stops accepting | +| Single-certificate/configured TLS, HTTP/2, HTTP/1.1, TLS 1.2/1.3, redirects, peer IP, and lifecycle remain compatible | All 27 existing TLS parser/runtime tests plus web integration and the full Rust suite | +| WFL startup errors remain catchable | Both tests in `TestPrograms/tls_sni/startup.test.wfl` with the release binary | + +Existing cancellation, timeout, disconnect, bounded-queue/backpressure, +resource-limit and handler-isolation tests passed in the full Rust suite. This +change does not introduce a new queue, task, handshake scheduler or streaming +path. The existing parser fuzz target builds against the change; no extended +fuzz campaign or quantitative coverage run is claimed. + +## Verification + +Local platform: Windows x86-64, Rust 1.98.1. No dependencies or lockfiles changed. + +- `cargo fmt --all -- --check`: passed. +- `cargo clippy --all-targets --all-features -- -D warnings`: passed. +- `cargo test --all --no-fail-fast`: passed, 2,431 tests across 177 result groups; + 27 pre-existing ignored cases remain unchanged. All seven new tests ran. +- `cargo build --release`: passed. +- `cargo build --locked -p wfl-lsp`: passed. +- `cargo check --locked --manifest-path fuzz/Cargo.toml`: passed. +- `scripts/run_web_tests.ps1`: passed all three scenarios, including native TLS + and HTTP-to-HTTPS redirection. +- `scripts/run_integration_tests.ps1 -TestOnly`: Rust integration tests passed; + the WFL sweep reported **165 passed, 1 failed, 24 pre-existing exclusions**. + The new `tls_sni/startup.test.wfl` passed. The failure was + `file_io_comprehensive.wfl` scanning `.` recursively and encountering + `target/test-artifacts/config-review/snapshot-5n0sbodd`, an inaccessible + artifact directory created September 17, before this change. A diagnostic + rerun from the repository root reproduced `Access is denied (os error 5)` at + line 123. Python directory traversal independently identified that directory. + The unchanged program passed from an isolated directory under + `target/test-artifacts/tls-sni-file-io/`. The original suite failure remains + recorded; this is not a claim that the original full sweep passed. Three + source-root test files left by its failed cleanup were removed afterward. +- `target/release/wfl --execution-timeout 330 --test tests/fixtures/cli_budget/long-run.test.wfl`: + passed the real 305-second execution-budget check. +- `python -X utf8 scripts/validate_docs_examples.py --ci --force`: passed all + 38 registered examples. The external-certificate example is explicitly + non-executable and checked at layers 1–4; real TLS execution is tested above. +- The built `wfl-lsp --mcp` passed `parse_wfl`, `analyze_wfl`, `typecheck_wfl` + and `lint_wfl` on both new WFL files, with no diagnostics/type/lint errors. +- `python scripts/check_repo_hygiene.py --mode static`: passed. + +Raw local reports are ephemeral under `target/reports/tls-sni/`. The first +sandboxed full test run failed in the existing Git patch round-trip test because +Git could not access the temporary directory. Running the unchanged suite with +that filesystem restriction removed passed. The first documentation run also +hit Python's Windows text-decoding error; explicit UTF-8 fixed the environment. +Neither failure was hidden through skipped tests or relaxed assertions. The +validator still emits existing warnings for its manifest schema metadata keys. + +## PR review follow-up (2026-09-23) + +[PR #746](https://github.com/WebFirstLanguage/wfl/pull/746) publishes only the +SNI changes. After rebasing onto `main`, all 34 initial TLS tests passed locally. + +The operand-order finding was reproduced in test-only commit `02972e3a`: +`sni_evaluates_operands_once_in_source_order` observed `host;cert;key;` instead +of `cert;key;host;`. The follow-up fix assigns named result fields in source +order and short-circuits on an invalid operand. All 36 TLS tests (nine SNI, +14 original parser, 13 original runtime) then passed. The added negative case +also verifies that an invalid certificate operand prevents key/hostname side +effects. + +The fallback finding was investigated against the actual locked Rustls 0.23.45 +resolver: `ResolvesServerCertUsingSni::resolve` only looks up the supplied name; +it does not filter the key by signature schemes. The new +`sni_incompatible_named_key_does_not_select_compatible_default` test passed +before any runtime fix. It gives both an ECDSA named certificate and an Ed25519 +default certificate the same DNS name, then restricts a TLS 1.3 client to +Ed25519. Configured SNI receives a server fatal alert; absent SNI succeeds with +the exact default certificate; an unrestricted named request receives the exact +named certificate. No resolver behavior change was needed. + +A subsequent review found that the separate unused-variable pass did not visit +TLS operands. Test-only commit `70745701` records two failing regressions: the +real `wfl --analyze` CLI incorrectly reported all five default/named TLS +variables unused, and the analyzer unit test reported six unused variables +instead of only its deliberately unused sentinel. The fix visits both default +paths and each named certificate's certificate, key and hostname expressions. +All 38 analyzer tests and all 37 TLS tests (ten SNI, 14 parser, 13 runtime) +then passed, retaining detection of genuinely unused variables. + +The initial PR head's Linux/Windows WFL-program suites, build/test/Clippy, +fuzz compilation, repository hygiene, database, release scripts, config lint and +Docker jobs passed. Its integration jobs reached the long-budget step without +failures. These results are superseded by final-head checks after pushing the +review fix; final CI links and review disposition are recorded on the PR. + +## Release boundaries and recovery + +No deployment or merge is claimed. The original local validation did not include +Linux execution or independent R3 review; the PR now supplies platform CI and +independent review. Clean final-head Windows/Linux CI and resolved review findings +remain merge/release gates. Extension host tests were not run locally because +no VS Code extension code, dependencies, or protocol changes were made; the PR's +Build, Test, Clippy job runs the extension checks. +The repository-root file-I/O sweep failure needs a clean test workspace (or +repair of the old inaccessible artifact directory) before that gate can pass. + +Revert the feature commit to remove the new syntax. Existing single-certificate +programs require no migration. Programs using the new named syntax must return +to a single certificate or proxy TLS before running on an older binary. No +persistent state, certificate files, or external server configuration is changed +by this implementation task. diff --git a/History/dev-diary/2026/2026-09-23-tls-sni.md b/History/dev-diary/2026/2026-09-23-tls-sni.md new file mode 100644 index 00000000..43f824b6 --- /dev/null +++ b/History/dev-diary/2026/2026-09-23-tls-sni.md @@ -0,0 +1,36 @@ +# Multiple TLS certificates on one listener + +WFL now accepts named certificate/key pairs on secured listeners using +`for "hostname"` and additional `and certificate` clauses. The original unnamed +pair remains an optional explicit fallback; bare `secured` still uses the +existing configuration paths. Named-only listeners reject unknown or absent +SNI instead of inheriting a default from configuration. + +The runtime builds a Rustls SNI resolver before binding, validates DNS names and +certificate coverage, and rejects duplicate names and invalid key pairs. The +existing per-connection handshake and cancellation transport is unchanged. +Certificate selection does not add HTTP virtual-host routing or Host/SNI +authorization checks. Certificate renewal still requires a listener restart. + +Tests exercise the real WFL binary with generated certificates and verified TLS +clients, assert the selected certificate bytes and HTTP responses for two names, +and cover fallback, rejection, startup validation, partial handshakes, and +shutdown. Parser, analyzer, typechecker, and WFL startup regression cases cover +the language boundary. See the corresponding engineering evidence record for +commands, outcomes, and remaining release gates. + +## Review follow-up (2026-09-23) + +PR review identified that named-clause operands ran in hostname-first order. +A failing regression observed `host;cert;key;` instead of the source order +`cert;key;host;`. The runtime now evaluates each operand once in source order +and stops on an invalid operand. A separate verified TLS regression confirms +that a named ECDSA key incompatible with an Ed25519-only client fails the +handshake rather than falling back to a compatible default certificate covering +the same hostname; the pinned Rustls resolver already preserves that behavior. + +A further review caught a separate static-analysis visitor that did not count +TLS operands as variable uses. Unit and real-CLI regressions reproduced false +unused-variable diagnostics for certificate paths, key paths, and hostnames. +That visitor now traverses both default and named pairs while retaining warnings +for genuinely unused variables. diff --git a/TestPrograms/docs_examples/_meta/manifest.json b/TestPrograms/docs_examples/_meta/manifest.json index 41bf8e0d..aaa8cbac 100644 --- a/TestPrograms/docs_examples/_meta/manifest.json +++ b/TestPrograms/docs_examples/_meta/manifest.json @@ -1,4 +1,10 @@ { + "docs_examples/tls_sni/multiple_domains.wfl": { + "doc_section": "Docs/04-advanced-features/web-servers.md#multiple-domains-on-one-https-port", + "type": "snippet", + "validate_layers": [1, 2, 3, 4], + "skip_execution": true + }, "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://wfl.example.com/schemas/docs-examples-manifest.json", "title": "WFL Documentation Examples Manifest", diff --git a/TestPrograms/docs_examples/tls_sni/multiple_domains.wfl b/TestPrograms/docs_examples/tls_sni/multiple_domains.wfl new file mode 100644 index 00000000..aa078fda --- /dev/null +++ b/TestPrograms/docs_examples/tls_sni/multiple_domains.wfl @@ -0,0 +1,10 @@ +// CI-SKIP: Requires certificate and key files covering the configured domains. +listen on port 8443 secured with + certificate "one.pem" and key "one.key" for "one.example.com" + and certificate "two.pem" and key "two.key" for "two.example.com" as secure_server +close server secure_server + +listen on port 8443 secured with + certificate "default.pem" and key "default.key" + and certificate "one.pem" and key "one.key" for "one.example.com" as fallback_server +close server fallback_server diff --git a/TestPrograms/tls_sni/startup.test.wfl b/TestPrograms/tls_sni/startup.test.wfl new file mode 100644 index 00000000..27668f67 --- /dev/null +++ b/TestPrograms/tls_sni/startup.test.wfl @@ -0,0 +1,23 @@ +// Named TLS certificates are validated before the listener starts. +describe "TLS domain configuration": + test "invalid DNS names fail before certificate files are read": + store caught_message as "" + try: + listen on port 0 secured with certificate "unused.pem" and key "unused.key" for "https://example.com" as secure_server + when error: + change caught_message to error_message + end try + expect caught_message contains "Invalid TLS DNS hostname" to be yes + end test + + test "named certificates retain the existing actionable missing-file error": + store caught_message as "" + try: + listen on port 0 secured with certificate "" and key "" for "one.test" + and certificate "" and key "" for "two.test" as secure_server + when error: + change caught_message to error_message + end try + expect caught_message contains "Cannot open TLS certificate file" to be yes + end test +end describe diff --git a/fuzz/seeds/fuzz_parser/seed_tls_sni.wfl b/fuzz/seeds/fuzz_parser/seed_tls_sni.wfl new file mode 100644 index 00000000..276bea32 --- /dev/null +++ b/fuzz/seeds/fuzz_parser/seed_tls_sni.wfl @@ -0,0 +1,4 @@ +listen on port 0 secured with + certificate "one.pem" and key "one.key" for "one.example.com" + and certificate "two.pem" and key "two.key" for "two.example.com" as secure_server +close server secure_server diff --git a/fuzz/seeds/fuzz_parser/seed_tls_sni_default.wfl b/fuzz/seeds/fuzz_parser/seed_tls_sni_default.wfl new file mode 100644 index 00000000..6dda2967 --- /dev/null +++ b/fuzz/seeds/fuzz_parser/seed_tls_sni_default.wfl @@ -0,0 +1,2 @@ +listen on port 0 secured with certificate "default.pem" and key "default.key" + and certificate "one.pem" and key "one.key" for "one.example.com" as secure_server diff --git a/fuzz/seeds/fuzz_parser/seed_tls_sni_incomplete.wfl b/fuzz/seeds/fuzz_parser/seed_tls_sni_incomplete.wfl new file mode 100644 index 00000000..764f5872 --- /dev/null +++ b/fuzz/seeds/fuzz_parser/seed_tls_sni_incomplete.wfl @@ -0,0 +1,2 @@ +listen on port 0 secured with certificate "one.pem" and key "one.key" for "one.example.com" + and certificate "two.pem" and key "two.key" for diff --git a/src/analyzer/mod.rs b/src/analyzer/mod.rs index d0b5b097..064cdb16 100644 --- a/src/analyzer/mod.rs +++ b/src/analyzer/mod.rs @@ -2755,6 +2755,11 @@ impl Analyzer { if let Some(key_path) = &tls_config.key_path { self.analyze_expression(key_path); } + for certificate in &tls_config.sni_certificates { + self.analyze_expression(&certificate.hostname); + self.analyze_expression(&certificate.cert_path); + self.analyze_expression(&certificate.key_path); + } } // Analyze the redirect target port expression if present diff --git a/src/analyzer/static_analyzer.rs b/src/analyzer/static_analyzer.rs index 55788503..a384b161 100644 --- a/src/analyzer/static_analyzer.rs +++ b/src/analyzer/static_analyzer.rs @@ -1347,8 +1347,18 @@ impl Analyzer { self.mark_used_in_expression(headers, usages); } } - Statement::ListenStatement { port, .. } => { + Statement::ListenStatement { port, tls, .. } => { self.mark_used_in_expression(port, usages); + if let Some(tls) = tls { + for path in tls.cert_path.iter().chain(&tls.key_path) { + self.mark_used_in_expression(path, usages); + } + for certificate in &tls.sni_certificates { + self.mark_used_in_expression(&certificate.cert_path, usages); + self.mark_used_in_expression(&certificate.key_path, usages); + self.mark_used_in_expression(&certificate.hostname, usages); + } + } } Statement::WaitForRequestStatement { server, @@ -2275,6 +2285,27 @@ mod tests { assert_eq!(diagnostics[0].code, "ANALYZE-UNUSED"); } + #[test] + fn test_tls_operands_are_used_but_unreferenced_variables_remain_unused() { + let input = r#" +store default_cert as "default.pem" +store default_key as "default.key" +store named_cert as "one.pem" +store named_key as "one.key" +store hostname as "one.test" +store unused_tls_setting as "unused" +listen on port 0 secured with certificate default_cert and key default_key + and certificate named_cert and key named_key for hostname as secure_server +close server secure_server +"#; + let tokens = crate::lexer::lex_wfl_with_positions(input); + let program = crate::parser::Parser::new(&tokens).parse().unwrap(); + let diagnostics = Analyzer::new().check_unused_variables(&program, 0); + assert_eq!(diagnostics.len(), 1, "{diagnostics:?}"); + assert_eq!(diagnostics[0].code, "ANALYZE-UNUSED"); + assert!(diagnostics[0].message.contains("unused_tls_setting")); + } + #[test] fn test_streaming_statement_variables_are_not_reported_unused() { // Variables referenced only inside the streaming/incremental-read diff --git a/src/interpreter/mod.rs b/src/interpreter/mod.rs index 4de2f6d4..a3bbc300 100644 --- a/src/interpreter/mod.rs +++ b/src/interpreter/mod.rs @@ -11571,62 +11571,111 @@ impl Interpreter { // HTTPS server. Certificate/key paths come from the listen // statement itself, falling back to .wflcfg for the bare // `secured` form. - let cert_path = match &tls_config.cert_path { - Some(expr) => { - let v = self.evaluate_expression(expr, Rc::clone(&env)).await?; - match &v { - Value::Text(t) => t.to_string(), - _ => { - return Err(RuntimeError::new( - format!( - "Expected text for TLS certificate path, got {v:?}" - ), - *line, - *column, - )); + let default_paths = if tls_config.cert_path.is_some() + || tls_config.sni_certificates.is_empty() + { + let cert_path = match &tls_config.cert_path { + Some(expr) => { + let v = self.evaluate_expression(expr, Rc::clone(&env)).await?; + match &v { + Value::Text(t) => t.to_string(), + _ => { + return Err(RuntimeError::new( + format!( + "Expected text for TLS certificate path, got {v:?}" + ), + *line, + *column, + )); + } } } - } - None => match &self.config.web_server_tls_cert_file { - Some(path) => path.clone(), - None => { - return Err(RuntimeError::new( + None => match &self.config.web_server_tls_cert_file { + Some(path) => path.clone(), + None => { + return Err(RuntimeError::new( "This listen statement is marked 'secured' but no certificate is configured. Either write 'secured with certificate \"cert.pem\" and key \"key.pem\"' or set web_server_tls_cert_file and web_server_tls_key_file in .wflcfg".to_string(), *line, *column, )); - } - }, - }; - let key_path = match &tls_config.key_path { - Some(expr) => { - let v = self.evaluate_expression(expr, Rc::clone(&env)).await?; - match &v { - Value::Text(t) => t.to_string(), - _ => { - return Err(RuntimeError::new( - format!( - "Expected text for TLS private key path, got {v:?}" - ), - *line, - *column, - )); + } + }, + }; + let key_path = match &tls_config.key_path { + Some(expr) => { + let v = self.evaluate_expression(expr, Rc::clone(&env)).await?; + match &v { + Value::Text(t) => t.to_string(), + _ => { + return Err(RuntimeError::new( + format!( + "Expected text for TLS private key path, got {v:?}" + ), + *line, + *column, + )); + } } } - } - None => match &self.config.web_server_tls_key_file { - Some(path) => path.clone(), - None => { - return Err(RuntimeError::new( + None => match &self.config.web_server_tls_key_file { + Some(path) => path.clone(), + None => { + return Err(RuntimeError::new( "This listen statement is marked 'secured' but no private key is configured. Either write 'secured with certificate \"cert.pem\" and key \"key.pem\"' or set web_server_tls_cert_file and web_server_tls_key_file in .wflcfg".to_string(), *line, *column, )); - } - }, - }; + } + }, + }; - let tls_config = match tls::load_server_config(&cert_path, &key_path) { + Some((cert_path, key_path)) + } else { + None + }; + let mut named_certificates = Vec::new(); + for certificate in &tls_config.sni_certificates { + let mut resolved = tls::NamedCertificate { + cert_path: String::new(), + key_path: String::new(), + hostname: String::new(), + }; + // Expressions can call actions: preserve source order + // and stop at the first invalid operand. + for (expr, destination, label) in [ + ( + &certificate.cert_path, + &mut resolved.cert_path, + "certificate path", + ), + ( + &certificate.key_path, + &mut resolved.key_path, + "private key path", + ), + (&certificate.hostname, &mut resolved.hostname, "hostname"), + ] { + let value = self.evaluate_expression(expr, Rc::clone(&env)).await?; + let Value::Text(text) = value else { + return Err(RuntimeError::new( + format!( + "Expected text for TLS {label}, got {}", + value.type_name() + ), + *line, + *column, + )); + }; + *destination = text.to_string(); + } + named_certificates.push(resolved); + } + let tls_config = match tls::load_server_config( + default_paths + .as_ref() + .map(|(cert, key)| (cert.as_str(), key.as_str())), + &named_certificates, + ) { Ok(config) => config, Err(message) => { return Err(RuntimeError::new(message, *line, *column)); diff --git a/src/interpreter/tls.rs b/src/interpreter/tls.rs index 31b41bbf..c7959e61 100644 --- a/src/interpreter/tls.rs +++ b/src/interpreter/tls.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fmt; use std::fs::File; use std::future::Future; @@ -290,10 +290,32 @@ impl Accept for SecuredIncoming { } } -pub(super) fn load_server_config( +pub(super) struct NamedCertificate { + pub hostname: String, + pub cert_path: String, + pub key_path: String, +} + +#[derive(Debug)] +struct CertificateResolver { + named: rustls::server::ResolvesServerCertUsingSni, + default: Option>, +} + +impl rustls::server::ResolvesServerCert for CertificateResolver { + fn resolve( + &self, + hello: rustls::server::ClientHello<'_>, + ) -> Option> { + self.named.resolve(hello).or_else(|| self.default.clone()) + } +} + +fn load_certified_key( cert_path: &str, key_path: &str, -) -> Result { + provider: &rustls::crypto::CryptoProvider, +) -> Result { let cert_file = File::open(cert_path).map_err(|error| { format!( "Cannot open TLS certificate file '{cert_path}': {error}. For local development you can create a self-signed certificate with: openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem -days 365 -subj \"/CN=localhost\"" @@ -320,18 +342,65 @@ pub(super) fn load_server_config( ) })?; + rustls::sign::CertifiedKey::from_der(certs, key, provider) + .map_err(|error| { + format!( + "TLS certificate '{cert_path}' and private key '{key_path}' are not a valid pair: {error}" + ) + }) +} + +pub(super) fn load_server_config( + default_paths: Option<(&str, &str)>, + certificates: &[NamedCertificate], +) -> Result { + if certificates.len() > crate::parser::ast::MAX_TLS_SNI_CERTIFICATES { + return Err(format!( + "A secured listener supports at most {} named certificates", + crate::parser::ast::MAX_TLS_SNI_CERTIFICATES + )); + } + if default_paths.is_none() && certificates.is_empty() { + return Err("A secured listener needs at least one TLS certificate".to_string()); + } let provider = Arc::new(rustls::crypto::ring::default_provider()); + let default = default_paths + .map(|(cert, key)| load_certified_key(cert, key, &provider).map(Arc::new)) + .transpose()?; + let mut named = rustls::server::ResolvesServerCertUsingSni::new(); + let mut names = HashSet::new(); + for certificate in certificates { + let name = &certificate.hostname; + // SNI contains DNS names, not URLs, ports, IP addresses or wildcard + // patterns. A wildcard certificate can still cover an exact name here. + if name.ends_with('.') + || name.parse::().is_ok() + || rustls::pki_types::DnsName::try_from(name.as_str()).is_err() + { + return Err(format!( + "Invalid TLS DNS hostname '{name}': use an exact DNS name without a port, wildcard or trailing dot" + )); + } + let normalized = name.to_ascii_lowercase(); + if !names.insert(normalized.clone()) { + return Err(format!( + "Duplicate TLS hostname '{name}' (DNS names are case-insensitive)" + )); + } + let key = load_certified_key(&certificate.cert_path, &certificate.key_path, &provider)?; + named.add(&normalized, key).map_err(|error| { + format!( + "TLS certificate '{}' is not valid for hostname '{name}': {error}", + certificate.cert_path + ) + })?; + } let builder = rustls::ServerConfig::builder_with_provider(provider) .with_safe_default_protocol_versions() .map_err(|error| format!("Failed to configure safe TLS protocol versions: {error}"))?; let mut config = builder .with_no_client_auth() - .with_single_cert(certs, key) - .map_err(|error| { - format!( - "TLS certificate '{cert_path}' and private key '{key_path}' are not a valid pair: {error}" - ) - })?; + .with_cert_resolver(Arc::new(CertificateResolver { named, default })); // Match Warp's prior TLS behavior and ordering: prefer HTTP/2, with a // standards-compatible HTTP/1.1 fallback. diff --git a/src/linter/layout.rs b/src/linter/layout.rs index 2e57618b..b759fc4d 100644 --- a/src/linter/layout.rs +++ b/src/linter/layout.rs @@ -290,6 +290,13 @@ fn statement_expressions<'a>(statement: &'a Statement, pending: &mut Vec<&'a Exp pending.extend(redirect_to_port); if let Some(tls) = tls { pending.extend(tls.cert_path.iter().chain(&tls.key_path)); + for certificate in &tls.sni_certificates { + pending.extend([ + &certificate.hostname, + &certificate.cert_path, + &certificate.key_path, + ]); + } } } Statement::WaitForRequestStatement { diff --git a/src/parser/ast.rs b/src/parser/ast.rs index 803765d0..e0f7497c 100644 --- a/src/parser/ast.rs +++ b/src/parser/ast.rs @@ -91,15 +91,26 @@ pub struct EventDefinition { pub column: usize, } -/// TLS settings on a `listen` statement. Both paths `None` means the bare +/// TLS settings on a `listen` statement. No paths or named certificates means the bare /// `secured` form: certificate and key paths come from .wflcfg at runtime /// (`web_server_tls_cert_file` / `web_server_tls_key_file`). #[derive(Debug, Clone, PartialEq)] pub struct TlsListenConfig { pub cert_path: Option, pub key_path: Option, + pub sni_certificates: Vec, } +/// A certificate selected by an exact DNS name in the TLS ClientHello. +#[derive(Debug, Clone, PartialEq)] +pub struct TlsSniCertificate { + pub hostname: Expression, + pub cert_path: Expression, + pub key_path: Expression, +} + +pub const MAX_TLS_SNI_CERTIFICATES: usize = 128; + /// Which WebSocket lifecycle event an `on websocket ... end on` block handles. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WsHandlerEvent { diff --git a/src/parser/stmt/web.rs b/src/parser/stmt/web.rs index 6fab753f..e569455f 100644 --- a/src/parser/stmt/web.rs +++ b/src/parser/stmt/web.rs @@ -3,7 +3,9 @@ use super::super::{Expression, ParseError, Parser, Statement}; use super::StmtParser; use crate::lexer::token::Token; -use crate::parser::ast::{Argument, TlsListenConfig, WsHandlerEvent}; +use crate::parser::ast::{ + Argument, MAX_TLS_SNI_CERTIFICATES, TlsListenConfig, TlsSniCertificate, WsHandlerEvent, +}; use crate::parser::expr::{ExprParser, PrimaryExprParser}; pub(crate) trait WebParser<'a>: ExprParser<'a> + PrimaryExprParser<'a> { @@ -145,22 +147,68 @@ impl<'a> WebParser<'a> for Parser<'a> { { // `secured with certificate and key ` self.bump_sync(); // Consume "with" - let cert_path = self.parse_tls_path_value("certificate")?; - self.expect_token( - Token::KeywordAnd, - "Expected 'and key ...' after certificate path", - )?; - let key_path = self.parse_tls_path_value("key")?; - tls = Some(TlsListenConfig { - cert_path: Some(cert_path), - key_path: Some(key_path), - }); + let mut config = TlsListenConfig { + cert_path: None, + key_path: None, + sni_certificates: Vec::new(), + }; + loop { + self.skip_eol(); + let cert_path = self.parse_tls_path_value("certificate")?; + self.expect_token( + Token::KeywordAnd, + "Expected 'and key ...' after certificate path", + )?; + let key_path = self.parse_tls_path_value("key")?; + if self + .cursor + .peek() + .is_some_and(|t| t.token == Token::KeywordFor) + { + self.bump_sync(); + let hostname = self.parse_primary_expression()?; + if config.sni_certificates.len() == MAX_TLS_SNI_CERTIFICATES { + return Err(ParseError::from_token( + format!( + "A secured listener supports at most {MAX_TLS_SNI_CERTIFICATES} named certificates" + ), + listen_token, + )); + } + config.sni_certificates.push(TlsSniCertificate { + hostname, + cert_path, + key_path, + }); + } else if config.cert_path.is_none() && config.sni_certificates.is_empty() { + // The original, unnamed pair is an explicit fallback. + config.cert_path = Some(cert_path); + config.key_path = Some(key_path); + } else { + return Err(ParseError::from_token( + "Additional certificates require 'for \"hostname\"'; place an optional default certificate first".to_string(), + listen_token, + )); + } + self.skip_eol(); + if self + .cursor + .peek() + .is_some_and(|t| t.token == Token::KeywordAnd) + { + self.bump_sync(); + } else { + break; + } + } + tls = Some(config); } else { // Bare `secured` — certificate and key paths come from // .wflcfg at runtime. tls = Some(TlsListenConfig { cert_path: None, key_path: None, + sni_certificates: Vec::new(), }); } diff --git a/src/typechecker/mod.rs b/src/typechecker/mod.rs index 9277b5df..77cd4c47 100644 --- a/src/typechecker/mod.rs +++ b/src/typechecker/mod.rs @@ -7194,7 +7194,15 @@ impl TypeChecker { for (path_expr, what) in [ (tls_config.cert_path.as_ref(), "Certificate path"), (tls_config.key_path.as_ref(), "Key path"), - ] { + ] + .into_iter() + .chain(tls_config.sni_certificates.iter().flat_map(|cert| { + [ + (Some(&cert.hostname), "TLS hostname"), + (Some(&cert.cert_path), "Certificate path"), + (Some(&cert.key_path), "Key path"), + ] + })) { if let Some(expr) = path_expr { let path_type = self.infer_expression_type(expr); if path_type != Type::Text && !self.is_gradual_type(&path_type) { diff --git a/tests/web_server_sni_test.rs b/tests/web_server_sni_test.rs new file mode 100644 index 00000000..ab79a92c --- /dev/null +++ b/tests/web_server_sni_test.rs @@ -0,0 +1,567 @@ +//! SNI contract tests: real certificates, verified TLS, and the real WFL binary. +use rustls::pki_types::{CertificateDer, ServerName}; +use std::{net::SocketAddr, path::Path, process::Stdio, sync::Arc, time::Duration}; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; +use wfl::{ + Interpreter, analyzer::Analyzer, lexer::lex_wfl_with_positions, parser::Parser, + typechecker::TypeChecker, +}; + +fn parse(code: &str) -> wfl::parser::ast::Program { + Parser::new(&lex_wfl_with_positions(code)) + .parse() + .expect("SNI syntax must parse") +} + +struct Cert { + cert: String, + key: String, + der: CertificateDer<'static>, +} + +impl Cert { + fn new(dir: &Path, name: &str) -> Self { + let generated = rcgen::generate_simple_self_signed(vec![name.to_owned()]).unwrap(); + let cert = dir.join(format!("{name}.pem")); + let key = dir.join(format!("{name}.key")); + std::fs::write(&cert, generated.cert.pem()).unwrap(); + std::fs::write(&key, generated.signing_key.serialize_pem()).unwrap(); + Self { + cert: cert.to_string_lossy().replace('\\', "/"), + key: key.to_string_lossy().replace('\\', "/"), + der: generated.cert.der().clone(), + } + } + fn clause(&self, name: &str) -> String { + format!( + r#"certificate "{}" and key "{}" for "{name}""#, + self.cert, self.key + ) + } +} + +fn connector(certs: &[&Cert], sni: bool) -> tokio_rustls::TlsConnector { + let mut roots = rustls::RootCertStore::empty(); + for cert in certs { + roots.add(cert.der.clone()).unwrap(); + } + let mut config = rustls::ClientConfig::builder_with_provider(Arc::new( + rustls::crypto::ring::default_provider(), + )) + .with_safe_default_protocol_versions() + .unwrap() + .with_root_certificates(roots) + .with_no_client_auth(); + config.enable_sni = sni; + config.alpn_protocols = vec![b"http/1.1".to_vec()]; + tokio_rustls::TlsConnector::from(Arc::new(config)) +} + +async fn connect( + addr: SocketAddr, + name: &str, + connector: &tokio_rustls::TlsConnector, +) -> std::io::Result> { + let tcp = tokio::net::TcpStream::connect(addr).await?; + tokio::time::timeout( + Duration::from_secs(3), + connector.connect(ServerName::try_from(name.to_owned()).unwrap(), tcp), + ) + .await + .expect("TLS handshake must be bounded") +} + +async fn start_binary( + dir: &Path, + clauses: &str, + requests: usize, +) -> (tokio::process::Child, SocketAddr) { + let mut code = format!( + "listen on port 0 secured with {clauses} as secure_server\ndisplay secure_server\n" + ); + for _ in 0..requests { + code.push_str("wait for request comes in on secure_server as req with timeout 5000\nrespond to req with header \"Host\" of req\n"); + } + code.push_str("close server secure_server\n"); + std::fs::write(dir.join("server.wfl"), code).unwrap(); + std::fs::write(dir.join(".wflcfg"), "web_server_bind_address = 127.0.0.1\n").unwrap(); + let mut child = tokio::process::Command::new(env!("CARGO_BIN_EXE_wfl")) + .args(["--execution-timeout", "15", "server.wfl"]) + .current_dir(dir) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let mut lines = BufReader::new(child.stdout.take().unwrap()).lines(); + let addr = tokio::time::timeout(Duration::from_secs(10), async { + while let Some(line) = lines.next_line().await.unwrap() { + if let Some(addr) = line.trim().strip_prefix("WebServer::") { + return addr.parse().unwrap(); + } + } + panic!("WFL exited before publishing the listener address"); + }) + .await + .expect("WFL startup must be bounded"); + (child, addr) +} + +async fn request( + addr: SocketAddr, + name: &str, + cert: &Cert, + connector: &tokio_rustls::TlsConnector, +) { + let mut stream = connect(addr, name, connector) + .await + .expect("verified SNI handshake"); + assert_eq!( + stream.get_ref().1.peer_certificates().unwrap()[0], + cert.der, + "SNI must choose this domain's certificate" + ); + stream + .write_all( + format!("GET / HTTP/1.1\r\nHost: {name}\r\nConnection: close\r\n\r\n").as_bytes(), + ) + .await + .unwrap(); + let mut response = String::new(); + tokio::time::timeout(Duration::from_secs(3), stream.read_to_string(&mut response)) + .await + .unwrap() + .unwrap(); + assert!(response.starts_with("HTTP/1.1 200"), "{response}"); + assert!(response.ends_with(name), "{response}"); +} + +#[test] +fn sni_syntax_accepts_named_certificates_and_variables() { + let ast = parse( + r#" +store hostname as "one.test" +store cert_path as "one.pem" +store key_path as "one.key" +store listen_port as 0 +listen on port listen_port secured with certificate cert_path and key key_path for hostname + and certificate "two.pem" and key "two.key" for "two.test" as secure_server +close server secure_server +"#, + ); + Analyzer::new() + .analyze(&ast) + .expect("SNI expressions must be analyzed"); + TypeChecker::new() + .check_types(&ast) + .expect("SNI text operands must typecheck"); +} + +#[test] +fn sni_operands_are_analyzed_and_typechecked() { + for operand in [ + "certificate 42 and key \"k\" for \"one.test\"", + "certificate \"c\" and key 42 for \"one.test\"", + "certificate \"c\" and key \"k\" for 42", + ] { + let ast = parse(&format!( + "listen on port 0 secured with {operand} as secure_server" + )); + assert!(TypeChecker::new().check_types(&ast).is_err(), "{operand}"); + } + for operand in [ + "certificate missing_cert and key \"k\" for \"one.test\"", + "certificate \"c\" and key missing_key for \"one.test\"", + "certificate \"c\" and key \"k\" for missing_host", + ] { + let ast = parse(&format!( + "listen on port 0 secured with {operand} as secure_server" + )); + assert!(Analyzer::new().analyze(&ast).is_err(), "{operand}"); + } +} + +#[tokio::test] +async fn sni_binary_selects_two_certs_rejects_unknown_and_survives_stalled_clients() { + let dir = tempfile::tempdir().unwrap(); + let one = Cert::new(dir.path(), "one.test"); + let two = Cert::new(dir.path(), "two.test"); + let (mut child, addr) = start_binary( + dir.path(), + &format!("{} and {}", one.clause("ONE.test"), two.clause("two.test")), + 2, + ) + .await; + let client = connector(&[&one, &two], true); + let mut stalled = tokio::net::TcpStream::connect(addr).await.unwrap(); + // Unknown/missing SNI fails before any HTTP request reaches the application. + let rejected = connect(addr, "unknown.test", &client).await.unwrap_err(); + assert!( + matches!( + rejected + .get_ref() + .and_then(|e| e.downcast_ref::()), + Some(rustls::Error::AlertReceived(_)) + ), + "The server must reject unknown SNI, not merely present a mismatched certificate: {rejected}" + ); + assert!( + connect(addr, "one.test", &connector(&[&one], false)) + .await + .is_err() + ); + let mut idle = connect(addr, "one.test", &client).await.unwrap(); + let ((), ()) = tokio::join!( + request(addr, "one.test", &one, &client), + request(addr, "two.test", &two, &client) + ); + assert!( + tokio::time::timeout(Duration::from_secs(5), child.wait()) + .await + .unwrap() + .unwrap() + .success() + ); + let mut byte = [0]; + for result in [ + tokio::time::timeout(Duration::from_secs(2), stalled.read(&mut byte)).await, + tokio::time::timeout(Duration::from_secs(2), idle.read(&mut byte)).await, + ] { + assert!( + matches!(result, Ok(Ok(0)) | Ok(Err(_))), + "close must cancel established and partial TLS connections: {result:?}" + ); + } + assert!(tokio::net::TcpStream::connect(addr).await.is_err()); +} + +#[tokio::test] +async fn sni_explicit_default_handles_unknown_and_missing_sni() { + let dir = tempfile::tempdir().unwrap(); + let default = Cert::new(dir.path(), "default.test"); + let named = Cert::new(dir.path(), "named.test"); + let clauses = format!( + r#"certificate "{}" and key "{}" and {}"#, + default.cert, + default.key, + named.clause("named.test") + ); + let (mut child, addr) = start_binary(dir.path(), &clauses, 3).await; + let client = connector(&[&default, &named], true); + request(addr, "default.test", &default, &client).await; + request(addr, "named.test", &named, &client).await; + request( + addr, + "default.test", + &default, + &connector(&[&default], false), + ) + .await; + assert!( + tokio::time::timeout(Duration::from_secs(5), child.wait()) + .await + .unwrap() + .unwrap() + .success() + ); +} + +#[tokio::test] +async fn sni_invalid_configuration_fails_before_binding() { + let dir = tempfile::tempdir().unwrap(); + let one = Cert::new(dir.path(), "one.test"); + let two = Cert::new(dir.path(), "two.test"); + let cases = [ + ( + format!("{} and {}", one.clause("one.test"), one.clause("ONE.TEST")), + "Duplicate", + ), + (one.clause("wrong.test"), "one.test.pem"), + (one.clause("*.test"), "DNS"), + (one.clause("one.test:443"), "DNS"), + (one.clause("one.test."), "DNS"), + (one.clause("127.0.0.1"), "DNS"), + (one.clause("https://one.test"), "DNS"), + (one.clause(""), "DNS"), + (one.clause(&format!("{}.test", "a".repeat(64))), "DNS"), + ( + format!( + r#"certificate "{}" and key "{}" for "one.test""#, + one.cert, two.key + ), + "not a valid pair", + ), + ( + format!( + r#"certificate "missing.pem" and key "{}" for "one.test""#, + one.key + ), + "Cannot open", + ), + ]; + for (clause, expected) in cases { + let ast = parse(&format!( + "listen on port 0 secured with {clause} as secure_server" + )); + let mut interpreter = Interpreter::new(); + let error = format!( + "{:?}", + interpreter + .interpret(&ast) + .await + .expect_err("invalid SNI configuration must fail") + ); + assert!(error.contains(expected), "expected {expected}: {error}"); + assert!( + interpreter + .global_env() + .borrow() + .get("secure_server") + .is_none(), + "invalid listener must not be published" + ); + } +} + +#[test] +fn sni_parser_rejects_incomplete_ambiguous_and_oversized_lists() { + for clause in [ + r#"certificate "c" and key "k" for"#, + r#"certificate "c" and key "k" for "one.test" and"#, + r#"certificate "c" and key "k" for "one.test" and certificate "c" for "two.test""#, + r#"certificate "c" and key "k" for "one.test" and certificate "d" and key "e""#, + r#"certificate "c" and key "k" and certificate "d" and key "e""#, + ] { + let code = format!("listen on port 0 secured with {clause} as secure_server"); + assert!( + Parser::new(&lex_wfl_with_positions(&code)).parse().is_err(), + "{code}" + ); + } + let clauses = (0..129) + .map(|i| format!(r#"certificate "c" and key "k" for "host{i}.test""#)) + .collect::>(); + parse(&format!( + "listen on port 0 secured with {} as secure_server", + clauses[..128].join(" and ") + )); + let code = format!( + "listen on port 0 secured with {} as secure_server", + clauses.join(" and ") + ); + let error = Parser::new(&lex_wfl_with_positions(&code)) + .parse() + .unwrap_err(); + assert!(format!("{error:?}").contains("at most 128")); +} + +#[tokio::test] +async fn sni_runtime_checks_dynamic_operands_and_ignores_implicit_default() { + for operand in [ + "certificate 42 and key \"k\" for \"one.test\"", + "certificate \"c\" and key 42 for \"one.test\"", + "certificate \"c\" and key \"k\" for 42", + ] { + let ast = parse(&format!( + "listen on port 0 secured with {operand} as secure_server" + )); + let error = Interpreter::new().interpret(&ast).await.unwrap_err(); + assert!(format!("{error:?}").contains("Expected text for TLS")); + } + let dir = tempfile::tempdir().unwrap(); + let one = Cert::new(dir.path(), "one.test"); + let config = wfl::config::WflConfig { + web_server_tls_cert_file: Some("missing-default.pem".to_owned()), + web_server_tls_key_file: Some("missing-default.key".to_owned()), + ..Default::default() + }; + let mut interpreter = Interpreter::with_config(Arc::new(config)); + interpreter + .interpret(&parse(&format!( + "listen on port 0 secured with {} as secure_server\nclose server secure_server", + one.clause("one.test") + ))) + .await + .unwrap(); +} + +#[tokio::test] +async fn sni_evaluates_operands_once_in_source_order() { + let dir = tempfile::tempdir().unwrap(); + let cert = Cert::new(dir.path(), "one.test"); + let source = format!( + r#" +store evaluation_order as "" +define action called choose_certificate: + change evaluation_order to evaluation_order with "cert;" + return "{}" +end action +define action called choose_key: + change evaluation_order to evaluation_order with "key;" + return "{}" +end action +define action called choose_hostname: + change evaluation_order to evaluation_order with "host;" + return "one.test" +end action +listen on port 0 secured with certificate choose_certificate and key choose_key for choose_hostname as secure_server +close server secure_server +"#, + cert.cert, cert.key + ); + let mut interpreter = Interpreter::new(); + interpreter.interpret(&parse(&source)).await.unwrap(); + let order = interpreter + .global_env() + .borrow() + .get("evaluation_order") + .unwrap(); + let wfl::interpreter::value::Value::Text(order) = order else { + panic!("order must be text") + }; + assert_eq!(order.as_ref(), "cert;key;host;"); + + let invalid = source.replace(&format!("return \"{}\"", cert.cert), "return 42"); + let mut interpreter = Interpreter::new(); + let error = interpreter.interpret(&parse(&invalid)).await.unwrap_err(); + assert!(format!("{error:?}").contains("Expected text for TLS certificate path")); + let order = interpreter + .global_env() + .borrow() + .get("evaluation_order") + .unwrap(); + let wfl::interpreter::value::Value::Text(order) = order else { + panic!("order must be text") + }; + assert_eq!( + order.as_ref(), + "cert;", + "later operands must not run after an invalid certificate path" + ); +} + +#[tokio::test] +async fn sni_cli_analyzer_counts_certificate_and_hostname_variables_as_used() { + let dir = tempfile::tempdir().unwrap(); + let code = r#" +store default_cert as "default.pem" +store default_key as "default.key" +store named_cert as "one.pem" +store named_key as "one.key" +store hostname as "one.test" +listen on port 0 secured with certificate default_cert and key default_key + and certificate named_cert and key named_key for hostname as secure_server +close server secure_server +"#; + std::fs::write(dir.path().join("analyze.wfl"), code).unwrap(); + let result = tokio::time::timeout( + Duration::from_secs(10), + tokio::process::Command::new(env!("CARGO_BIN_EXE_wfl")) + .args(["--analyze", "analyze.wfl"]) + .current_dir(dir.path()) + .env( + "WFL_GLOBAL_CONFIG_PATH", + dir.path().join("absent-global-config"), + ) + .env("NO_COLOR", "1") + .stdin(Stdio::null()) + .kill_on_drop(true) + .output(), + ) + .await + .unwrap() + .unwrap(); + let output = format!( + "{}{}", + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&result.stderr) + ); + assert!( + result.status.success(), + "--analyze must accept used TLS operands: {output}" + ); + assert!(!output.contains("ANALYZE-UNUSED"), "{output}"); +} + +#[tokio::test] +async fn sni_incompatible_named_key_does_not_select_compatible_default() { + let dir = tempfile::tempdir().unwrap(); + let named = Cert::new(dir.path(), "one.test"); + let fallback_key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ED25519).unwrap(); + let fallback_cert = rcgen::CertificateParams::new(vec!["one.test".to_owned()]) + .unwrap() + .self_signed(&fallback_key) + .unwrap(); + let fallback = Cert { + cert: dir + .path() + .join("fallback.pem") + .to_string_lossy() + .replace('\\', "/"), + key: dir + .path() + .join("fallback.key") + .to_string_lossy() + .replace('\\', "/"), + der: fallback_cert.der().clone(), + }; + std::fs::write(&fallback.cert, fallback_cert.pem()).unwrap(); + std::fs::write(&fallback.key, fallback_key.serialize_pem()).unwrap(); + let clauses = format!( + r#"certificate "{}" and key "{}" and {}"#, + fallback.cert, + fallback.key, + named.clause("one.test") + ); + let (mut child, addr) = start_binary(dir.path(), &clauses, 2).await; + let mut roots = rustls::RootCertStore::empty(); + roots.add(named.der.clone()).unwrap(); + roots.add(fallback.der.clone()).unwrap(); + let mut provider = rustls::crypto::ring::default_provider(); + let mapping = provider.signature_verification_algorithms.mapping; + let index = mapping + .iter() + .position(|(scheme, _)| *scheme == rustls::SignatureScheme::ED25519) + .unwrap(); + provider.signature_verification_algorithms.mapping = &mapping[index..index + 1]; + let config = rustls::ClientConfig::builder_with_provider(Arc::new(provider)) + .with_protocol_versions(&[&rustls::version::TLS13]) + .unwrap() + .with_root_certificates(roots) + .with_no_client_auth(); + let restricted = tokio_rustls::TlsConnector::from(Arc::new(config.clone())); + let rejected = connect(addr, "one.test", &restricted).await.unwrap_err(); + assert!( + matches!( + rejected + .get_ref() + .and_then(|e| e.downcast_ref::()), + Some(rustls::Error::AlertReceived(_)) + ), + "a known incompatible named key must fail, not fall back: {rejected}" + ); + let mut no_sni = config; + no_sni.enable_sni = false; + request( + addr, + "one.test", + &fallback, + &tokio_rustls::TlsConnector::from(Arc::new(no_sni)), + ) + .await; + request( + addr, + "one.test", + &named, + &connector(&[&named, &fallback], true), + ) + .await; + assert!( + tokio::time::timeout(Duration::from_secs(5), child.wait()) + .await + .unwrap() + .unwrap() + .success() + ); +}