diff --git a/.gitignore b/.gitignore index 1f3fb695..2708c5c0 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ package/ **/build/ TODO.txt DESIGN.txt +.vscode/ diff --git a/.vscode/settings.json b/.vscode/settings.json index 0843dda9..137a79a0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,10 +3,19 @@ "libsysinspect", "Sysinspect" ], + "python.defaultInterpreterPath": "/usr/bin/python3", "files.associations": { "*.cfg": "yaml" }, "makefile.configureOnOpen": false, + "esbonio.sphinx.confDir": "${workspaceFolder}/docs", + "esbonio.sphinx.srcDir": "${workspaceFolder}/docs", "python-envs.defaultEnvManager": "ms-python.python:system", - "python-envs.pythonProjects": [] -} \ No newline at end of file + "python-envs.pythonProjects": [], + "esbonio.sphinx.pythonCommand": { + "command": [ + "/usr/bin/python3" + ], + "cwd": "${workspaceFolder}" + } +} diff --git a/Cargo.lock b/Cargo.lock index 67d734ac..a6879bfb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -51,6 +51,7 @@ dependencies = [ "actix-codec", "actix-rt", "actix-service", + "actix-tls", "actix-utils", "base64", "bitflags 2.11.0", @@ -143,6 +144,25 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "actix-tls" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6176099de3f58fbddac916a7f8c6db297e021d706e7a6b99947785fee14abe9f" +dependencies = [ + "actix-rt", + "actix-service", + "actix-utils", + "futures-core", + "impl-more", + "pin-project-lite", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tokio-util", + "tracing", +] + [[package]] name = "actix-utils" version = "3.0.1" @@ -166,6 +186,7 @@ dependencies = [ "actix-rt", "actix-server", "actix-service", + "actix-tls", "actix-utils", "actix-web-codegen", "bytes", @@ -425,13 +446,29 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive 0.5.1", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "asn1-rs" version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" dependencies = [ - "asn1-rs-derive", + "asn1-rs-derive 0.6.0", "asn1-rs-impl", "displaydoc", "nom", @@ -441,6 +478,18 @@ dependencies = [ "time", ] +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + [[package]] name = "asn1-rs-derive" version = "0.6.0" @@ -1803,13 +1852,27 @@ dependencies = [ "zeroize", ] +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs 0.6.2", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "der-parser" version = "10.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" dependencies = [ - "asn1-rs", + "asn1-rs 0.7.1", "displaydoc", "nom", "num-bigint", @@ -4004,10 +4067,8 @@ dependencies = [ "actix-files", "actix-web", "async-trait", - "base64", "colored", "futures-util", - "hex", "hostname", "indexmap 2.13.0", "libcommon", @@ -4017,16 +4078,17 @@ dependencies = [ "once_cell", "pam", "reqwest 0.12.28", - "rsa", + "rustls", + "rustls-pemfile", "serde", "serde_json", "serde_yaml", - "sodiumoxide", "tempfile", "tokio", "utoipa", "utoipa-swagger-ui", "uuid", + "x509-parser 0.16.0", ] [[package]] @@ -4700,13 +4762,22 @@ dependencies = [ "memchr", ] +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs 0.6.2", +] + [[package]] name = "oid-registry" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" dependencies = [ - "asn1-rs", + "asn1-rs 0.7.1", ] [[package]] @@ -6880,7 +6951,7 @@ dependencies = [ "num-complex", "num-traits", "num_enum", - "oid-registry", + "oid-registry 0.8.1", "page_size", "parking_lot 0.12.5", "paste", @@ -6922,7 +6993,7 @@ dependencies = [ "widestring", "windows-sys 0.61.2", "x509-cert", - "x509-parser", + "x509-parser 0.18.1", "xml", ] @@ -7755,13 +7826,18 @@ dependencies = [ name = "sysinspect-client" version = "0.1.0" dependencies = [ + "actix-web", + "async-trait", "libcommon", + "libdatastore", "libsysinspect", + "libwebapi", "log", "reqwest 0.12.28", "rpassword", "serde", "serde_json", + "tempfile", "tokio", ] @@ -10274,18 +10350,35 @@ dependencies = [ "tls_codec", ] +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs 0.6.2", + "data-encoding", + "der-parser 9.0.0", + "lazy_static", + "nom", + "oid-registry 0.7.1", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "x509-parser" version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" dependencies = [ - "asn1-rs", + "asn1-rs 0.7.1", "data-encoding", - "der-parser", + "der-parser 10.0.0", "lazy_static", "nom", - "oid-registry", + "oid-registry 0.8.1", "rusticata-macros", "thiserror 2.0.18", "time", diff --git a/Makefile b/Makefile index 2177be40..2132f327 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ include Makefile.in .PHONY: build devel all all-devel modules modules-dev modules-dist-devel modules-refresh-devel clean check fix setup \ musl-aarch64-dev musl-aarch64 musl-x86_64-dev musl-x86_64 \ - stats man test test-core test-modules test-sensors test-integration tar + stats man test test-core test-modules test-sensors test-integration tar dev-tls setup: $(call deps) @@ -90,20 +90,22 @@ stats: man: pandoc --standalone --to man docs/manpages/sysinspect.8.md -o docs/manpages/sysinspect.8 -test: setup - CARGO_BUILD_JOBS=$(TEST_BUILD_JOBS) cargo nextest run --workspace $(PLATFORM_WORKSPACE_EXCLUDES) --test-threads $(TEST_RUN_THREADS) +dev-tls: + ./scripts/dev-tls.sh +test: setup + @CARGO_BUILD_JOBS=$(TEST_BUILD_JOBS) cargo nextest run --workspace $(PLATFORM_WORKSPACE_EXCLUDES) --test-threads $(TEST_RUN_THREADS) test-core: setup - CARGO_BUILD_JOBS=$(TEST_BUILD_JOBS) cargo nextest run $(foreach pkg,$(CORE_PACKAGE_SPECS),-p $(pkg)) --lib --bins --test-threads $(TEST_RUN_THREADS) + @CARGO_BUILD_JOBS=$(TEST_BUILD_JOBS) cargo nextest run $(foreach pkg,$(CORE_PACKAGE_SPECS),-p $(pkg)) --lib --bins --test-threads $(TEST_RUN_THREADS) test-modules: setup - CARGO_BUILD_JOBS=$(TEST_BUILD_JOBS) cargo nextest run $(foreach pkg,$(MODULE_PACKAGE_SPECS),-p $(pkg)) --bins --test-threads $(TEST_RUN_THREADS) + @CARGO_BUILD_JOBS=$(TEST_BUILD_JOBS) cargo nextest run $(foreach pkg,$(MODULE_PACKAGE_SPECS),-p $(pkg)) --bins --test-threads $(TEST_RUN_THREADS) test-sensors: setup - CARGO_BUILD_JOBS=$(TEST_BUILD_JOBS) cargo nextest run $(foreach pkg,$(SENSOR_PACKAGE_SPECS),-p $(pkg)) --lib --bins --test-threads $(TEST_RUN_THREADS) + @CARGO_BUILD_JOBS=$(TEST_BUILD_JOBS) cargo nextest run $(foreach pkg,$(SENSOR_PACKAGE_SPECS),-p $(pkg)) --lib --bins --test-threads $(TEST_RUN_THREADS) test-integration: setup - CARGO_BUILD_JOBS=$(TEST_BUILD_JOBS) cargo nextest run $(INTEGRATION_TEST_TARGETS) --test-threads $(TEST_RUN_THREADS) + @CARGO_BUILD_JOBS=$(TEST_BUILD_JOBS) cargo nextest run $(INTEGRATION_TEST_TARGETS) --test-threads $(TEST_RUN_THREADS) tar: # Cleanup diff --git a/docs/apidoc/overview.rst b/docs/apidoc/overview.rst index fb6f63a3..17107582 100644 --- a/docs/apidoc/overview.rst +++ b/docs/apidoc/overview.rst @@ -1,18 +1,65 @@ Web API ======= -This section provides an overview of the Web API for SysInspect, including its structure, endpoints, and usage. +This section provides an overview of the Web API for SysInspect, including its +HTTPS/TLS access pattern, endpoints, and request flow. Accessing the Documentation --------------------------- -The Web API is automatically documented using Swagger, which provides a user-friendly interface to explore the available endpoints -and their parameters. You can access the Swagger UI at the following URL: +The Web API is automatically documented using Swagger, which provides a +user-friendly interface to explore the available endpoints and their +parameters. You can access the Swagger UI at: -``` -http://:4202/doc/ -``` +``https://:4202/doc/`` -This interface, running on default port **4202**, allows you to interact with the API, view the available endpoints, -and test them directly from your browser. You can change the port and API version in the general ``sysinspect.conf`` -configuration file. \ No newline at end of file +This interface runs on default port **4202**. + +The embedded Web API only starts when TLS is configured correctly under +``api.tls.*`` in ``sysinspect.conf``. + +Authentication And Requests +--------------------------- + +The Web API uses: + +- HTTPS/TLS for transport protection +- bearer tokens for authentication +- plain JSON request and response bodies + +Typical flow: + +1. ``POST /api/v1/authenticate`` with JSON credentials +2. receive ``access_token`` +3. call later endpoints with ``Authorization: Bearer `` + +Example authentication request body: + +.. code-block:: json + + { + "username": "operator", + "password": "secret" + } + +Example query request body: + +.. code-block:: json + + { + "model": "cm/file-ops", + "query": "*", + "traits": "", + "mid": "", + "context": { + "reason": "manual-run" + } + } + +Related Material +---------------- + +- :doc:`../global_config` +- :doc:`../genusage/operator_security` +- ``examples/transport-fixtures/webapi_auth_request.json`` +- ``examples/transport-fixtures/webapi_query_request.json`` diff --git a/docs/arch/current_system.drawio b/docs/arch/current_system.drawio new file mode 100644 index 00000000..c50f58c7 --- /dev/null +++ b/docs/arch/current_system.drawio @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/conf.py b/docs/conf.py index 1347bd29..17c44fe1 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,3 +1,9 @@ +"""Sphinx configuration for the Sysinspect documentation.""" + +from __future__ import annotations + +# pylint: disable=invalid-name,redefined-builtin + project = "Sysinspect" copyright = "2026, Bo Maryniuk" author = "Bo Maryniuk" diff --git a/docs/genusage/operator_security.rst b/docs/genusage/operator_security.rst new file mode 100644 index 00000000..469e2f7d --- /dev/null +++ b/docs/genusage/operator_security.rst @@ -0,0 +1,197 @@ +Operator Security Guide +======================= + +This page collects the operator-facing secure transport and Web API procedures +in one place. + +Registration +------------ + +When a minion starts without an existing trust relationship, it reports the +master fingerprint and waits for registration. + +Typical operator flow: + +1. Start ``sysmaster``. +2. Start the minion once and note the reported master fingerprint. +3. Verify that fingerprint out-of-band. +4. Register the minion with ``sysminion --register ``. +5. Start the minion normally with ``sysminion --start``. + +After registration: + +- the master stores the minion RSA public key +- the minion stores the master RSA public key +- both sides create managed transport state on disk +- later reconnects bootstrap secure sessions automatically + +Key And State Locations +----------------------- + +Under the Sysinspect root, the important files are: + +Master: + +- ``master.rsa``: master RSA private key +- ``master.rsa.pub``: master RSA public key +- ``console.rsa``: local console private key +- ``console.rsa.pub``: local console public key +- ``console-keys/``: authorized console client public keys +- ``transport/minions//state.json``: managed transport state for + one minion + +Minion: + +- ``minion.rsa``: minion RSA private key +- ``minion.rsa.pub``: minion RSA public key +- ``master.rsa.pub``: trusted master RSA public key +- ``transport/master/state.json``: managed transport state for the current + master + +These files are managed by Sysinspect. Do not edit the transport state files by +hand during normal operation. + +Secure Transport Verification +----------------------------- + +The quickest operator checks are: + +- ``sysinspect network --status`` +- master and minion logs + +The transport status view shows: + +- active key id +- last successful handshake time +- last rotation time +- current rotation state + +Healthy behavior looks like: + +- the minion reconnects cleanly +- the master logs secure session establishment +- ``network --status`` shows a current handshake timestamp + +Transport Key Rotation +---------------------- + +Rotate one minion: + +.. code-block:: bash + + sysinspect network --rotate --id + +Rotate a group: + +.. code-block:: bash + + sysinspect network --rotate 'edge-*' + +Inspect state: + +.. code-block:: bash + + sysinspect network --status '*' + +Important behavior: + +- online minions receive the signed rotation intent immediately +- offline minions keep a pending rotation state until reconnect +- the reconnect after rotation establishes a fresh secure session +- old key material is kept briefly as retiring overlap and then removed + +Troubleshooting +--------------- + +Check the logs first. + +Common master-side messages: + +- secure bootstrap authentication failure +- replay rejection +- duplicate session rejection +- version mismatch or malformed bootstrap rejection +- staged rotation key mismatch +- Web API TLS startup failure + +Common minion-side messages: + +- missing trusted master key +- missing managed transport state +- bootstrap diagnostic returned by the master +- bootstrap ack verification failure +- reconnect triggered after transport failure + +Typical recovery paths: + +- stale or broken minion trust: re-register the minion +- changed master identity: re-register affected minions +- pending rotation on an offline minion: let it reconnect, then check + ``network --status`` +- bad Web API TLS file paths: fix ``api.tls.*`` and restart ``sysmaster`` + +Web API TLS Setup +----------------- + +The embedded Web API is separate from the Master/Minion secure transport. +It uses normal HTTPS/TLS. + +Required configuration: + +.. code-block:: yaml + + config: + master: + api.enabled: true + api.tls.enabled: true + api.tls.cert-file: etc/web/api.crt + api.tls.key-file: etc/web/api.key + +Optional configuration: + +.. code-block:: yaml + + config: + master: + api.tls.ca-file: trust/ca.pem + api.tls.allow-insecure: true + +If ``api.tls.ca-file`` is set, the Web API requires client certificates signed +by that CA bundle. + +If the configured Web API certificate is self-signed, set +``api.tls.allow-insecure: true`` only when you intentionally want to allow +that certificate posture. Sysinspect will log a warning when it starts in that +mode. + +Behavior: + +- if ``api.enabled`` is ``true`` but TLS is not configured correctly, the Web + API stays disabled +- ``sysmaster`` itself keeps running +- Swagger UI is served over ``https://:4202/doc/`` by default + +Re-Registration And Replacement +------------------------------- + +Use re-registration when: + +- the minion was rebuilt or replaced +- the master identity changed +- trust files were lost or corrupted + +A clean replacement flow is: + +1. unregister the old minion identity from the master if needed +2. start the replacement minion once and verify the current master fingerprint +3. register the replacement minion +4. start it normally +5. confirm secure handshake and transport status + +Related Material +---------------- + +- :doc:`secure_transport` +- :doc:`transport_protocol` +- :doc:`security_model` +- :doc:`../apidoc/overview` diff --git a/docs/genusage/overview.rst b/docs/genusage/overview.rst index bcbef776..b984e7e0 100644 --- a/docs/genusage/overview.rst +++ b/docs/genusage/overview.rst @@ -48,6 +48,9 @@ sections: cli distributed_model secure_transport + transport_protocol + operator_security + security_model systraits targeting virtual_minions diff --git a/docs/genusage/secure_transport.rst b/docs/genusage/secure_transport.rst index a9964dca..caafea57 100644 --- a/docs/genusage/secure_transport.rst +++ b/docs/genusage/secure_transport.rst @@ -11,6 +11,10 @@ hand or understand the protocol internals. This page is only about the Master/Minion link. It does not describe the Web API. +For the exact wire format, see :doc:`transport_protocol`. +For operator procedures, see :doc:`operator_security`. +For threat coverage and limits, see :doc:`security_model`. + What You Need To Know --------------------- @@ -124,6 +128,36 @@ The protection happens in two steps: So the long-term trust comes from the registered identities, while everyday traffic is protected by a fresh session created when the connection starts. +What Changes And What Does Not +------------------------------ + +This transport change affects the Master/Minion boundary only. + +What changes: + +- the Master/Minion bootstrap now uses authenticated ephemeral key exchange +- reconnects always create a fresh secure session +- unsupported or malformed peers fail explicitly instead of falling back + +What does not change: + +- the local ``sysinspect`` to ``sysmaster`` console path still uses its own + local console transport +- the embedded Web API still uses normal HTTPS/TLS and is separate from the + Master/Minion transport +- the fileserver still publishes artefacts on its existing fileserver endpoint +- profile assignment still happens through master-managed traits and normal + sync workflows + +Operationally this means: + +- console administration commands keep working as before +- Web API TLS settings are configured separately under ``api.*`` +- ``sysinspect --sync`` still refreshes modules, libraries, sensors, and + profiles through the fileserver path after the secure control channel is up +- profile sync policy is unchanged; the secure transport only protects the + control messages that trigger or coordinate it + What Operators Should Do ------------------------ @@ -151,6 +185,29 @@ If a Minion can no longer establish a secure connection, the usual causes are: In those cases, prefer the supported recovery path such as re-registration or re-bootstrap instead of editing transport files manually. +Operator Diagnostics +-------------------- + +Sysinspect now emits operator-visible diagnostics for the common failure cases. + +Look for these classes of messages: + +- secure bootstrap authentication failure +- secure bootstrap replay rejection +- secure bootstrap version mismatch or malformed-frame rejection +- staged rotation key mismatch versus the reconnecting Minion key +- Web API TLS startup failure, including configured cert/key/CA paths + +The quickest operator checks are: + +- ``sysinspect network --status`` for active key id, last handshake time, and + rotation state +- the master error log for bootstrap rejection and TLS startup messages +- the minion error log for bootstrap-diagnostic and ack-verification failures + +If a Minion reconnects but does not complete bootstrap, check the logs on both +sides before editing any managed state. + Transport Rotation Workflow --------------------------- @@ -385,6 +442,19 @@ The status view includes: - current rotation state - ``security.transport.last-rotated-at`` value +Fresh Installs, Re-Registration, And Admin Workflows +---------------------------------------------------- + +The intended operator workflow remains simple: + +- fresh registration auto-provisions the managed transport metadata +- normal reconnects auto-bootstrap a fresh secure session +- re-registration replaces the trust relationship when identity changes +- master-side administration stays on the console and Web API paths + +In other words, the secure transport is hardened without adding a manual +day-to-day key exchange procedure for operators. + Rotation Safety Model --------------------- diff --git a/docs/genusage/security_model.rst b/docs/genusage/security_model.rst new file mode 100644 index 00000000..15538f0e --- /dev/null +++ b/docs/genusage/security_model.rst @@ -0,0 +1,104 @@ +Security Model +============== + +This page states what each security layer in Sysinspect is responsible for. + +Master/Minion Transport +----------------------- + +For the Master/Minion link: + +- RSA identifies the master and the minion +- RSA signatures authenticate the bootstrap exchange +- ephemeral Curve25519 key exchange provides a fresh short-lived shared secret +- libsodium protects steady-state traffic after bootstrap + +What this gives you: + +- authenticated peers +- fresh per-connection session protection +- replay rejection through counters and nonce derivation +- fail-closed rejection of unsupported or malformed peers + +What it is not: + +- browser-style TLS +- a public PKI system +- a substitute for operator verification of the initial trust relationship + +Web API +------- + +For the embedded Web API: + +- TLS protects the HTTPS connection +- bearer tokens authenticate API requests +- request and response bodies are plain JSON over HTTPS + +What this gives you: + +- standard HTTPS protection for remote API calls +- standard certificate validation behavior unless explicitly relaxed by the + client side +- no custom application-layer crypto inside the JSON payloads + +What it is not: + +- part of the Master/Minion secure transport +- protected by the Master/Minion libsodium channel + +Console +------- + +The local ``sysinspect`` to ``sysmaster`` console path is a separate transport. + +It remains: + +- local to the master host +- based on its own console RSA/bootstrap mechanism +- independent from both the Web API TLS layer and the Master/Minion transport + +Threats Covered +--------------- + +The current design is meant to cover: + +- passive eavesdropping on Master/Minion traffic after registration +- tampering with secure Master/Minion frames +- replay of old secure frames +- unsupported peers attempting insecure fallback +- duplicate active sessions for the same minion +- normal remote API exposure through plaintext HTTP + +Out Of Scope +------------ + +The current design does not try to solve everything. + +Out of scope: + +- compromise of the master or minion host itself +- theft of private keys from a compromised host +- manual trust mistakes during initial fingerprint verification +- deliberate operator choice to allow self-signed Web API TLS +- weak client-side trust settings outside Sysinspect server configuration +- generic browser, PAM, LDAP, or operating-system hardening outside + Sysinspect itself + +Operational Assumptions +----------------------- + +Sysinspect assumes: + +- the initial master fingerprint is verified by the operator +- private key files are protected by host filesystem permissions +- managed transport state is not edited manually during normal operation +- Web API certificates and keys are provided and rotated through normal + operator procedures + +In short: + +- RSA authenticates identities +- Curve25519 + libsodium protect Master/Minion traffic +- TLS protects the Web API +- each layer has a separate role and should be operated that way diff --git a/docs/genusage/transport_protocol.rst b/docs/genusage/transport_protocol.rst new file mode 100644 index 00000000..a2ce8212 --- /dev/null +++ b/docs/genusage/transport_protocol.rst @@ -0,0 +1,220 @@ +Master/Minion Protocol +====================== + +This page describes the exact secure transport used between ``sysmaster`` and +``sysminion``. + +It is intentionally lower-level than :doc:`secure_transport`. Use this page +when you need the precise wire shapes, handshake order, or rejection rules. + +Frame Envelope +-------------- + +The outer wire format is length-prefixed: + +1. write a big-endian ``u32`` frame length +2. write one JSON-encoded secure frame + +Secure frame kinds are: + +- ``bootstrap_hello`` +- ``bootstrap_ack`` +- ``bootstrap_diagnostic`` +- ``data`` + +Handshake Binding +----------------- + +Every secure session is bound to: + +- minion id +- minion RSA fingerprint +- master RSA fingerprint +- secure protocol version +- connection id +- client nonce +- master nonce + +That binding is carried in ``SecureSessionBinding`` and authenticated during +bootstrap. + +Bootstrap Sequence +------------------ + +Normal secure session establishment is: + +1. The minion opens a TCP connection to the master. +2. The minion sends ``bootstrap_hello``. +3. The master validates: + - the minion is registered + - the stored RSA fingerprints match + - at least one common secure transport version exists + - the opening is not stale or replayed + - there is no conflicting active session for that minion +4. The master replies with: + - ``bootstrap_ack`` on success, or + - ``bootstrap_diagnostic`` on failure +5. Both sides derive the same short-lived session key from: + - the authenticated Curve25519 shared secret + - the completed binding + - both ephemeral public keys +6. After that, every frame on that connection must be ``data``. + +``bootstrap_hello`` +------------------- + +Fields: + +- ``binding``: initial session binding +- ``supported_versions``: secure transport versions supported by the minion +- ``client_ephemeral_pubkey``: minion ephemeral Curve25519 public key +- ``binding_signature``: minion RSA signature over the authenticated opening +- ``key_id``: optional managed transport key id for reconnect/rotation continuity + +Example: + +.. code-block:: json + + { + "kind": "bootstrap_hello", + "binding": { + "minion_id": "minion-a", + "minion_rsa_fingerprint": "minion-fp", + "master_rsa_fingerprint": "master-fp", + "protocol_version": 1, + "connection_id": "conn-1", + "client_nonce": "client-nonce", + "master_nonce": "", + "timestamp": 1734739200 + }, + "supported_versions": [1], + "client_ephemeral_pubkey": "", + "binding_signature": "", + "key_id": "trk-current" + } + +``bootstrap_ack`` +----------------- + +Fields: + +- ``binding``: completed binding with the master nonce filled in +- ``session_id``: master-assigned secure session id +- ``key_id``: accepted transport key id +- ``rotation``: ``none``, ``rekey``, or ``reregister`` +- ``master_ephemeral_pubkey``: master ephemeral Curve25519 public key +- ``binding_signature``: master RSA signature over the authenticated ack + +Example: + +.. code-block:: json + + { + "kind": "bootstrap_ack", + "binding": { + "minion_id": "minion-a", + "minion_rsa_fingerprint": "minion-fp", + "master_rsa_fingerprint": "master-fp", + "protocol_version": 1, + "connection_id": "conn-1", + "client_nonce": "client-nonce", + "master_nonce": "master-nonce", + "timestamp": 1734739200 + }, + "session_id": "sid-1", + "key_id": "trk-current", + "rotation": "none", + "master_ephemeral_pubkey": "", + "binding_signature": "" + } + +``bootstrap_diagnostic`` +------------------------ + +This is the only failure frame allowed before a secure session exists. + +Fields: + +- ``code``: ``unsupported_version``, ``bootstrap_rejected``, + ``replay_rejected``, ``rate_limited``, ``malformed_frame``, or + ``duplicate_session`` +- ``message``: human-readable rejection reason +- ``failure``: retry/disconnect semantics + +Example: + +.. code-block:: json + + { + "kind": "bootstrap_diagnostic", + "code": "replay_rejected", + "message": "Secure bootstrap replay rejected for minion-a", + "failure": { + "retryable": false, + "disconnect": true, + "rate_limit": true + } + } + +``data`` +-------- + +After bootstrap succeeds, all traffic uses ``data``. + +Fields: + +- ``protocol_version``: negotiated secure transport version +- ``session_id``: active secure session id +- ``key_id``: active managed transport key id +- ``counter``: per-direction monotonic counter +- ``nonce``: counter-derived libsodium nonce +- ``payload``: authenticated encrypted payload + +Example: + +.. code-block:: json + + { + "kind": "data", + "protocol_version": 1, + "session_id": "sid-1", + "key_id": "trk-current", + "counter": 1, + "nonce": "", + "payload": "" + } + +Enforcement Rules +----------------- + +The transport fails closed. + +Important rules: + +- unsupported peers do not fall back silently +- plaintext registration remains the only allowed non-secure setup path +- plaintext ``ehlo`` and other normal minion traffic are rejected +- duplicate secure sessions for the same minion are rejected +- replayed bootstrap openings are rejected +- replayed, duplicated, stale, or tampered ``data`` frames are rejected +- reconnects create a new connection id, new nonces, and a fresh short-lived + session key + +Rotation Interaction +-------------------- + +Managed transport rotation does not change the wire shape. + +What changes during rotation: + +- the active ``key_id`` can change +- the master may advertise rotation state in ``bootstrap_ack`` +- reconnect after rotation establishes a fresh secure session using the new + managed transport key id + +Related Material +---------------- + +- :doc:`secure_transport` for operator-facing usage +- :doc:`operator_security` for registration, key storage, and troubleshooting +- :doc:`security_model` for threat coverage and limits diff --git a/docs/genusage/virtual_minions.rst b/docs/genusage/virtual_minions.rst index 1a4f6395..fadc19ef 100644 --- a/docs/genusage/virtual_minions.rst +++ b/docs/genusage/virtual_minions.rst @@ -16,7 +16,7 @@ .. role:: bi :class: bolditalic -.. _global_configuration: +.. _virtual_minions: Clusters and Virtual Minions ============================ diff --git a/docs/global_config.rst b/docs/global_config.rst index ee1e8d6e..a372638e 100644 --- a/docs/global_config.rst +++ b/docs/global_config.rst @@ -280,14 +280,21 @@ Below are directives for the configuration of the File Server service: Type: **boolean** - Enable or disable the WebAPI service to control Sysinspect Master remotely. + Enable or disable the embedded Web API listener inside the ``sysmaster`` + process so Sysinspect Master can be controlled remotely. + + This listener is part of ``sysmaster`` itself. Sysinspect does not start a + separate Web API daemon for this interface. .. important:: When enabled, the WebAPI serves its OpenAPI documentation through Swagger UI. - The documentation endpoint is available at ``http://:/doc/``. + The documentation endpoint is available at ``https://:/doc/``. + + If ``api.enabled`` is ``true`` but TLS is not configured correctly, + ``sysmaster`` keeps running and the Web API stays disabled with an error log. - The Swagger UI is typically available at ``http://:/doc/``. + The Swagger UI is typically available at ``https://:/doc/``. Default port is ``4202``. .. note:: @@ -301,7 +308,7 @@ Below are directives for the configuration of the File Server service: Type: **string** - IPv4 address on which the WebAPI service is listening for all incoming and outgoing traffic. + IPv4 address on which the embedded Web API listener accepts traffic. Default value is ``0.0.0.0``. @@ -310,16 +317,16 @@ Below are directives for the configuration of the File Server service: Type: **integer** - Network port number on which the WebAPI service is listening. + Network port number on which the embedded Web API listener is listening. - WebAPI service port is ``4202``. + The embedded Web API listener uses port ``4202`` by default. ``api.auth`` ############ Type: **string** - Authentication method to be used for the WebAPI service. This is a string and can be one of the following: + Authentication method to be used for the embedded Web API. This is a string and can be one of the following: - ``pam`` - ``ldap`` `(planned, not implemented yet)` @@ -329,7 +336,7 @@ Below are directives for the configuration of the File Server service: Type: **boolean** - Enable or disable development-only helpers for the WebAPI service. + Enable or disable development-only helpers for the embedded Web API. .. danger:: @@ -339,6 +346,69 @@ Below are directives for the configuration of the File Server service: Default is ``false``. +``api.tls.enabled`` +################### + + Type: **boolean** + + Enable TLS for the embedded Web API listener. + + Default is ``false``. + +``api.tls.cert-file`` +##################### + + Type: **string** + + Path to the PEM certificate chain used by the Web API TLS listener. + + If the path is relative, it is resolved under the Sysinspect root. If it is + absolute, it is used as-is. + + When ``api.tls.enabled`` is ``true``, this option is required. + +``api.tls.key-file`` +#################### + + Type: **string** + + Path to the PEM private key used by the Web API TLS listener. + + If the path is relative, it is resolved under the Sysinspect root. If it is + absolute, it is used as-is. + + When ``api.tls.enabled`` is ``true``, this option is required. + +``api.tls.ca-file`` +################### + + Type: **string** + + Optional CA bundle path used to verify client certificates for the Web API + TLS listener. + + If the path is relative, it is resolved under the Sysinspect root. If it is + absolute, it is used as-is. + + When set, clients must present a certificate chain that validates against + this CA bundle. + +``api.tls.allow-insecure`` +########################## + + Type: **boolean** + + Allow the embedded Web API to start with a self-signed or otherwise + non-public TLS certificate. + + When this option is ``false``, Sysinspect rejects a self-signed Web API + certificate during startup. + + When this option is ``true``, Sysinspect allows that setup and logs a + warning so operators know clients must explicitly trust the certificate. + + Default is ``false``. + ``telemetry.location`` ###################### @@ -492,6 +562,13 @@ Example configuration for the Sysinspect Master: - my_model - my_other_model + api.enabled: false + # To enable the embedded Web API, configure TLS first: + # api.enabled: true + # api.tls.enabled: true + # api.tls.cert-file: etc/web/api.crt + # api.tls.key-file: etc/web/api.key + ``datastore.path`` ################### Type: **string** diff --git a/docs/index.rst b/docs/index.rst index 40c505f6..dfe754de 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -48,6 +48,7 @@ welcome—see the section on contributing for how to get involved. tutorial/action_chain_tutor tutorial/module_management tutorial/profiles_tutor + tutorial/secure_transport_tutor tutorial/wasm_modules_tutor tutorial/lua_modules_tutor tutorial/menotify_tutor diff --git a/docs/requirements.txt b/docs/requirements.txt index 51eebd03..75c98b7b 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,2 +1,3 @@ +sphinx myst_parser sphinx_rtd_theme diff --git a/docs/tutorial/secure_transport_tutor.rst b/docs/tutorial/secure_transport_tutor.rst new file mode 100644 index 00000000..aa779613 --- /dev/null +++ b/docs/tutorial/secure_transport_tutor.rst @@ -0,0 +1,161 @@ +Secure Transport Tutorial +========================= + +This tutorial walks through the normal operator lifecycle for secure transport: + +1. first registration +2. fingerprint verification +3. automatic key provisioning +4. Web API TLS usage +5. recovery from a broken or replaced master/minion + +First Bootstrap +--------------- + +Start ``sysmaster`` first. + +Then start the minion once. If this is the first contact, the minion will not +be registered yet and will print the master fingerprint. + +Typical pattern: + +.. code-block:: text + + ERROR: Minion is not registered + INFO: Master fingerprint: + +At this point: + +- do not trust the fingerprint blindly +- verify it through your normal out-of-band process + +Fingerprint Verification +------------------------ + +After you have verified the fingerprint, register the minion: + +.. code-block:: bash + + sysminion --register + +If registration succeeds, the master accepts the minion RSA identity and both +sides create managed transport metadata automatically. + +What gets provisioned automatically: + +- the minion stores the trusted master public key +- the master stores the minion public key +- the minion creates ``transport/master/state.json`` +- the master creates ``transport/minions//state.json`` + +Normal Startup After Registration +--------------------------------- + +Once registration exists, start the minion normally: + +.. code-block:: bash + + sysminion --start + +The normal sequence is: + +1. the minion loads its managed transport state +2. the minion sends secure bootstrap to the master +3. the master validates identity, version, and replay rules +4. the connection switches to a secure session +5. traits, commands, events, and sync control traffic use that secure session + +Verify it from the operator side: + +.. code-block:: bash + + sysinspect network --status + +Look for: + +- a current handshake timestamp +- an active key id +- idle rotation state unless you intentionally staged rotation + +Automatic Key Provisioning +-------------------------- + +You do not need to create transport session keys manually. + +Sysinspect manages: + +- registration trust anchors +- transport metadata +- fresh per-connection secure sessions +- staged and applied rotation state + +If you rotate transport state: + +.. code-block:: bash + + sysinspect network --rotate --id + +the reconnect and secure bootstrap after that rotation are still automatic. + +Web API TLS Usage +----------------- + +The Web API is separate from the Master/Minion secure transport. + +Configure it on the master: + +.. code-block:: yaml + + config: + master: + api.enabled: true + api.tls.enabled: true + api.tls.cert-file: etc/web/api.crt + api.tls.key-file: etc/web/api.key + +Then restart ``sysmaster`` and open: + +.. code-block:: text + + https://:4202/doc/ + +Normal API flow: + +1. authenticate over HTTPS +2. receive a bearer token +3. send plain JSON requests over HTTPS with ``Authorization: Bearer `` + +Broken Minion Recovery +---------------------- + +If a minion loses trust data or is rebuilt: + +1. start it once and inspect the failure +2. if needed, unregister the old relationship on the master +3. verify the current master fingerprint again +4. register the minion again +5. start it normally +6. verify secure handshake with ``sysinspect network --status`` + +Broken Master Or Replaced Master Recovery +----------------------------------------- + +If the master identity changes, the old trust relationship is no longer valid. + +Recovery flow: + +1. start the rebuilt master +2. verify its new fingerprint +3. re-register affected minions against the new master fingerprint +4. start the minions normally +5. verify transport status and, if desired, run a cluster sync + +Quick Checklist +--------------- + +For healthy secure operation: + +- verify the master fingerprint during registration +- avoid editing transport state files manually +- use ``network --status`` to confirm handshakes and rotation state +- keep Web API TLS configured separately from the Master/Minion transport diff --git a/etc/sysinspect.conf.sample b/etc/sysinspect.conf.sample index 93b3d5d3..506a8b98 100644 --- a/etc/sysinspect.conf.sample +++ b/etc/sysinspect.conf.sample @@ -113,9 +113,19 @@ config: pidfile: /tmp/sysinspect.pid # API configuration + # The embedded Web API only runs when TLS is configured. + # Leave it disabled until certificate and key paths are in place. + api.enabled: false + # api.enabled: true + # api.bind.ip: 0.0.0.0 + # api.bind.port: 4202 + # api.tls.enabled: true + # api.tls.cert-file: etc/web/api.crt + # api.tls.key-file: etc/web/api.key + # api.tls.ca-file: trust/ca.pem + # api.tls.allow-insecure: false # Set api.devmode only when you need auth bypass and the development query endpoint. api.devmode: false - api.enabled: true # Configuration that is present only on a minion node minion: diff --git a/examples/transport-fixtures/README.md b/examples/transport-fixtures/README.md new file mode 100644 index 00000000..88a028a5 --- /dev/null +++ b/examples/transport-fixtures/README.md @@ -0,0 +1,20 @@ +# Secure transport fixtures + +These files are minimal example fixtures for the current transport and Web API +shapes. + +They are illustrative fixtures, not captured live traffic dumps. + +Files: + +- `bootstrap_hello.json`: plaintext secure bootstrap opening +- `bootstrap_ack.json`: plaintext secure bootstrap acknowledgement +- `secure_data_frame.json`: encrypted steady-state frame envelope +- `webapi_auth_request.json`: HTTPS JSON authentication request body +- `webapi_query_request.json`: HTTPS JSON query request body + +Use them for: + +- documentation examples +- fixture-driven tests +- quick contract reviews during API or protocol changes diff --git a/examples/transport-fixtures/bootstrap_ack.json b/examples/transport-fixtures/bootstrap_ack.json new file mode 100644 index 00000000..2ffff5c7 --- /dev/null +++ b/examples/transport-fixtures/bootstrap_ack.json @@ -0,0 +1,18 @@ +{ + "kind": "bootstrap_ack", + "binding": { + "minion_id": "minion-a", + "minion_rsa_fingerprint": "minion-fp", + "master_rsa_fingerprint": "master-fp", + "protocol_version": 1, + "connection_id": "conn-1", + "client_nonce": "client-nonce", + "master_nonce": "master-nonce", + "timestamp": 1734739200 + }, + "session_id": "sid-1", + "key_id": "trk-current", + "rotation": "none", + "master_ephemeral_pubkey": "", + "binding_signature": "" +} diff --git a/examples/transport-fixtures/bootstrap_hello.json b/examples/transport-fixtures/bootstrap_hello.json new file mode 100644 index 00000000..29851ae0 --- /dev/null +++ b/examples/transport-fixtures/bootstrap_hello.json @@ -0,0 +1,19 @@ +{ + "kind": "bootstrap_hello", + "binding": { + "minion_id": "minion-a", + "minion_rsa_fingerprint": "minion-fp", + "master_rsa_fingerprint": "master-fp", + "protocol_version": 1, + "connection_id": "conn-1", + "client_nonce": "client-nonce", + "master_nonce": "", + "timestamp": 1734739200 + }, + "supported_versions": [ + 1 + ], + "client_ephemeral_pubkey": "", + "binding_signature": "", + "key_id": "trk-current" +} diff --git a/examples/transport-fixtures/secure_data_frame.json b/examples/transport-fixtures/secure_data_frame.json new file mode 100644 index 00000000..57f162dc --- /dev/null +++ b/examples/transport-fixtures/secure_data_frame.json @@ -0,0 +1,9 @@ +{ + "kind": "data", + "protocol_version": 1, + "session_id": "sid-1", + "key_id": "trk-current", + "counter": 1, + "nonce": "", + "payload": "" +} diff --git a/examples/transport-fixtures/webapi_auth_request.json b/examples/transport-fixtures/webapi_auth_request.json new file mode 100644 index 00000000..7cf3c28d --- /dev/null +++ b/examples/transport-fixtures/webapi_auth_request.json @@ -0,0 +1,4 @@ +{ + "username": "operator", + "password": "secret" +} diff --git a/examples/transport-fixtures/webapi_query_request.json b/examples/transport-fixtures/webapi_query_request.json new file mode 100644 index 00000000..d345d3fe --- /dev/null +++ b/examples/transport-fixtures/webapi_query_request.json @@ -0,0 +1,9 @@ +{ + "model": "cm/file-ops", + "query": "*", + "traits": "", + "mid": "", + "context": { + "reason": "manual-run" + } +} diff --git a/libdatastore/src/resources.rs b/libdatastore/src/resources.rs index ae0eb669..d5bae4c5 100644 --- a/libdatastore/src/resources.rs +++ b/libdatastore/src/resources.rs @@ -2,12 +2,14 @@ use crate::{ cfg::DataStorageConfig, util::{copy, data_tree, get_sha256, json_write, meta_tree, unix_now}, }; +use colored::Colorize; use serde::{Deserialize, Serialize}; use std::os::unix::fs::MetadataExt; use std::{ fs, io::{self}, path::{Path, PathBuf}, + time::Duration, }; #[derive(Debug, Clone)] @@ -26,10 +28,65 @@ pub struct DataItemMeta { pub fmode: u32, } +/// Format a byte count into a short human-readable string. +fn format_bytes(bytes: u64) -> String { + const KIB: u64 = 1024; + const MIB: u64 = 1024 * KIB; + const GIB: u64 = 1024 * MIB; + const TIB: u64 = 1024 * GIB; + + if bytes >= TIB { + format!("{:.1} TiB", bytes as f64 / TIB as f64) + } else if bytes >= GIB { + format!("{:.1} GiB", bytes as f64 / GIB as f64) + } else if bytes >= MIB { + format!("{:.1} MiB", bytes as f64 / MIB as f64) + } else if bytes >= KIB { + format!("{:.1} KiB", bytes as f64 / KIB as f64) + } else { + format!("{bytes} B") + } +} + +/// Format an optional size limit for operator-facing logging. +fn format_size_limit(bytes: Option) -> String { + bytes.map(format_bytes).unwrap_or_else(|| "unlimited".to_string()) +} + +/// Format an optional expiration duration for operator-facing logging. +fn format_expiration(expiration: Option) -> String { + match expiration { + Some(duration) => { + let total = duration.as_secs(); + let days = total / 86_400; + let hours = (total % 86_400) / 3_600; + let minutes = (total % 3_600) / 60; + let seconds = total % 60; + + if days > 0 { + format!("{days}d {hours}h") + } else if hours > 0 { + format!("{hours}h {minutes}m") + } else if minutes > 0 { + format!("{minutes}m {seconds}s") + } else { + format!("{seconds}s") + } + } + None => "never".to_string(), + } +} + impl DataStorage { pub fn new(cfg: DataStorageConfig, root: impl AsRef) -> io::Result { let root = root.as_ref().to_path_buf(); - log::info!("Initializing datastore at {:?} with config: {:?}", root, cfg); + log::info!( + "Initializing datastore at {}. Expiration: {}, max item size: {}, max overall size: {}", + root.display().to_string().bright_yellow(), + format_expiration(cfg.get_expiration()).bright_yellow(), + format_size_limit(cfg.get_max_item_size()).bright_yellow(), + format_size_limit(cfg.get_max_overall_size()).bright_yellow(), + ); fs::create_dir_all(&root)?; Ok(Self { cfg, root }) } diff --git a/libsysinspect/src/cfg/mmconf.rs b/libsysinspect/src/cfg/mmconf.rs index f9018c9b..c41f151f 100644 --- a/libsysinspect/src/cfg/mmconf.rs +++ b/libsysinspect/src/cfg/mmconf.rs @@ -825,6 +825,26 @@ pub struct MasterConfig { #[serde(rename = "api.auth")] pam_enabled: Option, + /// Enable TLS for the embedded Web API listener. + #[serde(rename = "api.tls.enabled")] + api_tls_enabled: Option, + + /// Path to the PEM certificate chain used by the Web API TLS listener. + #[serde(rename = "api.tls.cert-file")] + api_tls_cert_file: Option, + + /// Path to the PEM private key used by the Web API TLS listener. + #[serde(rename = "api.tls.key-file")] + api_tls_key_file: Option, + + /// Optional CA bundle path used to verify client certificates for the Web API TLS listener. + #[serde(rename = "api.tls.ca-file")] + api_tls_ca_file: Option, + + /// Allow self-signed or otherwise non-public Web API TLS certificates. + #[serde(rename = "api.tls.allow-insecure")] + api_tls_allow_insecure: Option, + /// Enable development-only Web API shortcuts. /// /// This keeps the normal Web API enabled, but allows the authentication @@ -996,6 +1016,31 @@ impl MasterConfig { self.api_version.unwrap_or(1) } + /// Return whether HTTPS/TLS is enabled for the embedded Web API listener. + pub fn api_tls_enabled(&self) -> bool { + self.api_tls_enabled.unwrap_or(false) + } + + /// Return the configured Web API TLS certificate path, resolved against the SysInspect root when relative. + pub fn api_tls_cert_file(&self) -> Option { + self.api_tls_cert_file.as_deref().map(|path| self.resolve_rooted_path(path)) + } + + /// Return the configured Web API TLS private key path, resolved against the SysInspect root when relative. + pub fn api_tls_key_file(&self) -> Option { + self.api_tls_key_file.as_deref().map(|path| self.resolve_rooted_path(path)) + } + + /// Return the optional Web API TLS CA bundle path, resolved against the SysInspect root when relative. + pub fn api_tls_ca_file(&self) -> Option { + self.api_tls_ca_file.as_deref().map(|path| self.resolve_rooted_path(path)) + } + + /// Return whether self-signed or otherwise non-public Web API TLS certificates are allowed. + pub fn api_tls_allow_insecure(&self) -> bool { + self.api_tls_allow_insecure.unwrap_or(false) + } + /// Get API authentication method pub fn api_auth(&self) -> AuthMethod { match self.pam_enabled.as_deref().map(|s| s.to_ascii_lowercase()) { @@ -1065,6 +1110,12 @@ impl MasterConfig { PathBuf::from(DEFAULT_SYSINSPECT_ROOT.to_string()) } + /// Resolve a path under the SysInspect root unless it is already absolute. + fn resolve_rooted_path(&self, path: &str) -> PathBuf { + let path = PathBuf::from(path); + if path.is_absolute() { path } else { self.root_dir().join(path) } + } + /// Get minion keys store pub fn minion_keys_root(&self) -> PathBuf { self.root_dir().join(CFG_MINION_KEYS) diff --git a/libsysinspect/src/cfg/mmconf_ut.rs b/libsysinspect/src/cfg/mmconf_ut.rs index 5ac84f22..76009976 100644 --- a/libsysinspect/src/cfg/mmconf_ut.rs +++ b/libsysinspect/src/cfg/mmconf_ut.rs @@ -56,6 +56,34 @@ fn master_transport_paths_are_under_managed_transport_root() { assert_eq!(cfg.transport_minions_root(), cfg.transport_root().join(CFG_TRANSPORT_MINIONS)); } +#[test] +fn master_api_tls_relative_paths_are_resolved_under_root() { + let cfg = MasterConfig::new(write_master_cfg( + "config:\n master:\n fileserver.models: []\n api.tls.enabled: true\n api.tls.cert-file: etc/web/api.crt\n api.tls.key-file: etc/web/api.key\n api.tls.ca-file: trust/ca.pem\n api.tls.allow-insecure: true\n", + )) + .unwrap(); + + assert!(cfg.api_tls_enabled()); + assert_eq!(cfg.api_tls_cert_file().unwrap(), cfg.root_dir().join("etc/web/api.crt")); + assert_eq!(cfg.api_tls_key_file().unwrap(), cfg.root_dir().join("etc/web/api.key")); + assert_eq!(cfg.api_tls_ca_file().unwrap(), cfg.root_dir().join("trust/ca.pem")); + assert!(cfg.api_tls_allow_insecure()); +} + +#[test] +fn master_api_tls_absolute_paths_stay_absolute() { + let cfg = MasterConfig::new(write_master_cfg( + "config:\n master:\n fileserver.models: []\n api.tls.cert-file: /srv/tls/api.crt\n api.tls.key-file: /srv/tls/api.key\n api.tls.ca-file: /srv/tls/ca.pem\n", + )) + .unwrap(); + + assert_eq!(cfg.api_tls_cert_file().unwrap(), std::path::PathBuf::from("/srv/tls/api.crt")); + assert_eq!(cfg.api_tls_key_file().unwrap(), std::path::PathBuf::from("/srv/tls/api.key")); + assert_eq!(cfg.api_tls_ca_file().unwrap(), std::path::PathBuf::from("/srv/tls/ca.pem")); + assert!(!cfg.api_tls_enabled()); + assert!(!cfg.api_tls_allow_insecure()); +} + #[test] fn minion_transport_paths_are_under_managed_transport_root() { let mut cfg = MinionConfig::default(); diff --git a/libsysinspect/src/transport/secure_bootstrap.rs b/libsysinspect/src/transport/secure_bootstrap.rs index e625ec74..f9ed8b15 100644 --- a/libsysinspect/src/transport/secure_bootstrap.rs +++ b/libsysinspect/src/transport/secure_bootstrap.rs @@ -3,16 +3,19 @@ use base64::{Engine, engine::general_purpose::STANDARD}; use chrono::Utc; use libcommon::SysinspectError; use libsysproto::secure::{ - SECURE_PROTOCOL_VERSION, SecureBootstrapAck, SecureBootstrapDiagnostic, SecureBootstrapHello, SecureDiagnosticCode, SecureFailureSemantics, - SecureFrame, SecureRotationMode, SecureSessionBinding, + SECURE_SUPPORTED_PROTOCOL_VERSIONS, SecureBootstrapAck, SecureBootstrapDiagnostic, SecureBootstrapHello, SecureDiagnosticCode, + SecureFailureSemantics, SecureFrame, SecureRotationMode, SecureSessionBinding, }; use rsa::{RsaPrivateKey, RsaPublicKey}; use sha2::{Digest, Sha256}; -use sodiumoxide::crypto::secretbox::{self, Key}; +use sodiumoxide::crypto::{ + box_, + secretbox::{self, Key}, +}; use std::sync::OnceLock; use uuid::Uuid; -use crate::rsa::keys::{decrypt, encrypt, get_fingerprint, sign_data, verify_sign}; +use crate::rsa::keys::{get_fingerprint, sign_data, verify_sign}; static SODIUM_INIT: OnceLock<()> = OnceLock::new(); @@ -20,9 +23,21 @@ static SODIUM_INIT: OnceLock<()> = OnceLock::new(); #[derive(Debug, Clone)] pub struct SecureBootstrapSession { binding: SecureSessionBinding, - session_key: Key, + session_key: Option, key_id: String, session_id: Option, + offered_versions: Vec, + local_ephemeral_public: box_::PublicKey, + local_ephemeral_secret: Option, +} + +struct MasterBootstrapAckParams { + key_id: String, + session_id: String, + rotation: SecureRotationMode, + master_ephemeral_public: box_::PublicKey, + master_ephemeral_secret: box_::SecretKey, + minion_ephemeral_public: box_::PublicKey, } /// Factory for the plaintext bootstrap diagnostics allowed before a secure session exists. @@ -34,6 +49,7 @@ impl SecureBootstrapSession { sodium_ready()?; Self::ready(state)?; Self::fingerprint("master", master_pbk, &state.master_rsa_fingerprint)?; + let offered_versions = SECURE_SUPPORTED_PROTOCOL_VERSIONS.to_vec(); let key_id = state.active_key_id.clone().or_else(|| state.last_key_id.clone()).unwrap_or_else(|| Uuid::new_v4().to_string()); let binding = SecureSessionBinding::bootstrap_opening( state.minion_id.clone(), @@ -43,13 +59,7 @@ impl SecureBootstrapSession { Uuid::new_v4().to_string(), Utc::now().timestamp(), ); - Self::hello( - binding.clone(), - state.key_material(&key_id).as_deref().map(|material| Self::derive_session_key(material, &binding)).unwrap_or_else(secretbox::gen_key), - key_id, - minion_prk, - master_pbk, - ) + Self::hello(binding.clone(), offered_versions, key_id, minion_prk, master_pbk) } /// Validate a bootstrap hello on the master side and return a signed acknowledgement frame. @@ -58,22 +68,32 @@ impl SecureBootstrapSession { key_id: Option, rotation: Option, ) -> Result<(Self, SecureFrame), SysinspectError> { Self::ready(state)?; - Self::opening(state, &hello.binding)?; + let negotiated_version = Self::negotiate_version(hello)?; + Self::opening(state, &hello.binding, &hello.supported_versions)?; Self::fingerprint("minion", minion_pbk, &state.minion_rsa_fingerprint)?; Self::fingerprint("master", &RsaPublicKey::from(master_prk), &state.master_rsa_fingerprint)?; + Self::verify_hello(hello, minion_pbk)?; + let mut accepted_binding = hello.binding.clone(); + accepted_binding.protocol_version = negotiated_version; + let minion_ephemeral_public = Self::ephemeral_public_key(&hello.client_ephemeral_pubkey)?; + let (master_ephemeral_public, master_ephemeral_secret) = box_::gen_keypair(); Self::ack( - hello.binding.clone(), - Self::verify_hello(hello, minion_pbk, master_prk)?, - hello - .key_id - .clone() - .or_else(|| key_id.clone()) - .or_else(|| state.active_key_id.clone()) - .or_else(|| state.last_key_id.clone()) - .unwrap_or_else(|| Uuid::new_v4().to_string()), + accepted_binding, master_prk, - session_id.unwrap_or_else(|| Uuid::new_v4().to_string()), - rotation.unwrap_or(SecureRotationMode::None), + MasterBootstrapAckParams { + key_id: hello + .key_id + .clone() + .or_else(|| key_id.clone()) + .or_else(|| state.active_key_id.clone()) + .or_else(|| state.last_key_id.clone()) + .unwrap_or_else(|| Uuid::new_v4().to_string()), + session_id: session_id.unwrap_or_else(|| Uuid::new_v4().to_string()), + rotation: rotation.unwrap_or(SecureRotationMode::None), + master_ephemeral_public, + master_ephemeral_secret, + minion_ephemeral_public, + }, ) } @@ -88,9 +108,16 @@ impl SecureBootstrapSession { if ack.key_id.trim().is_empty() { return Err(SysinspectError::ProtoError("Secure bootstrap ack has an empty key id".to_string())); } + if !self.offered_versions.contains(&ack.binding.protocol_version) || !SECURE_SUPPORTED_PROTOCOL_VERSIONS.contains(&ack.binding.protocol_version) { + return Err(SysinspectError::ProtoError(format!( + "Negotiated secure bootstrap version {} is not supported by this minion", + ack.binding.protocol_version + ))); + } + let master_ephemeral_public = Self::ephemeral_public_key(&ack.master_ephemeral_pubkey)?; if !verify_sign( master_pbk, - &Self::ack_material(&ack.binding, &ack.session_id, &ack.key_id, &ack.rotation)?, + &Self::ack_material(&ack.binding, &ack.session_id, &ack.key_id, &ack.rotation, &ack.master_ephemeral_pubkey)?, STANDARD .decode(&ack.binding_signature) .map_err(|err| SysinspectError::SerializationError(format!("Failed to decode secure bootstrap ack signature: {err}")))?, @@ -102,6 +129,18 @@ impl SecureBootstrapSession { self.binding = ack.binding.clone(); self.key_id = ack.key_id.clone(); self.session_id = Some(ack.session_id.clone()); + self.session_key = Some(Self::derive_session_key( + &self.binding, + &self.local_ephemeral_public, + &master_ephemeral_public, + &box_::precompute( + &master_ephemeral_public, + self.local_ephemeral_secret + .as_ref() + .ok_or_else(|| SysinspectError::ProtoError("Secure bootstrap opening is missing the local ephemeral secret key".to_string()))?, + ), + )); + self.local_ephemeral_secret = None; Ok(self) } @@ -122,26 +161,35 @@ impl SecureBootstrapSession { /// Return the negotiated libsodium session key for the secure channel. pub fn session_key(&self) -> &Key { - &self.session_key + self.session_key + .as_ref() + .expect("secure bootstrap session key is only available after the bootstrap exchange completes") } /// Encode the minion's opening bootstrap frame and keep the local bootstrap state. fn hello( - binding: SecureSessionBinding, session_key: Key, key_id: String, minion_prk: &RsaPrivateKey, master_pbk: &RsaPublicKey, + binding: SecureSessionBinding, offered_versions: Vec, key_id: String, minion_prk: &RsaPrivateKey, _master_pbk: &RsaPublicKey, ) -> Result<(Self, SecureFrame), SysinspectError> { - let session_key_cipher = STANDARD.encode( - encrypt(master_pbk.clone(), session_key.0.to_vec()) - .map_err(|_| SysinspectError::RSAError("Failed to encrypt secure session key for the master".to_string()))?, - ); + let (client_ephemeral_public, client_ephemeral_secret) = box_::gen_keypair(); + let client_ephemeral_pubkey = STANDARD.encode(client_ephemeral_public.0); let binding_signature = STANDARD.encode( - sign_data(minion_prk.clone(), &Self::hello_material(&binding, &session_key_cipher, Some(&key_id))?) + sign_data(minion_prk.clone(), &Self::hello_material(&binding, &offered_versions, &client_ephemeral_pubkey, Some(&key_id))?) .map_err(|_| SysinspectError::RSAError("Failed to sign secure bootstrap binding".to_string()))?, ); Ok(( - Self { binding: binding.clone(), session_key: session_key.clone(), key_id: key_id.clone(), session_id: None }, + Self { + binding: binding.clone(), + session_key: None, + key_id: key_id.clone(), + session_id: None, + offered_versions: offered_versions.clone(), + local_ephemeral_public: client_ephemeral_public, + local_ephemeral_secret: Some(client_ephemeral_secret), + }, SecureFrame::BootstrapHello(SecureBootstrapHello { binding: binding.clone(), - session_key_cipher, + supported_versions: offered_versions, + client_ephemeral_pubkey, binding_signature, key_id: Some(key_id), }), @@ -150,26 +198,47 @@ impl SecureBootstrapSession { /// Encode the master's bootstrap acknowledgement after the hello was authenticated successfully. fn ack( - mut binding: SecureSessionBinding, session_key: Key, key_id: String, master_prk: &RsaPrivateKey, session_id: String, - rotation: SecureRotationMode, + mut binding: SecureSessionBinding, master_prk: &RsaPrivateKey, params: MasterBootstrapAckParams, ) -> Result<(Self, SecureFrame), SysinspectError> { binding.master_nonce = Uuid::new_v4().to_string(); + let session_key = Self::derive_session_key( + &binding, + ¶ms.minion_ephemeral_public, + ¶ms.master_ephemeral_public, + &box_::precompute(¶ms.minion_ephemeral_public, ¶ms.master_ephemeral_secret), + ); + let master_ephemeral_pubkey = STANDARD.encode(params.master_ephemeral_public.0); let binding_signature = STANDARD.encode( - sign_data(master_prk.clone(), &Self::ack_material(&binding, &session_id, &key_id, &rotation)?) + sign_data(master_prk.clone(), &Self::ack_material(&binding, ¶ms.session_id, ¶ms.key_id, ¶ms.rotation, &master_ephemeral_pubkey)?) .map_err(|_| SysinspectError::RSAError("Failed to sign secure bootstrap acknowledgement".to_string()))?, ); Ok(( - Self { binding: binding.clone(), session_key, key_id: key_id.clone(), session_id: Some(session_id.clone()) }, - SecureFrame::BootstrapAck(SecureBootstrapAck { binding, session_id: session_id.clone(), key_id, rotation, binding_signature }), + Self { + binding: binding.clone(), + session_key: Some(session_key), + key_id: params.key_id.clone(), + session_id: Some(params.session_id.clone()), + offered_versions: vec![binding.protocol_version], + local_ephemeral_public: params.master_ephemeral_public, + local_ephemeral_secret: None, + }, + SecureFrame::BootstrapAck(SecureBootstrapAck { + binding, + session_id: params.session_id.clone(), + key_id: params.key_id, + rotation: params.rotation, + master_ephemeral_pubkey, + binding_signature, + }), )) } /// Check that the stored peer state is approved and matches the active protocol version. fn ready(state: &TransportPeerState) -> Result<(), SysinspectError> { - if state.protocol_version != SECURE_PROTOCOL_VERSION { + if !SECURE_SUPPORTED_PROTOCOL_VERSIONS.contains(&state.protocol_version) { return Err(SysinspectError::ProtoError(format!( - "Secure transport version mismatch in state: expected {}, found {}", - SECURE_PROTOCOL_VERSION, state.protocol_version + "Secure transport version {} in state is not supported locally", + state.protocol_version ))); } if state.approved_at.is_none() { @@ -179,9 +248,15 @@ impl SecureBootstrapSession { } /// Verify that an opening bootstrap binding matches the trusted peer state before accepting it. - fn opening(state: &TransportPeerState, binding: &SecureSessionBinding) -> Result<(), SysinspectError> { - if binding.protocol_version != SECURE_PROTOCOL_VERSION { - return Err(SysinspectError::ProtoError(format!("Unsupported secure bootstrap version {}", binding.protocol_version))); + fn opening(state: &TransportPeerState, binding: &SecureSessionBinding, supported_versions: &[u16]) -> Result<(), SysinspectError> { + if supported_versions.is_empty() { + return Err(SysinspectError::ProtoError("Secure bootstrap hello is missing supported protocol versions".to_string())); + } + if !supported_versions.contains(&binding.protocol_version) { + return Err(SysinspectError::ProtoError(format!( + "Secure bootstrap hello preferred version {} is not present in supported_versions", + binding.protocol_version + ))); } if binding.master_nonce.is_empty() && binding.minion_id == state.minion_id @@ -197,7 +272,7 @@ impl SecureBootstrapSession { /// Verify that an acknowledgement binding is still tied to the same handshake attempt and peer identities. fn accepted(state: &TransportPeerState, opening: &SecureSessionBinding, binding: &SecureSessionBinding) -> Result<(), SysinspectError> { - if binding.protocol_version != SECURE_PROTOCOL_VERSION { + if !SECURE_SUPPORTED_PROTOCOL_VERSIONS.contains(&binding.protocol_version) { return Err(SysinspectError::ProtoError(format!("Unsupported secure bootstrap ack version {}", binding.protocol_version))); } if binding.master_nonce.trim().is_empty() { @@ -214,11 +289,11 @@ impl SecureBootstrapSession { Err(SysinspectError::ProtoError("Secure bootstrap ack does not match the opening handshake binding".to_string())) } - /// Decrypt and authenticate the opening bootstrap frame sent by the minion. - fn verify_hello(hello: &SecureBootstrapHello, minion_pbk: &RsaPublicKey, master_prk: &RsaPrivateKey) -> Result { + /// Authenticate the opening bootstrap frame sent by the minion. + fn verify_hello(hello: &SecureBootstrapHello, minion_pbk: &RsaPublicKey) -> Result<(), SysinspectError> { if !verify_sign( minion_pbk, - &Self::hello_material(&hello.binding, &hello.session_key_cipher, hello.key_id.as_deref())?, + &Self::hello_material(&hello.binding, &hello.supported_versions, &hello.client_ephemeral_pubkey, hello.key_id.as_deref())?, STANDARD .decode(&hello.binding_signature) .map_err(|err| SysinspectError::SerializationError(format!("Failed to decode secure bootstrap signature: {err}")))?, @@ -227,61 +302,82 @@ impl SecureBootstrapSession { { return Err(SysinspectError::RSAError("Secure bootstrap hello signature verification failed".to_string())); } - let session_key = Self::key(&hello.session_key_cipher, master_prk)?; - Ok(session_key) + Ok(()) } - /// Decrypt the RSA-wrapped libsodium session key from the opening bootstrap frame. - fn key(cipher: &str, master_prk: &RsaPrivateKey) -> Result { - Key::from_slice( - &decrypt( - master_prk.clone(), - STANDARD - .decode(cipher) - .map_err(|err| SysinspectError::SerializationError(format!("Failed to decode secure bootstrap session key: {err}")))?, - ) - .map_err(|_| SysinspectError::RSAError("Failed to decrypt secure bootstrap session key".to_string()))?, + fn ephemeral_public_key(encoded: &str) -> Result { + box_::PublicKey::from_slice( + &STANDARD + .decode(encoded) + .map_err(|err| SysinspectError::SerializationError(format!("Failed to decode secure bootstrap ephemeral public key: {err}")))?, ) - .ok_or_else(|| SysinspectError::RSAError("Secure bootstrap session key has invalid size".to_string())) + .ok_or_else(|| SysinspectError::ProtoError("Secure bootstrap ephemeral public key has invalid size".to_string())) } - /// Derive a fresh bootstrap session key from persisted transport material and the opening handshake tuple. - /// Derive a per-bootstrap session key from persisted transport material and the unique opening binding. - fn derive_session_key(material: &[u8], binding: &SecureSessionBinding) -> Key { + /// Derive a per-bootstrap session key from the authenticated ephemeral key exchange and the handshake binding. + fn derive_session_key( + binding: &SecureSessionBinding, client_ephemeral_public: &box_::PublicKey, master_ephemeral_public: &box_::PublicKey, + shared: &box_::PrecomputedKey, + ) -> Key { let mut digest = Sha256::new(); - digest.update(b"sysinspect-secure-bootstrap"); - digest.update(material); + digest.update(b"sysinspect-secure-bootstrap-pfs"); + digest.update(shared.0); digest.update(binding.minion_id.as_bytes()); digest.update(binding.minion_rsa_fingerprint.as_bytes()); digest.update(binding.master_rsa_fingerprint.as_bytes()); digest.update(binding.connection_id.as_bytes()); digest.update(binding.client_nonce.as_bytes()); + digest.update(binding.master_nonce.as_bytes()); + digest.update(client_ephemeral_public.0); + digest.update(master_ephemeral_public.0); + digest.update(binding.timestamp.to_be_bytes()); digest.update(binding.protocol_version.to_be_bytes()); Key::from_slice(&digest.finalize()).unwrap_or_else(secretbox::gen_key) } - /// Build the signed material for a bootstrap hello from the binding, ciphered session key bytes and negotiated key id. - fn hello_material(binding: &SecureSessionBinding, session_key_cipher: &str, key_id: Option<&str>) -> Result, SysinspectError> { - Self::material(binding, Some(session_key_cipher.as_bytes()), key_id, None, None) + /// Build the signed material for a bootstrap hello from the binding, client ephemeral key and negotiated key id. + fn hello_material( + binding: &SecureSessionBinding, supported_versions: &[u16], client_ephemeral_pubkey: &str, key_id: Option<&str>, + ) -> Result, SysinspectError> { + Self::material( + binding, + Some(client_ephemeral_pubkey.as_bytes()), + Some(supported_versions), + key_id, + None, + None, + None, + ) } /// Build the signed material for a bootstrap acknowledgement from the binding, session id, activated key id and rotation state. fn ack_material( - binding: &SecureSessionBinding, session_id: &str, key_id: &str, rotation: &SecureRotationMode, + binding: &SecureSessionBinding, session_id: &str, key_id: &str, rotation: &SecureRotationMode, master_ephemeral_pubkey: &str, ) -> Result, SysinspectError> { - Self::material(binding, None, Some(key_id), Some(session_id), Some(rotation)) + Self::material( + binding, + None, + None, + Some(key_id), + Some(session_id), + Some(rotation), + Some(master_ephemeral_pubkey.as_bytes()), + ) } /// Serialize the binding and append the extra authenticated bootstrap material used for signatures. fn material( - binding: &SecureSessionBinding, session_key: Option<&[u8]>, key_id: Option<&str>, session_id: Option<&str>, - rotation: Option<&SecureRotationMode>, + binding: &SecureSessionBinding, ephemeral_key: Option<&[u8]>, supported_versions: Option<&[u16]>, key_id: Option<&str>, + session_id: Option<&str>, rotation: Option<&SecureRotationMode>, peer_ephemeral_key: Option<&[u8]>, ) -> Result, SysinspectError> { serde_json::to_vec(binding) .map(|mut out| { - if let Some(chunk) = session_key { + if let Some(chunk) = ephemeral_key { out.extend_from_slice(chunk); } + if let Some(chunk) = supported_versions { + out.extend_from_slice(serde_json::to_string(chunk).unwrap_or_default().as_bytes()); + } if let Some(chunk) = key_id { out.extend_from_slice(chunk.as_bytes()); } @@ -291,11 +387,39 @@ impl SecureBootstrapSession { if let Some(chunk) = rotation { out.extend_from_slice(format!("{chunk:?}").as_bytes()); } + if let Some(chunk) = peer_ephemeral_key { + out.extend_from_slice(chunk); + } out }) .map_err(|err| SysinspectError::SerializationError(format!("Failed to serialise secure bootstrap material: {err}"))) } + fn negotiate_version(hello: &SecureBootstrapHello) -> Result { + if hello.supported_versions.is_empty() { + return Err(SysinspectError::ProtoError("Secure bootstrap hello did not advertise any supported protocol versions".to_string())); + } + let common: Vec = hello + .supported_versions + .iter() + .copied() + .filter(|version| SECURE_SUPPORTED_PROTOCOL_VERSIONS.contains(version)) + .collect(); + if common.is_empty() { + return Err(SysinspectError::ProtoError(format!( + "No common secure transport protocol version exists between peer {:?} and local {:?}", + hello.supported_versions, SECURE_SUPPORTED_PROTOCOL_VERSIONS + ))); + } + if common.contains(&hello.binding.protocol_version) { + return Ok(hello.binding.protocol_version); + } + common + .into_iter() + .max() + .ok_or_else(|| SysinspectError::ProtoError("Secure bootstrap version negotiation failed".to_string())) + } + /// Verify that the presented RSA public key fingerprint matches the trusted transport state. fn fingerprint(label: &str, pbk: &RsaPublicKey, expected: &str) -> Result<(), SysinspectError> { if get_fingerprint(pbk).map_err(|err| SysinspectError::RSAError(err.to_string()))? == expected { diff --git a/libsysinspect/src/transport/secure_bootstrap_ut.rs b/libsysinspect/src/transport/secure_bootstrap_ut.rs index e8684754..f431da4a 100644 --- a/libsysinspect/src/transport/secure_bootstrap_ut.rs +++ b/libsysinspect/src/transport/secure_bootstrap_ut.rs @@ -2,11 +2,11 @@ use super::{ TransportKeyExchangeModel, TransportPeerState, TransportProvisioningMode, TransportRotationStatus, secure_bootstrap::{SecureBootstrapDiagnostics, SecureBootstrapSession}, }; -use crate::rsa::keys::{get_fingerprint, keygen}; +use crate::rsa::keys::{get_fingerprint, keygen, sign_data}; +use base64::{Engine, engine::general_purpose::STANDARD}; use chrono::Utc; use libsysproto::secure::{SECURE_PROTOCOL_VERSION, SecureDiagnosticCode, SecureFrame, SecureRotationMode}; use rsa::RsaPublicKey; -use sodiumoxide::crypto::secretbox; fn state(master_pbk: &RsaPublicKey, minion_pbk: &RsaPublicKey) -> TransportPeerState { TransportPeerState { @@ -130,16 +130,231 @@ fn tampered_ack_key_id_is_rejected() { } #[test] -fn persisted_material_derives_distinct_session_keys_for_distinct_openings() { - let (_, master_pbk) = keygen(2048).unwrap(); +fn distinct_openings_derive_distinct_session_keys() { + let (master_prk, master_pbk) = keygen(2048).unwrap(); let (minion_prk, minion_pbk) = keygen(2048).unwrap(); - let mut state = state(&master_pbk, &minion_pbk); - let material = secretbox::gen_key(); - state.upsert_key_with_material("kid-1", super::TransportKeyStatus::Active, Some(&material.0)); + let state = state(&master_pbk, &minion_pbk); - let first = SecureBootstrapSession::open(&state, &minion_prk, &master_pbk).unwrap().0; - let second = SecureBootstrapSession::open(&state, &minion_prk, &master_pbk).unwrap().0; + let (first_opening, first_hello) = SecureBootstrapSession::open(&state, &minion_prk, &master_pbk).unwrap(); + let first_ack = match SecureBootstrapSession::accept( + &state, + match &first_hello { + SecureFrame::BootstrapHello(hello) => hello, + _ => panic!("expected bootstrap hello"), + }, + &master_prk, + &minion_pbk, + Some("sid-1".to_string()), + Some("kid-1".to_string()), + Some(SecureRotationMode::None), + ) + .unwrap() + .1 + { + SecureFrame::BootstrapAck(ack) => ack, + _ => panic!("expected bootstrap ack"), + }; + let first = first_opening.verify_ack(&state, &first_ack, &master_pbk).unwrap(); + + let (second_opening, second_hello) = SecureBootstrapSession::open(&state, &minion_prk, &master_pbk).unwrap(); + let second_ack = match SecureBootstrapSession::accept( + &state, + match &second_hello { + SecureFrame::BootstrapHello(hello) => hello, + _ => panic!("expected bootstrap hello"), + }, + &master_prk, + &minion_pbk, + Some("sid-2".to_string()), + Some("kid-1".to_string()), + Some(SecureRotationMode::None), + ) + .unwrap() + .1 + { + SecureFrame::BootstrapAck(ack) => ack, + _ => panic!("expected bootstrap ack"), + }; + let second = second_opening.verify_ack(&state, &second_ack, &master_pbk).unwrap(); assert_ne!(first.binding().connection_id, second.binding().connection_id); assert_ne!(first.session_key().0.to_vec(), second.session_key().0.to_vec()); } + +#[test] +fn bootstrap_negotiates_down_to_supported_version() { + let (master_prk, master_pbk) = keygen(2048).unwrap(); + let (minion_prk, minion_pbk) = keygen(2048).unwrap(); + let state = state(&master_pbk, &minion_pbk); + let (opening, hello) = SecureBootstrapSession::open(&state, &minion_prk, &master_pbk).unwrap(); + + let mut hello = match hello { + SecureFrame::BootstrapHello(hello) => hello, + _ => panic!("expected bootstrap hello"), + }; + hello.binding.protocol_version = 99; + hello.supported_versions = vec![99, SECURE_PROTOCOL_VERSION]; + hello.binding_signature = STANDARD + .encode( + sign_data( + minion_prk.clone(), + &{ + let mut material = serde_json::to_vec(&hello.binding).unwrap(); + material.extend_from_slice(hello.client_ephemeral_pubkey.as_bytes()); + material.extend_from_slice(serde_json::to_string(&hello.supported_versions).unwrap().as_bytes()); + material.extend_from_slice(hello.key_id.as_deref().unwrap_or_default().as_bytes()); + material + }, + ) + .unwrap(), + ); + + let ack = match SecureBootstrapSession::accept( + &state, + &hello, + &master_prk, + &minion_pbk, + Some("sid-1".to_string()), + Some("kid-1".to_string()), + Some(SecureRotationMode::None), + ) + .unwrap() + .1 + { + SecureFrame::BootstrapAck(ack) => ack, + _ => panic!("expected bootstrap ack"), + }; + + assert_eq!(ack.binding.protocol_version, SECURE_PROTOCOL_VERSION); + assert_eq!(opening.verify_ack(&state, &ack, &master_pbk).unwrap().binding().protocol_version, SECURE_PROTOCOL_VERSION); +} + +#[test] +fn bootstrap_rejects_when_no_common_version_exists() { + let (master_prk, master_pbk) = keygen(2048).unwrap(); + let (minion_prk, minion_pbk) = keygen(2048).unwrap(); + let state = state(&master_pbk, &minion_pbk); + let (_, hello) = SecureBootstrapSession::open(&state, &minion_prk, &master_pbk).unwrap(); + + let mut hello = match hello { + SecureFrame::BootstrapHello(hello) => hello, + _ => panic!("expected bootstrap hello"), + }; + hello.binding.protocol_version = 99; + hello.supported_versions = vec![99]; + hello.binding_signature = STANDARD + .encode( + sign_data( + minion_prk.clone(), + &{ + let mut material = serde_json::to_vec(&hello.binding).unwrap(); + material.extend_from_slice(hello.client_ephemeral_pubkey.as_bytes()); + material.extend_from_slice(serde_json::to_string(&hello.supported_versions).unwrap().as_bytes()); + material.extend_from_slice(hello.key_id.as_deref().unwrap_or_default().as_bytes()); + material + }, + ) + .unwrap(), + ); + + let err = SecureBootstrapSession::accept(&state, &hello, &master_prk, &minion_pbk, None, Some("kid-1".to_string()), None).unwrap_err(); + assert!(err.to_string().contains("No common secure transport protocol version")); +} + +#[test] +fn bootstrap_hello_roundtrips_through_json() { + let (_, master_pbk) = keygen(2048).unwrap(); + let (minion_prk, minion_pbk) = keygen(2048).unwrap(); + let state = state(&master_pbk, &minion_pbk); + let (_, hello) = SecureBootstrapSession::open(&state, &minion_prk, &master_pbk).unwrap(); + + let parsed = serde_json::from_slice::(&serde_json::to_vec(&hello).unwrap()).unwrap(); + + assert!(matches!(parsed, SecureFrame::BootstrapHello(_))); +} + +#[test] +fn accept_rejects_wrong_registered_minion_key() { + let (master_prk, master_pbk) = keygen(2048).unwrap(); + let (minion_prk, minion_pbk) = keygen(2048).unwrap(); + let (_, wrong_minion_pbk) = keygen(2048).unwrap(); + let state = state(&master_pbk, &minion_pbk); + let (_, hello) = SecureBootstrapSession::open(&state, &minion_prk, &master_pbk).unwrap(); + + let err = SecureBootstrapSession::accept( + &state, + match &hello { + SecureFrame::BootstrapHello(hello) => hello, + _ => panic!("expected bootstrap hello"), + }, + &master_prk, + &wrong_minion_pbk, + Some("sid-1".to_string()), + Some("kid-1".to_string()), + Some(SecureRotationMode::None), + ) + .unwrap_err() + .to_string(); + + assert!(err.contains("fingerprint") || err.contains("signature")); +} + +#[test] +fn verify_ack_rejects_wrong_master_key() { + let (master_prk, master_pbk) = keygen(2048).unwrap(); + let (_, wrong_master_pbk) = keygen(2048).unwrap(); + let (minion_prk, minion_pbk) = keygen(2048).unwrap(); + let state = state(&master_pbk, &minion_pbk); + let (opening, hello) = SecureBootstrapSession::open(&state, &minion_prk, &master_pbk).unwrap(); + let ack = match SecureBootstrapSession::accept( + &state, + match &hello { + SecureFrame::BootstrapHello(hello) => hello, + _ => panic!("expected bootstrap hello"), + }, + &master_prk, + &minion_pbk, + Some("sid-1".to_string()), + Some("kid-1".to_string()), + Some(SecureRotationMode::None), + ) + .unwrap() + .1 + { + SecureFrame::BootstrapAck(ack) => ack, + _ => panic!("expected bootstrap ack"), + }; + + let err = opening.verify_ack(&state, &ack, &wrong_master_pbk).unwrap_err().to_string(); + assert!(err.contains("fingerprint") || err.contains("signature")); +} + +#[test] +fn verify_ack_rejects_invalid_master_ephemeral_key() { + let (master_prk, master_pbk) = keygen(2048).unwrap(); + let (minion_prk, minion_pbk) = keygen(2048).unwrap(); + let state = state(&master_pbk, &minion_pbk); + let (opening, hello) = SecureBootstrapSession::open(&state, &minion_prk, &master_pbk).unwrap(); + let mut ack = match SecureBootstrapSession::accept( + &state, + match &hello { + SecureFrame::BootstrapHello(hello) => hello, + _ => panic!("expected bootstrap hello"), + }, + &master_prk, + &minion_pbk, + Some("sid-1".to_string()), + Some("kid-1".to_string()), + Some(SecureRotationMode::None), + ) + .unwrap() + .1 + { + SecureFrame::BootstrapAck(ack) => ack, + _ => panic!("expected bootstrap ack"), + }; + ack.master_ephemeral_pubkey = STANDARD.encode([0u8; 8]); + + let err = opening.verify_ack(&state, &ack, &master_pbk).unwrap_err().to_string(); + assert!(err.contains("invalid size")); +} diff --git a/libsysinspect/src/transport/secure_channel.rs b/libsysinspect/src/transport/secure_channel.rs index 151e9004..dc4dcab5 100644 --- a/libsysinspect/src/transport/secure_channel.rs +++ b/libsysinspect/src/transport/secure_channel.rs @@ -45,6 +45,9 @@ impl SecureChannel { .to_string(); let mut digest = Sha256::new(); + digest.update(bootstrap.binding().connection_id.as_bytes()); + digest.update(bootstrap.binding().client_nonce.as_bytes()); + digest.update(bootstrap.binding().master_nonce.as_bytes()); digest.update(session_id.as_bytes()); digest.update(bootstrap.session_key().0); let hash = digest.finalize(); @@ -87,13 +90,14 @@ impl SecureChannel { } self.tx_counter = self.tx_counter.checked_add(1).ok_or_else(|| SysinspectError::ProtoError("Secure transmit counter overflow".to_string()))?; + let nonce = Self::nonce(self.role, self.tx_counter, &self.base_nonce); serde_json::to_vec(&SecureFrame::Data(SecureDataFrame { protocol_version: SECURE_PROTOCOL_VERSION, session_id: self.session_id.clone(), key_id: self.key_id.clone(), counter: self.tx_counter, - nonce: STANDARD.encode(Self::nonce(self.role, self.tx_counter, &self.base_nonce).0), - payload: STANDARD.encode(secretbox::seal(payload, &Self::nonce(self.role, self.tx_counter, &self.base_nonce), &self.key)), + nonce: STANDARD.encode(nonce.0), + payload: STANDARD.encode(secretbox::seal(payload, &nonce, &self.key)), })) .map_err(|err| SysinspectError::SerializationError(format!("Failed to encode secure data frame: {err}"))) } @@ -144,7 +148,7 @@ impl SecureChannel { if frame.counter != self.rx_counter.saturating_add(1) { return Err(SysinspectError::ProtoError(format!("Secure frame counter {} is out of sequence after {}", frame.counter, self.rx_counter))); } - let expected_nonce = Self::nonce(Self::peer_role(self.role), frame.counter, &self.base_nonce); + let expected_nonce = Self::nonce(Self::opposite_role(self.role), frame.counter, &self.base_nonce); if STANDARD.encode(expected_nonce.0) != frame.nonce { return Err(SysinspectError::ProtoError("Secure data frame nonce does not match the expected counter-derived nonce".to_string())); } @@ -161,12 +165,12 @@ impl SecureChannel { Ok(payload) } - /// Derive a deterministic nonce from the sender role, base_nonce and monotonic counter. + /// Derive a deterministic per-direction nonce from the base nonce and monotonic counter. fn nonce(role: SecurePeerRole, counter: u64, base_nonce: &[u8; secretbox::NONCEBYTES]) -> Nonce { let mut nonce = *base_nonce; nonce[0] ^= match role { - SecurePeerRole::Master => 1, - SecurePeerRole::Minion => 2, + SecurePeerRole::Master => 0x4d, + SecurePeerRole::Minion => 0x6d, }; let counter_bytes = counter.to_be_bytes(); for i in 0..8 { @@ -175,8 +179,7 @@ impl SecureChannel { Nonce(nonce) } - /// Return the opposite role used to validate the sender side of an incoming frame. - fn peer_role(role: SecurePeerRole) -> SecurePeerRole { + fn opposite_role(role: SecurePeerRole) -> SecurePeerRole { match role { SecurePeerRole::Master => SecurePeerRole::Minion, SecurePeerRole::Minion => SecurePeerRole::Master, diff --git a/libsysinspect/src/transport/secure_channel_ut.rs b/libsysinspect/src/transport/secure_channel_ut.rs index 1631cd8c..c3613fa2 100644 --- a/libsysinspect/src/transport/secure_channel_ut.rs +++ b/libsysinspect/src/transport/secure_channel_ut.rs @@ -7,7 +7,6 @@ use crate::rsa::keys::{get_fingerprint, keygen}; use chrono::Utc; use libsysproto::secure::{SECURE_PROTOCOL_VERSION, SecureFrame}; use rsa::RsaPublicKey; -use sodiumoxide::crypto::secretbox; fn state(master_pbk: &RsaPublicKey, minion_pbk: &RsaPublicKey) -> TransportPeerState { TransportPeerState { @@ -33,7 +32,7 @@ fn channels() -> (SecureChannel, SecureChannel) { let (minion_prk, minion_pbk) = keygen(2048).unwrap(); let state = state(&master_pbk, &minion_pbk); let (opening, hello) = SecureBootstrapSession::open(&state, &minion_prk, &master_pbk).unwrap(); - let ack = match SecureBootstrapSession::accept( + let accepted = SecureBootstrapSession::accept( &state, match &hello { SecureFrame::BootstrapHello(hello) => hello, @@ -45,27 +44,13 @@ fn channels() -> (SecureChannel, SecureChannel) { Some("kid-1".to_string()), None, ) - .unwrap() - .1 - { + .unwrap(); + let ack = match accepted.1 { SecureFrame::BootstrapAck(ack) => ack, _ => panic!("expected bootstrap ack"), }; let minion = opening.verify_ack(&state, &ack, &master_pbk).unwrap(); - let master = SecureBootstrapSession::accept( - &state, - match &hello { - SecureFrame::BootstrapHello(hello) => hello, - _ => panic!("expected bootstrap hello"), - }, - &master_prk, - &minion_pbk, - Some("sid-1".to_string()), - Some("kid-1".to_string()), - None, - ) - .unwrap() - .0; + let master = accepted.0; (SecureChannel::new(SecurePeerRole::Master, &master).unwrap(), SecureChannel::new(SecurePeerRole::Minion, &minion).unwrap()) } @@ -105,12 +90,10 @@ fn secure_channel_rejects_oversized_payloads() { } #[test] -fn secure_channel_first_frame_differs_across_reconnects_with_same_persisted_material() { +fn secure_channel_first_frame_differs_across_reconnects() { let (master_prk, master_pbk) = keygen(2048).unwrap(); let (minion_prk, minion_pbk) = keygen(2048).unwrap(); - let mut state = state(&master_pbk, &minion_pbk); - let material = secretbox::gen_key(); - state.upsert_key_with_material("kid-1", super::TransportKeyStatus::Active, Some(&material.0)); + let state = state(&master_pbk, &minion_pbk); let (opening_one, hello_one) = SecureBootstrapSession::open(&state, &minion_prk, &master_pbk).unwrap(); let ack_one = match SecureBootstrapSession::accept( @@ -159,3 +142,83 @@ fn secure_channel_first_frame_differs_across_reconnects_with_same_persisted_mate assert_ne!(frame_one, frame_two); } + +#[test] +fn secure_channel_accepts_consecutive_frames() { + let (mut master, mut minion) = channels(); + let first = minion.seal(&serde_json::json!({"n":1})).unwrap(); + let second = minion.seal(&serde_json::json!({"n":2})).unwrap(); + + let first_payload: serde_json::Value = master.open(&first).unwrap(); + let second_payload: serde_json::Value = master.open(&second).unwrap(); + + assert_eq!(first_payload["n"], 1); + assert_eq!(second_payload["n"], 2); +} + +#[test] +fn secure_channel_rejects_truncated_frame_bytes() { + let (mut master, mut minion) = channels(); + let mut frame = minion.seal(&serde_json::json!({"hello":"world"})).unwrap(); + frame.pop(); + + assert!(master.open::(&frame).is_err()); +} + +#[test] +fn secure_channel_rejects_tampered_session_id() { + let (mut master, mut minion) = channels(); + let frame = minion.seal(&serde_json::json!({"hello":"world"})).unwrap(); + let mut parsed = serde_json::from_slice::(&frame).unwrap(); + match &mut parsed { + SecureFrame::Data(data) => data.session_id = "wrong-session".to_string(), + _ => panic!("expected data frame"), + } + + assert!(master.open::(&serde_json::to_vec(&parsed).unwrap()).is_err()); +} + +#[test] +fn secure_channel_rejects_tampered_key_id() { + let (mut master, mut minion) = channels(); + let frame = minion.seal(&serde_json::json!({"hello":"world"})).unwrap(); + let mut parsed = serde_json::from_slice::(&frame).unwrap(); + match &mut parsed { + SecureFrame::Data(data) => data.key_id = "wrong-key".to_string(), + _ => panic!("expected data frame"), + } + + assert!(master.open::(&serde_json::to_vec(&parsed).unwrap()).is_err()); +} + +#[test] +fn secure_channel_rejects_tampered_nonce() { + let (mut master, mut minion) = channels(); + let frame = minion.seal(&serde_json::json!({"hello":"world"})).unwrap(); + let mut parsed = serde_json::from_slice::(&frame).unwrap(); + match &mut parsed { + SecureFrame::Data(data) => data.nonce = "AA==".to_string(), + _ => panic!("expected data frame"), + } + + assert!(master.open::(&serde_json::to_vec(&parsed).unwrap()).is_err()); +} + +#[test] +fn secure_channel_uses_distinct_first_frame_nonces_per_direction() { + let (mut master, mut minion) = channels(); + let minion_frame = minion.seal(&serde_json::json!({"from":"minion"})).unwrap(); + let master_frame = master.seal(&serde_json::json!({"from":"master"})).unwrap(); + + let minion_frame = serde_json::from_slice::(&minion_frame).unwrap(); + let master_frame = serde_json::from_slice::(&master_frame).unwrap(); + + match (minion_frame, master_frame) { + (SecureFrame::Data(minion_data), SecureFrame::Data(master_data)) => { + assert_eq!(minion_data.counter, 1); + assert_eq!(master_data.counter, 1); + assert_ne!(minion_data.nonce, master_data.nonce); + } + _ => panic!("expected data frames"), + } +} diff --git a/libsysinspect/tests/secure_transport_contract.rs b/libsysinspect/tests/secure_transport_contract.rs new file mode 100644 index 00000000..9fc10388 --- /dev/null +++ b/libsysinspect/tests/secure_transport_contract.rs @@ -0,0 +1,162 @@ +use chrono::{Duration as ChronoDuration, Utc}; +use libsysinspect::{ + rsa::{ + keys::{get_fingerprint, keygen}, + rotation::{RotationActor, RsaTransportRotator}, + }, + transport::{ + TransportKeyExchangeModel, TransportKeyStatus, TransportPeerState, TransportProvisioningMode, TransportRotationStatus, TransportStore, + secure_bootstrap::SecureBootstrapSession, + secure_channel::{SecureChannel, SecurePeerRole}, + }, +}; +use libsysproto::secure::{SECURE_PROTOCOL_VERSION, SecureFrame}; +use rsa::RsaPublicKey; + +fn state(master_pbk: &RsaPublicKey, minion_pbk: &RsaPublicKey) -> TransportPeerState { + TransportPeerState { + minion_id: "mid-1".to_string(), + master_rsa_fingerprint: get_fingerprint(master_pbk).unwrap(), + minion_rsa_fingerprint: get_fingerprint(minion_pbk).unwrap(), + protocol_version: SECURE_PROTOCOL_VERSION, + key_exchange: TransportKeyExchangeModel::EphemeralSessionKeys, + provisioning: TransportProvisioningMode::Automatic, + approved_at: Some(Utc::now()), + active_key_id: Some("kid-1".to_string()), + last_key_id: Some("kid-1".to_string()), + last_handshake_at: None, + rotation: TransportRotationStatus::Idle, + pending_rotation_context: None, + updated_at: Utc::now(), + keys: vec![], + } +} + +fn establish_channels(state: &TransportPeerState) -> (SecureChannel, SecureChannel) { + let (master_prk, master_pbk) = keygen(2048).unwrap(); + let (minion_prk, minion_pbk) = keygen(2048).unwrap(); + let rebound = TransportPeerState { master_rsa_fingerprint: get_fingerprint(&master_pbk).unwrap(), minion_rsa_fingerprint: get_fingerprint(&minion_pbk).unwrap(), ..state.clone() }; + let (opening, hello) = SecureBootstrapSession::open(&rebound, &minion_prk, &master_pbk).unwrap(); + let accepted = SecureBootstrapSession::accept( + &rebound, + match &hello { + SecureFrame::BootstrapHello(hello) => hello, + _ => panic!("expected bootstrap hello"), + }, + &master_prk, + &minion_pbk, + Some("sid-1".to_string()), + rebound.active_key_id.clone(), + None, + ) + .unwrap(); + let ack = match accepted.1 { + SecureFrame::BootstrapAck(ack) => ack, + _ => panic!("expected bootstrap ack"), + }; + let minion = opening.verify_ack(&rebound, &ack, &master_pbk).unwrap(); + let master = accepted.0; + + ( + SecureChannel::new(SecurePeerRole::Master, &master).unwrap(), + SecureChannel::new(SecurePeerRole::Minion, &minion).unwrap(), + ) +} + +#[test] +fn automatic_transport_state_establishes_secure_session_roundtrip() { + let (master_prk, master_pbk) = keygen(2048).unwrap(); + let (_, minion_pbk) = keygen(2048).unwrap(); + let state = state(&master_pbk, &minion_pbk); + let (mut master, mut minion) = establish_channels(&state); + + let frame = minion.seal(&serde_json::json!({"kind":"ping","value":1})).unwrap(); + let payload: serde_json::Value = master.open(&frame).unwrap(); + + assert_eq!(payload["kind"], "ping"); + assert_eq!(payload["value"], 1); + assert!(!master.session_id().is_empty()); + assert!(!minion.session_id().is_empty()); + drop(master_prk); +} + +#[test] +fn public_transport_api_rejects_replayed_frames() { + let (_, master_pbk) = keygen(2048).unwrap(); + let (_, minion_pbk) = keygen(2048).unwrap(); + let state = state(&master_pbk, &minion_pbk); + let (mut master, mut minion) = establish_channels(&state); + + let frame = minion.seal(&serde_json::json!({"kind":"ping"})).unwrap(); + let _: serde_json::Value = master.open(&frame).unwrap(); + + assert!(master.open::(&frame).is_err()); +} + +#[test] +fn rotated_transport_state_reconnects_with_new_key_id() { + let root = tempfile::tempdir().unwrap(); + let (master_prk, master_pbk) = keygen(2048).unwrap(); + let (_, minion_pbk) = keygen(2048).unwrap(); + let store = TransportStore::new(root.path().join("transport/master/state.json")).unwrap(); + let mut state = state(&master_pbk, &minion_pbk); + store.save(&state).unwrap(); + + let mut rotator = RsaTransportRotator::new( + RotationActor::Master, + store, + &state.minion_id, + &state.master_rsa_fingerprint, + &state.minion_rsa_fingerprint, + SECURE_PROTOCOL_VERSION, + ) + .unwrap(); + let plan = rotator.plan("manual"); + let signed = rotator.sign_plan(&plan, &master_prk).unwrap(); + let rollback = rotator + .execute_signed_intent_with_overlap(&signed, &RsaPublicKey::from(&master_prk), ChronoDuration::seconds(60)) + .unwrap(); + state = rotator.state().clone(); + + assert_eq!(state.rotation, TransportRotationStatus::Idle); + assert_eq!(state.active_key_id.as_deref(), Some(signed.intent().next_key_id())); + assert!(state.keys.iter().any(|record| record.status == TransportKeyStatus::Retiring)); + + let (mut master, mut minion) = establish_channels(&state); + let frame = minion.seal(&serde_json::json!({"kind":"after-rotation"})).unwrap(); + let payload: serde_json::Value = master.open(&frame).unwrap(); + assert_eq!(payload["kind"], "after-rotation"); + + rotator.rollback(&rollback).unwrap(); +} + +#[test] +fn bootstrap_wire_shape_roundtrips_through_json() { + let (master_prk, master_pbk) = keygen(2048).unwrap(); + let (minion_prk, minion_pbk) = keygen(2048).unwrap(); + let state = state(&master_pbk, &minion_pbk); + let (_, hello) = SecureBootstrapSession::open(&state, &minion_prk, &master_pbk).unwrap(); + let wire = serde_json::to_vec(&hello).unwrap(); + let parsed = serde_json::from_slice::(&wire).unwrap(); + + let ack = match SecureBootstrapSession::accept( + &state, + match &parsed { + SecureFrame::BootstrapHello(hello) => hello, + _ => panic!("expected bootstrap hello"), + }, + &master_prk, + &minion_pbk, + Some("sid-1".to_string()), + Some("kid-1".to_string()), + None, + ) + .unwrap() + .1 + { + SecureFrame::BootstrapAck(ack) => ack, + _ => panic!("expected bootstrap ack"), + }; + + assert_eq!(ack.key_id, "kid-1"); +} diff --git a/libsysproto/src/README.md b/libsysproto/src/README.md index 3317f449..29a62709 100644 --- a/libsysproto/src/README.md +++ b/libsysproto/src/README.md @@ -4,7 +4,7 @@ Protocol description about message exchange between master and a minion. ## Secure transport design -This document is the Phase 1 source of truth for the secure Master/Minion transport. +This document is the source of truth for the secure Master/Minion transport. The concrete shared protocol types live in `libsysproto::secure`. ### Transport goals @@ -60,10 +60,33 @@ Sent by the minion to begin a new secure session. Fields: - `binding`: initial `SecureSessionBinding` -- `session_key_cipher`: fresh symmetric session key encrypted to the master's registered RSA key -- `binding_signature`: minion RSA signature over the binding and raw session key +- `supported_versions`: secure transport protocol versions supported by the minion +- `client_ephemeral_pubkey`: minion ephemeral Curve25519 public key +- `binding_signature`: minion RSA signature over the binding and ephemeral public key - `key_id`: optional transport key identifier for reconnect or rotation continuity +Example: + +```json +{ + "kind": "bootstrap_hello", + "binding": { + "minion_id": "minion-a", + "minion_rsa_fingerprint": "minion-fp", + "master_rsa_fingerprint": "master-fp", + "protocol_version": 1, + "connection_id": "conn-1", + "client_nonce": "client-nonce", + "master_nonce": "", + "timestamp": 1734739200 + }, + "supported_versions": [1], + "client_ephemeral_pubkey": "", + "binding_signature": "", + "key_id": "trk-current" +} +``` + #### `bootstrap_ack` Sent by the master after validating the registered minion RSA identity and accepting the new secure session. @@ -74,7 +97,31 @@ Fields: - `session_id`: master-assigned secure session id - `key_id`: accepted transport key id - `rotation`: `none`, `rekey`, or `reregister` -- `binding_signature`: master RSA signature over the completed binding and accepted session id +- `master_ephemeral_pubkey`: master ephemeral Curve25519 public key +- `binding_signature`: master RSA signature over the completed binding, accepted session id, and master ephemeral public key + +Example: + +```json +{ + "kind": "bootstrap_ack", + "binding": { + "minion_id": "minion-a", + "minion_rsa_fingerprint": "minion-fp", + "master_rsa_fingerprint": "master-fp", + "protocol_version": 1, + "connection_id": "conn-1", + "client_nonce": "client-nonce", + "master_nonce": "master-nonce", + "timestamp": 1734739200 + }, + "session_id": "sid-1", + "key_id": "trk-current", + "rotation": "none", + "master_ephemeral_pubkey": "", + "binding_signature": "" +} +``` #### `bootstrap_diagnostic` @@ -86,6 +133,21 @@ Fields: - `message`: human-readable diagnostic - `failure`: retry and disconnect semantics +Example: + +```json +{ + "kind": "bootstrap_diagnostic", + "code": "replay_rejected", + "message": "Secure bootstrap replay rejected for minion-a", + "failure": { + "retryable": false, + "disconnect": true, + "rate_limit": true + } +} +``` + ### Encrypted steady-state frame After bootstrap succeeds, every Master/Minion frame must use `data`. @@ -102,6 +164,49 @@ Fields: - `nonce`: libsodium nonce for the sealed payload - `payload`: authenticated encrypted payload +Example: + +```json +{ + "kind": "data", + "protocol_version": 1, + "session_id": "sid-1", + "key_id": "trk-current", + "counter": 1, + "nonce": "", + "payload": "" +} +``` + +### Handshake sequence + +The exact bootstrap sequence is: + +1. the minion opens a TCP connection to the master +2. the minion sends `bootstrap_hello` +3. the master checks: + - registered minion identity + - RSA fingerprints against managed transport state + - supported protocol versions + - replay window and duplicate-session rules +4. the master sends either: + - `bootstrap_ack` on success + - `bootstrap_diagnostic` on failure +5. both sides derive the same short-lived session key from: + - the authenticated Curve25519 shared secret + - the full `SecureSessionBinding` + - both ephemeral public keys +6. every later frame on that TCP connection must use `data` + +### Steady-state frame rules + +For `data` frames: + +- `counter` must increase exactly by one in each direction +- `nonce` must match the counter-derived nonce expected for that session +- `session_id` and `key_id` must match the active secure channel +- replayed, stale, duplicated, or tampered frames are rejected + ### Failure semantics Unsupported or malformed peers must not silently fall back to plaintext behavior. @@ -121,7 +226,7 @@ Rules: - only one active secure session may exist per minion - reconnects must create a new connection id and fresh nonces - replay protection is per direction and tied to the session id and active key id -- RSA remains only the bootstrap and rotation trust anchor +- RSA authenticates the ephemeral bootstrap exchange and remains the rotation trust anchor - steady-state traffic uses libsodium-protected frames only ## Message Structure diff --git a/libsysproto/src/secure.rs b/libsysproto/src/secure.rs index 96c348e1..9d8d9c57 100644 --- a/libsysproto/src/secure.rs +++ b/libsysproto/src/secure.rs @@ -2,6 +2,8 @@ use serde::{Deserialize, Serialize}; /// Version of the planned secure Master/Minion transport protocol. pub const SECURE_PROTOCOL_VERSION: u16 = 1; +/// Explicit set of secure transport protocol versions currently supported locally. +pub const SECURE_SUPPORTED_PROTOCOL_VERSIONS: &[u16] = &[SECURE_PROTOCOL_VERSION]; /// Master/Minion transport goals fixed by Phase 1 decisions. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -127,9 +129,11 @@ impl SecureFailureSemantics { pub struct SecureBootstrapHello { /// Session binding input that must later be authenticated and echoed. pub binding: SecureSessionBinding, - /// Fresh symmetric session key encrypted to the master's registered RSA key. - pub session_key_cipher: String, - /// RSA signature over the bootstrap binding, wrapped session key ciphertext and negotiated key id. + /// Ordered secure transport protocol versions supported by the opening peer. + pub supported_versions: Vec, + /// Minion ephemeral Curve25519 public key for the bootstrap key exchange. + pub client_ephemeral_pubkey: String, + /// RSA signature over the bootstrap binding, ephemeral public key and negotiated key id. pub binding_signature: String, /// Optional transport key identifier when reconnecting or rotating. pub key_id: Option, @@ -146,7 +150,9 @@ pub struct SecureBootstrapAck { pub key_id: String, /// Rotation state communicated during handshake. pub rotation: SecureRotationMode, - /// RSA signature over the completed binding, accepted session identifier, activated key id and rotation state. + /// Master ephemeral Curve25519 public key for the bootstrap key exchange. + pub master_ephemeral_pubkey: String, + /// RSA signature over the completed binding, accepted session identifier, activated key id, rotation state and master ephemeral public key. pub binding_signature: String, } diff --git a/libsysproto/src/secure/secure_ut.rs b/libsysproto/src/secure/secure_ut.rs index ea266033..feb6c7e3 100644 --- a/libsysproto/src/secure/secure_ut.rs +++ b/libsysproto/src/secure/secure_ut.rs @@ -1,6 +1,6 @@ use super::{ - SECURE_PROTOCOL_VERSION, SecureBootstrapAck, SecureBootstrapDiagnostic, SecureBootstrapHello, SecureDataFrame, SecureDiagnosticCode, - SecureFailureSemantics, SecureFrame, SecureRotationMode, SecureSessionBinding, SecureTransportGoals, + SECURE_PROTOCOL_VERSION, SECURE_SUPPORTED_PROTOCOL_VERSIONS, SecureBootstrapAck, SecureBootstrapDiagnostic, SecureBootstrapHello, SecureDataFrame, + SecureDiagnosticCode, SecureFailureSemantics, SecureFrame, SecureRotationMode, SecureSessionBinding, SecureTransportGoals, }; fn binding() -> SecureSessionBinding { @@ -39,7 +39,8 @@ fn only_bootstrap_frames_may_stay_plaintext() { assert!( SecureFrame::BootstrapHello(SecureBootstrapHello { binding: binding(), - session_key_cipher: "cipher".to_string(), + supported_versions: SECURE_SUPPORTED_PROTOCOL_VERSIONS.to_vec(), + client_ephemeral_pubkey: "pubkey".to_string(), binding_signature: "sig".to_string(), key_id: None, }) @@ -51,6 +52,7 @@ fn only_bootstrap_frames_may_stay_plaintext() { session_id: "sid".to_string(), key_id: "kid".to_string(), rotation: SecureRotationMode::None, + master_ephemeral_pubkey: "pubkey".to_string(), binding_signature: "sig".to_string(), }) .is_plaintext_bootstrap() @@ -104,7 +106,8 @@ fn secure_frame_serde_uses_stable_kind_tags() { assert_eq!( serde_json::to_value(SecureFrame::BootstrapHello(SecureBootstrapHello { binding: binding(), - session_key_cipher: "cipher".to_string(), + supported_versions: SECURE_SUPPORTED_PROTOCOL_VERSIONS.to_vec(), + client_ephemeral_pubkey: "pubkey".to_string(), binding_signature: "sig".to_string(), key_id: Some("kid".to_string()), })) @@ -124,3 +127,48 @@ fn secure_frame_serde_uses_stable_kind_tags() { "data" ); } + +#[test] +fn secure_bootstrap_ack_roundtrips_through_json() { + let frame = SecureFrame::BootstrapAck(SecureBootstrapAck { + binding: binding(), + session_id: "sid".to_string(), + key_id: "kid".to_string(), + rotation: SecureRotationMode::Rekey, + master_ephemeral_pubkey: "pubkey".to_string(), + binding_signature: "sig".to_string(), + }); + + let parsed = serde_json::from_slice::(&serde_json::to_vec(&frame).unwrap()).unwrap(); + + assert_eq!(parsed, frame); +} + +#[test] +fn secure_diagnostic_roundtrips_through_json() { + let frame = SecureFrame::BootstrapDiagnostic(SecureBootstrapDiagnostic { + code: SecureDiagnosticCode::ReplayRejected, + message: "duplicate".to_string(), + failure: SecureFailureSemantics::diagnostic(false, true), + }); + + let parsed = serde_json::from_slice::(&serde_json::to_vec(&frame).unwrap()).unwrap(); + + assert_eq!(parsed, frame); +} + +#[test] +fn secure_data_frame_roundtrips_through_json() { + let frame = SecureFrame::Data(SecureDataFrame { + protocol_version: SECURE_PROTOCOL_VERSION, + session_id: "sid".to_string(), + key_id: "kid".to_string(), + counter: 7, + nonce: "nonce".to_string(), + payload: "payload".to_string(), + }); + + let parsed = serde_json::from_slice::(&serde_json::to_vec(&frame).unwrap()).unwrap(); + + assert_eq!(parsed, frame); +} diff --git a/libwebapi/Cargo.toml b/libwebapi/Cargo.toml index bc3f3338..66c7f5c9 100644 --- a/libwebapi/Cargo.toml +++ b/libwebapi/Cargo.toml @@ -4,12 +4,10 @@ version = "0.1.0" edition = "2024" [dependencies] -actix-web = "4.12.1" -hex = "0.4.3" -reqwest = { version = "0.12.28", features = ["blocking", "json"] } +actix-web = { version = "4.12.1", features = ["rustls-0_23"] } +reqwest = { version = "0.12.28", features = ["blocking", "json", "native-tls"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" -sodiumoxide = "0.2.7" tokio = { version = "1.49.0", features = ["full"] } once_cell = "1.21.3" log = "0.4.29" @@ -25,8 +23,6 @@ utoipa-swagger-ui = { version = "9.0.2", features = [ utoipa = { version = "5.4.0", features = ["actix_extras"] } pam = "0.8.0" uuid = "1.19.0" -base64 = "0.22.1" -rsa = "0.9.10" colored = "3.1.1" serde_yaml = "0.9.34" indexmap = { version = "2.13.0", features = ["serde"] } @@ -34,3 +30,6 @@ actix-files = "0.6.10" tempfile = "3.25.0" futures-util = "0.3.32" hostname = "0.4.1" +rustls = "0.23.36" +rustls-pemfile = "2.2.0" +x509-parser = "0.16.0" diff --git a/libwebapi/src/api/v1/minions.rs b/libwebapi/src/api/v1/minions.rs index 72504537..52269e72 100644 --- a/libwebapi/src/api/v1/minions.rs +++ b/libwebapi/src/api/v1/minions.rs @@ -1,6 +1,7 @@ pub use crate::api::v1::system::health_handler; use crate::{MasterInterfaceType, api::v1::TAG_MINIONS, sessions::get_session_store}; use actix_web::{ + HttpRequest, Result, post, web::{Data, Json}, }; @@ -10,7 +11,7 @@ use std::{collections::HashMap, fmt::Display}; use utoipa::ToSchema; #[derive(Deserialize, Serialize, ToSchema)] -pub struct QueryPayloadRequest { +pub struct QueryRequest { pub model: String, pub query: String, pub traits: String, @@ -18,49 +19,16 @@ pub struct QueryPayloadRequest { pub context: HashMap, } -impl QueryPayloadRequest { - pub fn to_query(&self) -> String { - format!( +impl QueryRequest { + pub fn to_query(&self) -> Result { + Ok(format!( "{};{};{};{};{}", self.model, self.query, self.traits, self.mid, self.context.iter().map(|(k, v)| format!("{k}:{v}")).collect::>().join(",") - ) - } -} - -#[derive(Deserialize, Serialize, ToSchema)] -pub struct QueryRequest { - pub sid: String, - pub model: String, - pub query: String, - pub traits: String, - pub mid: String, - pub context: HashMap, -} - -impl QueryRequest { - pub fn to_query_request(&self) -> Result { - if self.sid.trim().is_empty() { - return Err(SysinspectError::WebAPIError("Session ID cannot be empty".to_string())); - } - - let mut sessions = get_session_store().lock().unwrap(); - match sessions.uid(&self.sid) { - Some(_) => Ok(QueryPayloadRequest { - model: self.model.clone(), - query: self.query.clone(), - traits: self.traits.clone(), - mid: self.mid.clone(), - context: self.context.clone(), - }), - None => { - log::debug!("Session {} is missing or expired", self.sid); - Err(SysinspectError::WebAPIError("Invalid or expired session".to_string())) - } - } + )) } } #[derive(Serialize, ToSchema)] @@ -81,20 +49,54 @@ impl Display for QueryError { } } +pub(crate) fn authorize_request(req: &HttpRequest) -> Result { + let header = req + .headers() + .get(actix_web::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| SysinspectError::WebAPIError("Missing Authorization header".to_string()))?; + let token = header + .strip_prefix("Bearer ") + .ok_or_else(|| SysinspectError::WebAPIError("Authorization header must use Bearer token".to_string()))? + .trim(); + if token.is_empty() { + return Err(SysinspectError::WebAPIError("Bearer token cannot be empty".to_string())); + } + + let mut sessions = get_session_store().lock().unwrap(); + match sessions.uid(token) { + Some(uid) => { + sessions.ping(token); + Ok(uid) + } + None => Err(SysinspectError::WebAPIError("Invalid or expired bearer token".to_string())), + } +} + #[utoipa::path( post, path = "/api/v1/query", request_body = QueryRequest, tag = TAG_MINIONS, + security( + ("bearer_auth" = []) + ), responses( (status = 200, description = "Success", body = QueryResponse), - (status = 400, description = "Bad Request", body = QueryError) + (status = 400, description = "Bad Request", body = QueryError), + (status = 401, description = "Unauthorized", body = QueryError) ) )] #[post("/api/v1/query")] -async fn query_handler(master: Data, body: Json) -> Result> { +async fn query_handler(req: HttpRequest, master: Data, body: Json) -> Result> { + if let Err(e) = authorize_request(&req) { + use actix_web::http::StatusCode; + let err_body = Json(QueryError { status: "error".to_string(), error: e.to_string() }); + return Err(actix_web::error::InternalError::new(err_body, StatusCode::UNAUTHORIZED).into()); + } + let mut master = master.lock().await; - let qpr = match body.to_query_request() { + let query = match body.to_query() { Ok(q) => q, Err(e) => { use actix_web::http::StatusCode; @@ -103,27 +105,7 @@ async fn query_handler(master: Data, body: Json Ok(Json(QueryResponse { status: "success".to_string(), message: "Query executed successfully".to_string() })), - Err(err) => Ok(Json(QueryResponse { status: "error".to_string(), message: err.to_string() })), - } -} - -#[utoipa::path( - post, - path = "/api/v1/dev_query", - request_body = QueryPayloadRequest, - description = "Development endpoint for querying minions. FOR DEVELOPMENT AND DEBUGGING PURPOSES ONLY!", - tag = TAG_MINIONS, - responses( - (status = 200, description = "Success", body = QueryResponse), - (status = 400, description = "Bad Request", body = QueryError) - ) -)] -#[post("/api/v1/dev_query")] -async fn query_handler_dev(master: Data, body: Json) -> Result> { - let mut master = master.lock().await; - match master.query(body.to_query()).await { + match master.query(query).await { Ok(()) => Ok(Json(QueryResponse { status: "success".to_string(), message: "Query executed successfully".to_string() })), Err(err) => Ok(Json(QueryResponse { status: "error".to_string(), message: err.to_string() })), } diff --git a/libwebapi/src/api/v1/mod.rs b/libwebapi/src/api/v1/mod.rs index 74c00007..eeb7c708 100644 --- a/libwebapi/src/api/v1/mod.rs +++ b/libwebapi/src/api/v1/mod.rs @@ -1,6 +1,6 @@ pub use crate::api::v1::system::health_handler; use crate::api::v1::{ - minions::{QueryError, QueryPayloadRequest, QueryRequest, QueryResponse, query_handler, query_handler_dev}, + minions::{QueryError, QueryRequest, QueryResponse, query_handler}, model::{ModelNameResponse, model_descr_handler, model_names_handler}, store::{ StoreListQuery, StoreMetaResponse, StoreResolveQuery, store_blob_handler, store_list_handler, store_meta_handler, store_resolve_handler, @@ -9,7 +9,9 @@ use crate::api::v1::{ system::{AuthRequest, AuthResponse, HealthInfo, HealthResponse, authenticate_handler}, }; use actix_web::Scope; +use utoipa::Modify; use utoipa::OpenApi; +use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme}; use utoipa_swagger_ui::SwaggerUi; pub mod minions; @@ -24,6 +26,16 @@ pub static TAG_MINIONS: &str = "Minions"; pub static TAG_SYSTEM: &str = "System"; pub static TAG_MODELS: &str = "Models"; +struct SecurityAddon; + +impl Modify for SecurityAddon { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + if let Some(components) = openapi.components.as_mut() { + components.add_security_scheme("bearer_auth", SecurityScheme::Http(HttpBuilder::new().scheme(HttpAuthScheme::Bearer).build())); + } + } +} + /// API Version 1 implementation pub struct V1 { dev_mode: bool, @@ -37,8 +49,7 @@ impl V1 { impl super::ApiVersion for V1 { fn load(&self, scope: Scope) -> Scope { - let mut scope = scope - // Available services + scope .service(query_handler) .service(health_handler) .service(authenticate_handler) @@ -53,20 +64,13 @@ impl super::ApiVersion for V1 { SwaggerUi::new("/doc/{_:.*}").url("/api-doc/openapi.json", ApiDocDev::openapi()) } else { SwaggerUi::new("/doc/{_:.*}").url("/api-doc/openapi.json", ApiDoc::openapi()) - }); - - if self.dev_mode { - scope = scope.service(query_handler_dev); - } - - scope + }) } } #[derive(OpenApi)] #[openapi(paths( crate::api::v1::minions::query_handler, - crate::api::v1::minions::query_handler_dev, crate::api::v1::system::health_handler, crate::api::v1::system::authenticate_handler, crate::api::v1::model::model_names_handler, @@ -77,16 +81,16 @@ impl super::ApiVersion for V1 { crate::api::v1::store::store_resolve_handler, crate::api::v1::store::store_list_handler, ), - components(schemas(QueryRequest, QueryResponse, QueryError, QueryPayloadRequest, + components(schemas(QueryRequest, QueryResponse, QueryError, HealthInfo, HealthResponse, AuthRequest, AuthResponse, ModelNameResponse, StoreMetaResponse, StoreResolveQuery, StoreListQuery)), +modifiers(&SecurityAddon), info(title = "SysInspect API", version = API_VERSION, description = "SysInspect Web API for interacting with the master interface."))] pub struct ApiDoc; #[derive(OpenApi)] #[openapi(paths( crate::api::v1::minions::query_handler, - crate::api::v1::minions::query_handler_dev, crate::api::v1::system::health_handler, crate::api::v1::system::authenticate_handler, crate::api::v1::model::model_names_handler, @@ -97,8 +101,9 @@ pub struct ApiDoc; crate::api::v1::store::store_resolve_handler, crate::api::v1::store::store_list_handler, ), - components(schemas(QueryRequest, QueryResponse, QueryError, QueryPayloadRequest, + components(schemas(QueryRequest, QueryResponse, QueryError, HealthInfo, HealthResponse, AuthRequest, AuthResponse, ModelNameResponse, StoreMetaResponse, StoreResolveQuery, StoreListQuery)), +modifiers(&SecurityAddon), info(title = "SysInspect API", version = API_VERSION, description = "SysInspect Web API for interacting with the master interface."))] pub struct ApiDocDev; diff --git a/libwebapi/src/api/v1/model.rs b/libwebapi/src/api/v1/model.rs index 76de0b7f..04e0f9c8 100644 --- a/libwebapi/src/api/v1/model.rs +++ b/libwebapi/src/api/v1/model.rs @@ -1,7 +1,7 @@ -use crate::{MasterInterfaceType, api::v1::TAG_MODELS}; +use crate::{MasterInterfaceType, api::v1::{TAG_MODELS, minions::authorize_request}}; use actix_web::{ - HttpResponse, Result, get, - web::{Data, Json, Query}, + HttpRequest, HttpResponse, Result, get, + web::{Data, Query}, }; use indexmap::IndexMap; use libcommon::SysinspectError; @@ -92,15 +92,22 @@ pub struct ModelNameResponse { tag = TAG_MODELS, operation_id = "listModels", description = "Lists all available models in the SysInspect system. Each model includes details such as its name, description, version, maintainer, and statistics about its entities, actions, constraints, and events.", + security( + ("bearer_auth" = []) + ), responses( - (status = 200, description = "List of available models", body = ModelNameResponse) + (status = 200, description = "List of available models", body = ModelNameResponse), + (status = 401, description = "Unauthorized", body = ModelResponseError) ) )] #[allow(unused)] #[get("/api/v1/model/names")] -pub async fn model_names_handler(master: Data) -> Json { +pub async fn model_names_handler(req: HttpRequest, master: Data) -> Result { + if let Err(err) = authorize_request(&req) { + return Ok(HttpResponse::Unauthorized().json(ModelResponseError { error: err.to_string() })); + } let mut master = master.lock().await; - Json(ModelNameResponse { models: master.cfg().await.fileserver_models().to_owned() }) + Ok(HttpResponse::Ok().json(ModelNameResponse { models: master.cfg().await.fileserver_models().to_owned() })) } #[utoipa::path( get, @@ -108,16 +115,26 @@ pub async fn model_names_handler(master: Data) -> Json, query: Query>) -> Result { +pub async fn model_descr_handler(req: HttpRequest, master: Data, query: Query>) -> Result { + if let Err(err) = authorize_request(&req) { + return Ok(HttpResponse::Unauthorized().json(ModelResponseError { error: err.to_string() })); + } let mid = query.get("name").cloned().unwrap_or_default(); // Model Id if mid.is_empty() { return Ok(HttpResponse::BadRequest().json(ModelResponseError { error: "Missing 'name' query parameter".to_string() })); diff --git a/libwebapi/src/api/v1/store.rs b/libwebapi/src/api/v1/store.rs index 96ff1bb6..3a9b4f17 100644 --- a/libwebapi/src/api/v1/store.rs +++ b/libwebapi/src/api/v1/store.rs @@ -1,9 +1,9 @@ use std::path::{Path, PathBuf}; -use crate::MasterInterfaceType; +use crate::{MasterInterfaceType, api::v1::minions::authorize_request}; use actix_files::NamedFile; use actix_web::Result as ActixResult; -use actix_web::{HttpResponse, Responder, get, post, web}; +use actix_web::{HttpRequest, HttpResponse, Responder, get, post, web}; use futures_util::StreamExt; use libdatastore::resources::DataItemMeta; use serde::{Deserialize, Serialize}; @@ -30,6 +30,20 @@ pub struct StoreListQuery { pub limit: Option, } +#[derive(Debug, Serialize, ToSchema)] +pub struct StoreErrorResponse { + pub error: String, +} + +fn unauthorized_store_error(err: libcommon::SysinspectError) -> actix_web::Error { + let msg = err.to_string(); + actix_web::error::InternalError::from_response( + err, + HttpResponse::Unauthorized().json(StoreErrorResponse { error: msg }), + ) + .into() +} + /// Get a list of all meta files within the datastore. fn get_meta_files(root: &Path, out: &mut Vec) -> std::io::Result<()> { if !root.exists() { @@ -55,17 +69,24 @@ fn get_meta_files(root: &Path, out: &mut Vec) -> std::io::Result<()> { get, path = "/store/{sha256}", tag = "Datastore", + security( + ("bearer_auth" = []) + ), params( ("sha256" = String, Path, description = "SHA256 of the stored object") ), responses( (status = 200, description = "Metadata for object", body = StoreMetaResponse), + (status = 401, description = "Unauthorized", body = StoreErrorResponse), (status = 404, description = "Not found"), (status = 500, description = "Datastore error") ) )] #[get("/store/{sha256:[0-9a-fA-F]{64}}")] -pub async fn store_meta_handler(master: web::Data, sha256: web::Path) -> impl Responder { +pub async fn store_meta_handler(req: HttpRequest, master: web::Data, sha256: web::Path) -> impl Responder { + if let Err(err) = authorize_request(&req) { + return HttpResponse::Unauthorized().json(StoreErrorResponse { error: err.to_string() }); + } let ds = { let m = master.lock().await; m.datastore().await @@ -91,17 +112,22 @@ pub async fn store_meta_handler(master: web::Data, sha256: get, path = "/store/{sha256}/blob", tag = "Datastore", + security( + ("bearer_auth" = []) + ), params( ("sha256" = String, Path, description = "SHA256 of the stored object") ), responses( + (status = 401, description = "Unauthorized", body = StoreErrorResponse), (status = 200, description = "Binary blob"), (status = 404, description = "Not found"), (status = 500, description = "Datastore error") ) )] #[get("/store/{sha256:[0-9a-fA-F]{64}}/blob")] -pub async fn store_blob_handler(master: web::Data, sha256: web::Path) -> ActixResult { +pub async fn store_blob_handler(req: HttpRequest, master: web::Data, sha256: web::Path) -> ActixResult { + authorize_request(&req).map_err(unauthorized_store_error)?; let ds = { let m = master.lock().await; m.datastore().await @@ -121,12 +147,16 @@ pub async fn store_blob_handler(master: web::Data, sha256: post, path = "/store", tag = "Datastore", + security( + ("bearer_auth" = []) + ), request_body( content = Vec, content_type = "application/octet-stream", description = "Raw bytes to store" ), responses( + (status = 401, description = "Unauthorized", body = StoreErrorResponse), (status = 200, description = "Stored successfully", body = StoreMetaResponse), (status = 413, description = "Payload too large"), (status = 500, description = "Datastore error") @@ -134,6 +164,9 @@ pub async fn store_blob_handler(master: web::Data, sha256: )] #[post("/store")] pub async fn store_upload_handler(req: actix_web::HttpRequest, master: web::Data, mut payload: web::Payload) -> impl Responder { + if let Err(err) = authorize_request(&req) { + return HttpResponse::Unauthorized().json(StoreErrorResponse { error: err.to_string() }); + } // full path goes into fname (as you demanded) let origin = req.headers().get("X-Filename").and_then(|v| v.to_str().ok()).map(|s| s.to_string()); @@ -223,17 +256,24 @@ pub async fn store_upload_handler(req: actix_web::HttpRequest, master: web::Data get, path = "/store/resolve", tag = "Datastore", + security( + ("bearer_auth" = []) + ), params( ("fname" = String, Query, description = "Full path stored in metadata (meta.fname)") ), responses( (status = 200, description = "Resolved metadata", body = StoreMetaResponse), + (status = 401, description = "Unauthorized", body = StoreErrorResponse), (status = 404, description = "Not found"), (status = 500, description = "Error") ) )] #[get("/store/resolve")] -pub async fn store_resolve_handler(master: web::Data, q: web::Query) -> impl Responder { +pub async fn store_resolve_handler(req: HttpRequest, master: web::Data, q: web::Query) -> impl Responder { + if let Err(err) = authorize_request(&req) { + return HttpResponse::Unauthorized().json(StoreErrorResponse { error: err.to_string() }); + } let (root, want) = { let m = master.lock().await; (m.cfg().await.datastore_path(), q.fname.clone()) @@ -297,17 +337,24 @@ pub async fn store_resolve_handler(master: web::Data, q: we get, path = "/store/list", tag = "Datastore", + security( + ("bearer_auth" = []) + ), params( ("prefix" = Option, Query, description = "Only return items where meta.fname starts with this prefix"), ("limit" = Option, Query, description = "Max items to return (default 200)") ), responses( (status = 200, description = "List of metadata", body = Vec), + (status = 401, description = "Unauthorized", body = StoreErrorResponse), (status = 500, description = "Error") ) )] #[get("/store/list")] -pub async fn store_list_handler(master: web::Data, q: web::Query) -> impl Responder { +pub async fn store_list_handler(req: HttpRequest, master: web::Data, q: web::Query) -> impl Responder { + if let Err(err) = authorize_request(&req) { + return HttpResponse::Unauthorized().json(StoreErrorResponse { error: err.to_string() }); + } let (root, prefix, limit) = { let m = master.lock().await; (m.cfg().await.datastore_path(), q.prefix.clone(), q.limit.unwrap_or(200).min(5000)) diff --git a/libwebapi/src/api/v1/system.rs b/libwebapi/src/api/v1/system.rs index 87fef617..4c5a2b4f 100644 --- a/libwebapi/src/api/v1/system.rs +++ b/libwebapi/src/api/v1/system.rs @@ -75,38 +75,29 @@ impl AuthRequest { #[derive(ToSchema, Deserialize, Serialize)] pub struct AuthResponse { pub status: String, - pub sid: String, + pub access_token: String, + pub token_type: String, pub error: String, } impl AuthResponse { pub(crate) fn error(error: &str) -> Self { - AuthResponse { status: "error".into(), sid: String::new(), error: error.into() } + AuthResponse { status: "error".into(), access_token: String::new(), token_type: String::new(), error: error.into() } } } #[utoipa::path( post, path = "/api/v1/authenticate", - request_body = AuthRequest, + request_body = AuthRequest, responses( - (status = 200, description = "Authentication successful. Returns a plain session identifier.", - body = AuthResponse, example = json!({"status": "authenticated", "sid": "session-id", "error": ""})), + (status = 200, description = "Authentication successful. Returns a bearer token.", + body = AuthResponse, example = json!({"status": "authenticated", "access_token": "session-token", "token_type": "Bearer", "error": ""})), (status = 400, description = "Bad Request. Returned if payload is missing, invalid, or credentials are incorrect.", - body = AuthResponse, example = json!({"status": "error", "sid": "", "error": "Invalid payload"}))), + body = AuthResponse, example = json!({"status": "error", "access_token": "", "token_type": "", "error": "Invalid payload"}))), tag = TAG_SYSTEM, operation_id = "authenticateUser", - description = - "Authenticates a user using configured authentication method. The payload \ - is a plain JSON object with username and password fields as follows:\n\n\ - ```json\n\ - {\n\ - \"username\": \"darth_vader\",\n\ - \"password\": \"I am your father\"\n\ - }\n\ - ```\n\n\ - If `api.devmode` is enabled, the handler returns a static token without \ - performing authentication.", + description = "Authenticates a user using configured authentication method and returns a bearer token for subsequent HTTPS JSON requests.", )] #[post("/api/v1/authenticate")] pub async fn authenticate_handler(master: web::Data, body: web::Json) -> impl Responder { @@ -114,7 +105,15 @@ pub async fn authenticate_handler(master: web::Data, body: let cfg = master.cfg().await; if cfg.api_devmode() { log::warn!("Web API development auth bypass is enabled, returning static token."); - return HttpResponse::Ok().json(AuthResponse { status: "authenticated".into(), sid: "dev-token".into(), error: String::new() }); + return match get_session_store().lock().unwrap().open_with_sid("dev".into(), "dev-token".into()) { + Ok(token) => HttpResponse::Ok().json(AuthResponse { + status: "authenticated".into(), + access_token: token, + token_type: "Bearer".into(), + error: String::new(), + }), + Err(err) => HttpResponse::BadRequest().json(AuthResponse::error(&format!("Session error: {err}"))), + }; } if body.username.trim().is_empty() || body.password.trim().is_empty() { @@ -123,7 +122,12 @@ pub async fn authenticate_handler(master: web::Data, body: if cfg.api_auth() == Pam { match AuthRequest::pam_auth(body.username.clone(), body.password.clone()) { - Ok(sid) => HttpResponse::Ok().json(AuthResponse { status: "authenticated".into(), sid, error: String::new() }), + Ok(token) => HttpResponse::Ok().json(AuthResponse { + status: "authenticated".into(), + access_token: token, + token_type: "Bearer".into(), + error: String::new(), + }), Err(err) => HttpResponse::BadRequest().json(AuthResponse::error(&err)), } } else { diff --git a/libwebapi/src/lib.rs b/libwebapi/src/lib.rs index 6572046f..efe8a6e1 100644 --- a/libwebapi/src/lib.rs +++ b/libwebapi/src/lib.rs @@ -4,10 +4,16 @@ use colored::Colorize; use libcommon::SysinspectError; use libdatastore::resources::DataStorage; use libsysinspect::cfg::mmconf::MasterConfig; -use std::{sync::Arc, thread}; +use rustls::ServerConfig; +use rustls::RootCertStore; +use rustls::server::WebPkiClientVerifier; +use std::{fs::File, io::BufReader, sync::Arc, thread}; use tokio::sync::Mutex; +use x509_parser::prelude::parse_x509_certificate; pub mod api; +#[cfg(test)] +mod lib_ut; pub mod pamauth; pub mod sessions; @@ -20,6 +26,7 @@ pub trait MasterInterface: Send + Sync { pub type MasterInterfaceType = Arc>; +/// Determines the advertised API host for the Web API based on the bind address. fn advertised_api_host(bind_addr: &str) -> String { match bind_addr { "0.0.0.0" | "::" | "[::]" => hostname::get() @@ -33,16 +40,141 @@ fn advertised_api_host(bind_addr: &str) -> String { } } -fn advertised_doc_url(bind_addr: &str, bind_port: u32) -> String { - format!("http://{}:{bind_port}/doc/", advertised_api_host(bind_addr)) +/// Constructs the advertised documentation URL for the Web API based on the bind address, port, and TLS configuration. +pub(crate) fn advertised_doc_url(bind_addr: &str, bind_port: u32, tls_enabled: bool) -> String { + let scheme = if tls_enabled { "https" } else { "http" }; + format!("{scheme}://{}:{bind_port}/doc/", advertised_api_host(bind_addr)) } -pub fn start_webapi(cfg: MasterConfig, master: MasterInterfaceType) -> Result<(), SysinspectError> { +/// Returns a user-friendly error message about TLS setup for WebAPI, pointing to the relevant documentation section. +pub(crate) fn tls_setup_err_message() -> String { + format!( + "TLS is not setup for WebAPI. For more information, see Documentation chapter \"{}\", section \"{}\".", + "Configuration".bright_yellow(), + "api.tls.enabled".bright_yellow() + ) +} + +/// Returns a user-friendly warning message about using a self-signed TLS certificate for WebAPI, pointing to the relevant configuration option. +pub(crate) fn tls_self_signed_warning_message() -> String { + format!( + "Embedded Web API is using a {} TLS certificate because {} is set to {}. Clients must explicitly trust this certificate.", + "self-signed".bright_red(), + "api.tls.allow-insecure".bright_yellow(), + "true".bright_yellow() + ) +} + +fn cert_appears_self_signed(cert_der: &[u8]) -> Result { + let (_, cert) = parse_x509_certificate(cert_der) + .map_err(|err| SysinspectError::ConfigError(format!("Unable to parse Web API TLS certificate for trust checks: {err}")))?; + Ok(cert.tbs_certificate.subject == cert.tbs_certificate.issuer) +} + +/// Loads the TLS server configuration for the Web API from the provided MasterConfig. +/// This includes reading the certificate and private key files, and optionally +/// the CA file for client certificate authentication. +/// Returns a ServerConfig on success, or a SysinspectError with a user-friendly message on failure. +fn load_tls_server_config(cfg: &MasterConfig) -> Result { + let cert_path = cfg + .api_tls_cert_file() + .ok_or_else(|| SysinspectError::ConfigError("Web API TLS is enabled, but api.tls.cert-file is not configured".to_string()))?; + let key_path = cfg + .api_tls_key_file() + .ok_or_else(|| SysinspectError::ConfigError("Web API TLS is enabled, but api.tls.key-file is not configured".to_string()))?; + + let mut cert_reader = BufReader::new( + File::open(&cert_path) + .map_err(|err| SysinspectError::ConfigError(format!("Unable to open Web API TLS certificate file {}: {err}", cert_path.display())))?, + ); + let certs = rustls_pemfile::certs(&mut cert_reader) + .collect::, _>>() + .map_err(|err| SysinspectError::ConfigError(format!("Unable to read Web API TLS certificate file {}: {err}", cert_path.display())))?; + if certs.is_empty() { + return Err(SysinspectError::ConfigError(format!( + "Web API TLS certificate file {} does not contain any PEM certificates", + cert_path.display() + ))); + } + if cert_appears_self_signed(certs[0].as_ref())? && !cfg.api_tls_allow_insecure() { + return Err(SysinspectError::ConfigError(format!( + "Web API TLS certificate file {} appears to be self-signed. Set api.tls.allow-insecure=true only when you intentionally want to allow this setup.", + cert_path.display() + ))); + } + + let mut key_reader = BufReader::new( + File::open(&key_path) + .map_err(|err| SysinspectError::ConfigError(format!("Unable to open Web API TLS private key file {}: {err}", key_path.display())))?, + ); + let private_key = rustls_pemfile::private_key(&mut key_reader) + .map_err(|err| SysinspectError::ConfigError(format!("Unable to read Web API TLS private key file {}: {err}", key_path.display())))? + .ok_or_else(|| { + SysinspectError::ConfigError(format!("Web API TLS private key file {} does not contain a supported PEM private key", key_path.display())) + })?; + + let builder = if let Some(ca_path) = cfg.api_tls_ca_file() { + let mut ca_reader = BufReader::new( + File::open(&ca_path) + .map_err(|err| SysinspectError::ConfigError(format!("Unable to open Web API TLS CA file {}: {err}", ca_path.display())))?, + ); + let ca_certs = rustls_pemfile::certs(&mut ca_reader) + .collect::, _>>() + .map_err(|err| SysinspectError::ConfigError(format!("Unable to read Web API TLS CA file {}: {err}", ca_path.display())))?; + if ca_certs.is_empty() { + return Err(SysinspectError::ConfigError(format!("Web API TLS CA file {} does not contain any PEM certificates", ca_path.display()))); + } + let mut roots = RootCertStore::empty(); + for ca_cert in ca_certs { + roots + .add(ca_cert) + .map_err(|err| SysinspectError::ConfigError(format!("Unable to use Web API TLS CA file {}: {err}", ca_path.display())))?; + } + + let verifier = WebPkiClientVerifier::builder(Arc::new(roots)) + .build() + .map_err(|err| SysinspectError::ConfigError(format!("Invalid Web API TLS CA verifier configuration: {err}")))?; + ServerConfig::builder().with_client_cert_verifier(verifier) + } else { + ServerConfig::builder().with_no_client_auth() + }; + + builder + .with_single_cert(certs, private_key) + .map_err(|err| SysinspectError::ConfigError(format!("Invalid Web API TLS certificate/private key pair: {err}"))) +} + +fn tls_paths_summary(cfg: &MasterConfig) -> String { + format!( + "cert={}, key={}, ca={}", + cfg.api_tls_cert_file().map(|p| p.display().to_string()).unwrap_or_else(|| "".to_string()), + cfg.api_tls_key_file().map(|p| p.display().to_string()).unwrap_or_else(|| "".to_string()), + cfg.api_tls_ca_file().map(|p| p.display().to_string()).unwrap_or_else(|| "".to_string()) + ) +} + +/// Starts the embedded Web API server in a new thread, using the provided MasterConfig and MasterInterface. +pub fn start_embedded_webapi(cfg: MasterConfig, master: MasterInterfaceType) -> Result<(), SysinspectError> { if !cfg.api_enabled() { - log::info!("Web API disabled."); + log::info!("Embedded Web API disabled."); + return Ok(()); + } + + if !cfg.api_tls_enabled() { + log::error!("{}", tls_setup_err_message()); return Ok(()); } + let tls_config = match load_tls_server_config(&cfg) { + Ok(tls_config) => tls_config, + Err(err) => { + log::error!("{}", tls_setup_err_message()); + log::error!("Embedded Web API TLS setup error: {err}"); + log::error!("Embedded Web API TLS paths: {}", tls_paths_summary(&cfg)); + return Ok(()); + } + }; + let ccfg = cfg.clone(); let cmaster = master.clone(); @@ -56,22 +188,23 @@ pub fn start_webapi(cfg: MasterConfig, master: MasterInterfaceType) -> Result<() _ => ApiVersions::V1, }; - log::info!("Starting Web API at {}", listen_addr.bright_yellow()); - log::info!("Web API enabled. Swagger UI available at {}", advertised_doc_url(&bind_addr, bind_port)); - + log::info!("Starting embedded Web API inside sysmaster at {} over {}", listen_addr.bright_yellow(), "HTTPS/TLS"); + log::info!("Embedded Web API enabled. Swagger UI available at {}", advertised_doc_url(&bind_addr, bind_port, true).bright_yellow()); + if ccfg.api_tls_allow_insecure() { + log::warn!("{}", tls_self_signed_warning_message()); + } actix_web::rt::System::new().block_on(async move { - HttpServer::new(move || { + let server = HttpServer::new(move || { let mut scope = web::scope(""); if let Some(ver) = api::get(devmode, version) { scope = ver.load(scope); } App::new().app_data(web::Data::new(cmaster.clone())).service(scope) - }) - .bind((bind_addr.as_str(), bind_port as u16)) - .map_err(SysinspectError::from)? - .run() - .await - .map_err(SysinspectError::from) + }); + + let server = server.bind_rustls_0_23((bind_addr.as_str(), bind_port as u16), tls_config).map_err(SysinspectError::from)?; + + server.run().await.map_err(SysinspectError::from) }) }); diff --git a/libwebapi/src/lib_ut.rs b/libwebapi/src/lib_ut.rs new file mode 100644 index 00000000..71bf561e --- /dev/null +++ b/libwebapi/src/lib_ut.rs @@ -0,0 +1,155 @@ +use super::{advertised_doc_url, load_tls_server_config, tls_paths_summary, tls_self_signed_warning_message, tls_setup_err_message}; +use libsysinspect::cfg::mmconf::MasterConfig; +use std::{fs, path::Path, path::PathBuf}; + +const CERT_PEM: &str = include_str!("../tests/data/sysmaster-dev.crt"); +const KEY_PEM: &str = include_str!("../tests/data/sysmaster-dev.key"); + +fn write_cfg(root: &Path, extra: &str) -> MasterConfig { + let cfg_path = root.join("sysinspect.conf"); + fs::write( + &cfg_path, + format!( + "config:\n master:\n fileserver.models: []\n api.bind.ip: 127.0.0.1\n api.bind.port: 4202\n{extra}" + ), + ) + .unwrap(); + MasterConfig::new(cfg_path).unwrap() +} + +fn write_tls_fixture(root: &Path) -> (PathBuf, PathBuf) { + let cert = root.join("sysmaster-dev.crt"); + let key = root.join("sysmaster-dev.key"); + fs::write(&cert, CERT_PEM).unwrap(); + fs::write(&key, KEY_PEM).unwrap(); + (cert, key) +} + +#[test] +fn advertised_doc_url_uses_http_without_tls() { + assert_eq!(advertised_doc_url("127.0.0.1", 4202, false), "http://127.0.0.1:4202/doc/"); +} + +#[test] +fn advertised_doc_url_uses_https_with_tls() { + assert_eq!(advertised_doc_url("127.0.0.1", 4202, true), "https://127.0.0.1:4202/doc/"); +} + +#[test] +fn tls_not_setup_error_points_to_real_docs_section() { + assert_eq!( + tls_setup_err_message(), + "TLS is not setup for WebAPI. For more information, see Documentation chapter \"Configuration\", section \"api.tls.enabled\"." + ); +} + +#[test] +fn load_tls_server_config_accepts_valid_certificate_pair() { + let root = tempfile::tempdir().unwrap(); + let (cert, key) = write_tls_fixture(root.path()); + let cfg = write_cfg( + root.path(), + &format!( + " api.tls.enabled: true\n api.tls.cert-file: {}\n api.tls.key-file: {}\n api.tls.allow-insecure: true\n", + cert.display(), + key.display() + ), + ); + + assert!(load_tls_server_config(&cfg).is_ok()); +} + +#[test] +fn load_tls_server_config_accepts_valid_ca_bundle_for_client_auth() { + let root = tempfile::tempdir().unwrap(); + let (cert, key) = write_tls_fixture(root.path()); + let cfg = write_cfg( + root.path(), + &format!( + " api.tls.enabled: true\n api.tls.cert-file: {}\n api.tls.key-file: {}\n api.tls.ca-file: {}\n api.tls.allow-insecure: true\n", + cert.display(), + key.display(), + cert.display() + ), + ); + + assert!(load_tls_server_config(&cfg).is_ok()); +} + +#[test] +fn load_tls_server_config_rejects_self_signed_certificate_without_allow_insecure() { + let root = tempfile::tempdir().unwrap(); + let (cert, key) = write_tls_fixture(root.path()); + let cfg = write_cfg( + root.path(), + &format!( + " api.tls.enabled: true\n api.tls.cert-file: {}\n api.tls.key-file: {}\n", + cert.display(), + key.display() + ), + ); + + let err = load_tls_server_config(&cfg).unwrap_err().to_string(); + assert!(err.contains("self-signed")); + assert!(err.contains("api.tls.allow-insecure")); +} + +#[test] +fn load_tls_server_config_rejects_missing_private_key() { + let root = tempfile::tempdir().unwrap(); + let (cert, _) = write_tls_fixture(root.path()); + let cfg = write_cfg( + root.path(), + &format!(" api.tls.enabled: true\n api.tls.cert-file: {}\n", cert.display()), + ); + + let err = load_tls_server_config(&cfg).unwrap_err().to_string(); + assert!(err.contains("api.tls.key-file")); +} + +#[test] +fn load_tls_server_config_rejects_invalid_ca_bundle() { + let root = tempfile::tempdir().unwrap(); + let (cert, key) = write_tls_fixture(root.path()); + let ca = root.path().join("invalid-ca.pem"); + fs::write(&ca, "not a pem").unwrap(); + let cfg = write_cfg( + root.path(), + &format!( + " api.tls.enabled: true\n api.tls.cert-file: {}\n api.tls.key-file: {}\n api.tls.ca-file: {}\n api.tls.allow-insecure: true\n", + cert.display(), + key.display(), + ca.display() + ), + ); + + let err = load_tls_server_config(&cfg).unwrap_err().to_string(); + assert!(err.contains("CA file")); +} + +#[test] +fn tls_paths_summary_reports_configured_locations() { + let root = tempfile::tempdir().unwrap(); + let (cert, key) = write_tls_fixture(root.path()); + let cfg = write_cfg( + root.path(), + &format!( + " api.tls.cert-file: {}\n api.tls.key-file: {}\n api.tls.ca-file: {}\n", + cert.display(), + key.display(), + cert.display() + ), + ); + + let summary = tls_paths_summary(&cfg); + assert!(summary.contains(&cert.display().to_string())); + assert!(summary.contains(&key.display().to_string())); +} + +#[test] +fn tls_self_signed_warning_is_operator_facing() { + let msg = tls_self_signed_warning_message(); + assert!(msg.contains("self-signed")); + assert!(msg.contains("api.tls.allow-insecure")); + assert!(msg.contains("explicitly trust")); +} diff --git a/libwebapi/src/sessions.rs b/libwebapi/src/sessions.rs index edca911e..dd2d9d54 100644 --- a/libwebapi/src/sessions.rs +++ b/libwebapi/src/sessions.rs @@ -57,6 +57,18 @@ impl SessionStore { Ok(sid) } + pub fn open_with_sid(&mut self, uid: String, sid: String) -> Result { + reap_expired!(self); + + if let Some(esid) = self.sessions.iter().find_map(|(existing_sid, s)| if s.uid == uid { Some(existing_sid.clone()) } else { None }) { + self.sessions.remove(&esid); + } + + self.sessions.insert(sid.clone(), Session { uid, created: Instant::now(), timeout: self.default_timeout }); + + Ok(sid) + } + /// Returns the user ID associated with the session ID, if it exists and not expired. /// If the session is expired, it will be removed from the store. /// Returns `None` if the session does not exist or is expired. diff --git a/libwebapi/tests/api_contract.rs b/libwebapi/tests/api_contract.rs new file mode 100644 index 00000000..cb8aa7de --- /dev/null +++ b/libwebapi/tests/api_contract.rs @@ -0,0 +1,299 @@ +use actix_web::{App, HttpServer, web}; +use async_trait::async_trait; +use libdatastore::{cfg::DataStorageConfig, resources::DataStorage}; +use libsysinspect::cfg::mmconf::MasterConfig; +use libwebapi::{MasterInterface, MasterInterfaceType, api::{self, ApiVersions}}; +use reqwest::{Certificate, Identity}; +use rustls::ServerConfig; +use rustls::RootCertStore; +use rustls::server::WebPkiClientVerifier; +use std::{fs, io::BufReader, path::Path, sync::Arc}; +use tokio::{sync::Mutex, task::JoinHandle, time::{Duration, sleep}}; + +const CERT_PEM: &str = include_str!("data/sysmaster-dev.crt"); +const KEY_PEM: &str = include_str!("data/sysmaster-dev.key"); +const MTLS_CA_CERT_PEM: &str = include_str!("data/webapi-test-ca.crt"); +const MTLS_SERVER_CERT_PEM: &str = include_str!("data/webapi-test-server.crt"); +const MTLS_SERVER_KEY_PEM: &str = include_str!("data/webapi-test-server.key"); +const MTLS_CLIENT_CERT_PEM: &str = include_str!("data/webapi-test-client.crt"); +const MTLS_CLIENT_KEY_PEM: &str = include_str!("data/webapi-test-client.key"); + +struct TestMaster { + cfg: MasterConfig, + queries: Arc>>, + datastore: Arc>, +} + +#[async_trait] +impl MasterInterface for TestMaster { + async fn cfg(&self) -> &MasterConfig { + &self.cfg + } + + async fn query(&mut self, query: String) -> Result<(), libcommon::SysinspectError> { + self.queries.lock().await.push(query); + Ok(()) + } + + async fn datastore(&self) -> Arc> { + Arc::clone(&self.datastore) + } +} + +fn write_cfg(root: &Path, devmode: bool) -> MasterConfig { + let cfg_path = root.join("sysinspect.conf"); + fs::write( + &cfg_path, + format!( + "config:\n master:\n fileserver.models: [cm, net]\n api.bind.ip: 127.0.0.1\n api.bind.port: 4202\n api.devmode: {}\n", + if devmode { "true" } else { "false" } + ), + ) + .unwrap(); + MasterConfig::new(cfg_path).unwrap() +} + +fn tls_config(require_client_auth: bool) -> ServerConfig { + let (server_cert_pem, server_key_pem, ca_cert_pem) = if require_client_auth { + (MTLS_SERVER_CERT_PEM, MTLS_SERVER_KEY_PEM, Some(MTLS_CA_CERT_PEM)) + } else { + (CERT_PEM, KEY_PEM, None) + }; + + let mut cert_reader = BufReader::new(server_cert_pem.as_bytes()); + let certs = rustls_pemfile::certs(&mut cert_reader).collect::, _>>().unwrap(); + let mut key_reader = BufReader::new(server_key_pem.as_bytes()); + let key = rustls_pemfile::private_key(&mut key_reader).unwrap().unwrap(); + + let builder = if let Some(ca_cert_pem) = ca_cert_pem { + let mut ca_reader = BufReader::new(ca_cert_pem.as_bytes()); + let ca_certs = rustls_pemfile::certs(&mut ca_reader).collect::, _>>().unwrap(); + let mut roots = RootCertStore::empty(); + for ca_cert in ca_certs { + roots.add(ca_cert).unwrap(); + } + let verifier = WebPkiClientVerifier::builder(Arc::new(roots)).build().unwrap(); + ServerConfig::builder().with_client_cert_verifier(verifier) + } else { + ServerConfig::builder().with_no_client_auth() + }; + + builder.with_single_cert(certs, key).unwrap() +} + +async fn spawn_https_server(devmode: bool, require_client_auth: bool) -> (String, Arc>>, JoinHandle>) { + let root = tempfile::tempdir().unwrap(); + let cfg = write_cfg(root.path(), devmode); + let queries = Arc::new(Mutex::new(Vec::new())); + let datastore = Arc::new(Mutex::new(DataStorage::new(DataStorageConfig::new(), root.path().join("datastore")).unwrap())); + let master: MasterInterfaceType = Arc::new(Mutex::new(TestMaster { cfg, queries: Arc::clone(&queries), datastore })); + + let server = HttpServer::new(move || { + let scope = api::get(devmode, ApiVersions::V1).unwrap().load(web::scope("")); + App::new().app_data(web::Data::new(master.clone())).service(scope) + }) + .bind_rustls_0_23(("127.0.0.1", 0), tls_config(require_client_auth)) + .unwrap(); + let addr = server.addrs()[0]; + let handle = tokio::spawn(server.run()); + sleep(Duration::from_millis(100)).await; + + (format!("https://{}", addr), queries, handle) +} + +fn trusted_client() -> reqwest::Client { + reqwest::Client::builder() + .add_root_certificate(Certificate::from_pem(CERT_PEM.as_bytes()).unwrap()) + .build() + .unwrap() +} + +fn trusted_mtls_client() -> reqwest::Client { + reqwest::Client::builder() + .add_root_certificate(Certificate::from_pem(MTLS_CA_CERT_PEM.as_bytes()).unwrap()) + .build() + .unwrap() +} + +fn trusted_client_with_identity() -> reqwest::Client { + reqwest::Client::builder() + .add_root_certificate(Certificate::from_pem(MTLS_CA_CERT_PEM.as_bytes()).unwrap()) + .identity(Identity::from_pkcs8_pem(MTLS_CLIENT_CERT_PEM.as_bytes(), MTLS_CLIENT_KEY_PEM.as_bytes()).unwrap()) + .build() + .unwrap() +} + +#[tokio::test] +async fn https_server_rejects_default_certificate_validation_for_self_signed_cert() { + let (base, _, handle) = spawn_https_server(true, false).await; + + let err = reqwest::Client::new() + .post(format!("{base}/api/v1/health")) + .send() + .await + .unwrap_err() + .to_string(); + + handle.abort(); + assert!(!err.is_empty()); +} + +#[tokio::test] +async fn https_auth_and_query_use_plain_json_and_bearer_token() { + let (base, queries, handle) = spawn_https_server(true, false).await; + let client = trusted_client(); + + let auth = client + .post(format!("{base}/api/v1/authenticate")) + .json(&serde_json::json!({"username":"dev","password":"dev"})) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json::() + .await + .unwrap(); + let token = auth["access_token"].as_str().unwrap().to_string(); + + let query = client + .post(format!("{base}/api/v1/query")) + .bearer_auth(&token) + .json(&serde_json::json!({ + "model":"cm/file-ops", + "query":"*", + "traits":"", + "mid":"", + "context":{"reason":"test"} + })) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json::() + .await + .unwrap(); + + assert_eq!(query["status"], "success"); + assert_eq!(queries.lock().await.as_slice(), ["cm/file-ops;*;;;reason:test"]); + handle.abort(); +} + +#[tokio::test] +async fn https_query_rejects_missing_bearer_token() { + let (base, _, handle) = spawn_https_server(true, false).await; + let client = trusted_client(); + + let response = client + .post(format!("{base}/api/v1/query")) + .json(&serde_json::json!({ + "model":"cm/file-ops", + "query":"*", + "traits":"", + "mid":"", + "context":{} + })) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED); + handle.abort(); +} + +#[tokio::test] +async fn https_model_names_returns_plain_json_list() { + let (base, _, handle) = spawn_https_server(true, false).await; + let client = trusted_client(); + let auth = client + .post(format!("{base}/api/v1/authenticate")) + .json(&serde_json::json!({"username":"dev","password":"dev"})) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + + let response = client + .get(format!("{base}/api/v1/model/names")) + .bearer_auth(auth["access_token"].as_str().unwrap()) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json::() + .await + .unwrap(); + + assert_eq!(response["models"], serde_json::json!(["cm", "net"])); + handle.abort(); +} + +#[tokio::test] +async fn https_model_names_rejects_missing_bearer_token_with_json_error() { + let (base, _, handle) = spawn_https_server(true, false).await; + let client = trusted_client(); + + let response = client + .get(format!("{base}/api/v1/model/names")) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED); + let body = response.json::().await.unwrap(); + assert_eq!(body["error"], "Error Web API: Missing Authorization header"); + handle.abort(); +} + +#[tokio::test] +async fn https_store_list_rejects_missing_bearer_token_with_json_error() { + let (base, _, handle) = spawn_https_server(true, false).await; + let client = trusted_client(); + + let response = client + .get(format!("{base}/store/list")) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED); + let body = response.json::().await.unwrap(); + assert_eq!(body["error"], "Error Web API: Missing Authorization header"); + handle.abort(); +} + +#[tokio::test] +async fn https_server_rejects_requests_without_required_client_certificate() { + let (base, _, handle) = spawn_https_server(true, true).await; + let err = trusted_mtls_client() + .post(format!("{base}/api/v1/health")) + .send() + .await + .unwrap_err() + .to_string(); + + assert!(!err.is_empty()); + handle.abort(); +} + +#[tokio::test] +async fn https_server_accepts_requests_with_trusted_client_certificate() { + let (base, _, handle) = spawn_https_server(true, true).await; + let response = trusted_client_with_identity() + .post(format!("{base}/api/v1/health")) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json::() + .await + .unwrap(); + + assert_eq!(response["status"], "healthy"); + handle.abort(); +} diff --git a/libwebapi/tests/data/sysmaster-dev.crt b/libwebapi/tests/data/sysmaster-dev.crt new file mode 100644 index 00000000..ba759ddc --- /dev/null +++ b/libwebapi/tests/data/sysmaster-dev.crt @@ -0,0 +1,30 @@ +-----BEGIN CERTIFICATE----- +MIIFJTCCAw2gAwIBAgIUYfOEyyfhlEa11Bu4xP2E5sP6fpEwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDMyMTEzNTUxMVoXDTI3MDMy +MTEzNTUxMVowFDESMBAGA1UEAwwJbG9jYWxob3N0MIICIjANBgkqhkiG9w0BAQEF +AAOCAg8AMIICCgKCAgEAnE+jES3AsqHBc0+/Key3s9VVQCEqp5tszaZNMJVS85WQ +YlCDlik6BDnFbLEwZ8DOKr6bEn4IHEL/rOJ3vhNp2kaVXPltsPm/scdymmOQPS7V +7RO2BkzVHc9YYRNr326HF2VbiSwinQCHbrhcCBvFJItd+jwk7+mggK8QqflsvmdB +GkqSaqUJY+mop1hVfgWYjh9iaLow3tU3G3WoK/g/Cba0kyGyaaz+h8Hf/35AGPr+ +5LLSWPxON00h1juzoM9UVbAue+iF7/IKpKaoAXSDmHzeoGnyeR09Udsn4bW9GuhJ +DFNas5QhmwjWbJrH+QUzTC+8XO030kZmARNLX/wU2bpmvHtrqZMcxNn+oCcl3dHm +BhLJcdYFCPSuYoDJIyj93K0wEnSVs2WGyBPccCAL80k2TPxJeScgFnrsQeXoTlO8 +fNoFzXPnfqZM8y0+Xx+oag3LIPldwmd+yToelni2hQ0ev4Q3d/BoYzRvvYa8N4Ik +AkErNyI16BQByJSjSfcHKuDQ0mzgyhc40OcMYajq9/3EbfmTpTSNLAiegzrCJFp9 +8XE/r51Ay5zoSMWBBpeujH2QLckxXNA14t5Va8ckpsTedyGh9N+2lEjVhEd0EaGD +/D9xqwrLhskpnKpI3fWUe+d0fwT8gLU9xHmY5VUCTvDXVMg20Djx/hb7czMP8c0C +AwEAAaNvMG0wHQYDVR0OBBYEFHA9gSE8+gkTRJq7k/ja3kzTsxkxMB8GA1UdIwQY +MBaAFHA9gSE8+gkTRJq7k/ja3kzTsxkxMA8GA1UdEwEB/wQFMAMBAf8wGgYDVR0R +BBMwEYIJbG9jYWxob3N0hwR/AAABMA0GCSqGSIb3DQEBCwUAA4ICAQCUCYhwQIlB +k/ufXMqhakEdJFS+2O6KtZb81bmx1wOsk0U5os2eFChKMlcK1RJ7cmXdbxSx7z48 +6HP7hq6s37aaVaMWwnMQ2cgzxN1xn0dT0IkVD1gllxiqJUTlYHcv6Y/mpJb81LKR +sFWriQbvCmE5Bo3M1xKbozX75oGfwrsI5jr8j14vtnH7Qv86XEyEBMecHYJ2UohZ +SXmHH4od1TYGnnRmbuEze+lQttA1PWoJjLL5+CVKVgTS6hHE/+cMBCSaoqPQHBFl +TVic+TgBIcMIk/Cs/bgk2al/lXtTk4/3FOVNYQtaBZD2bw9tGsJR3wBYLDLBD2ln +BoBvOWMtwMtZaw/3YxCQGYCLEjjivvdbX7M2hz2Zq2o72MhGLFvprOtwaYz9220+ +z9tylh1a/35DrVo/VZjjyc7YnYHkxhTU7PIUNJJcqDKS3hPgiRrOJiEmGM5mhmuJ +WhUtjqIkH6Kk67TRRmEO0/jUE5PfATdWZ/QNuGYJPTRDR8MBcAxwtfVFW8Zsd9T3 +qXHSPmAPS0RaICkoEqnyzPg0Pzaq1d2gGlosVkk7V7tie9XEAzcEBcTy6F0GOVwc +IBmp/V3evJN46oY8tn1MqY51Ta5IsQ1Kqg0ZSLxIEldkwWeApJRAXS8juPYtteIj +REkVbmcpUMl/yKca77EP1R7db+PzvXzWsA== +-----END CERTIFICATE----- diff --git a/libwebapi/tests/data/sysmaster-dev.key b/libwebapi/tests/data/sysmaster-dev.key new file mode 100644 index 00000000..25910e7a --- /dev/null +++ b/libwebapi/tests/data/sysmaster-dev.key @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQCcT6MRLcCyocFz +T78p7Lez1VVAISqnm2zNpk0wlVLzlZBiUIOWKToEOcVssTBnwM4qvpsSfggcQv+s +4ne+E2naRpVc+W2w+b+xx3KaY5A9LtXtE7YGTNUdz1hhE2vfbocXZVuJLCKdAIdu +uFwIG8Uki136PCTv6aCArxCp+Wy+Z0EaSpJqpQlj6ainWFV+BZiOH2JoujDe1Tcb +dagr+D8JtrSTIbJprP6Hwd//fkAY+v7kstJY/E43TSHWO7Ogz1RVsC576IXv8gqk +pqgBdIOYfN6gafJ5HT1R2yfhtb0a6EkMU1qzlCGbCNZsmsf5BTNML7xc7TfSRmYB +E0tf/BTZuma8e2upkxzE2f6gJyXd0eYGEslx1gUI9K5igMkjKP3crTASdJWzZYbI +E9xwIAvzSTZM/El5JyAWeuxB5ehOU7x82gXNc+d+pkzzLT5fH6hqDcsg+V3CZ37J +Oh6WeLaFDR6/hDd38GhjNG+9hrw3giQCQSs3IjXoFAHIlKNJ9wcq4NDSbODKFzjQ +5wxhqOr3/cRt+ZOlNI0sCJ6DOsIkWn3xcT+vnUDLnOhIxYEGl66MfZAtyTFc0DXi +3lVrxySmxN53IaH037aUSNWER3QRoYP8P3GrCsuGySmcqkjd9ZR753R/BPyAtT3E +eZjlVQJO8NdUyDbQOPH+FvtzMw/xzQIDAQABAoICABZDKWBq+cT3UMwRkZJxCoDs +Y2Xs01xnwIlRpDDFM7lJlfTKrtMWMBMl/z5JxjEgvrxLxV5O4OzVhgCjiJZjwXG7 +F87UH5FTIMA7PdFLWOG95+4KHqSrELdcLqQ01epOnaLxZqYUySE/UAqu6zykZ+Ga +j9nx8vjQd3GcfW0X/yrnHdiWwl+5/apjPwgGhraaKW4kfimYSxmRmHWqvjb09lV1 +1iYWaIiwgNfo/vQukQZ9yQvdhCP0W1d4/ta6Tg0bOlGx9Azlwx23hViJ++epJozz +S+ng7Q3e4jrkUbvN3I8WgkDlJkfpUxf3nEJ/kPegi/vP2K4LgyXJrQF+NAAJsRZQ +Y8v1XfESKM028zLYryya7HxWSsg4q+tmZafL5H5323SiQi4SJ7P82+zYQ1Lie3g9 +wqCf4wkBf0iLPTeZQeYxFuCJnyhXDQQ4GjHJXHa5XUkp/1nGeZfwnN2XIZboVFiA +ckz/fvaAc4GJWrVu+/W3HL+Qy+zRRWiRlmIplKrNhGg1RLX6tqsfNDPf/z0YQAUA +eWdeoTZCw88wyxcL34PGKAHMjKtOElBarTEf8Gm5oaRPx1m9QnKH6wQWOcxy779s +1lAyfFtJiAWeC7QF7zCp652Fihu/tWMAWCH5ek4fZwiRybk4gLfvyYsiqt9OKCyj +Qkg1Ou9oiPWQtcts6QSpAoIBAQDVEFUuoRhYS+ZvAXUzpDMn2afYlvJ94U8hikyx +zN6TX/xRZoa2mkkP80qi3VncmnN/KGzerEe5vp9lOFP1ltlXzfZ0ZNLKIYRdn3LH +Pjz7N1Hg5gCOrFdxwG0PmViXMskDOfzuTdb1n3vq7gmY7CaOvR3belsNN88ZtMsF +M9Eh8Aikzb4RvpTxtdlVagNXH/2Xfq8Auf2c6QTO+dqcwdfGgiRNTz/Cea/BKz25 +G6+mvjt2MXvUui5AcK3OAqe0NaaP+pN8vKKAx07D8FY7nDnY9UBH2Wt278Cqj6RW +9mgN6/tR0fhN9otDiU06pni0eOmvjXJpTE29ZEcetlhStEWrAoIBAQC7z4KHtNVa +zH9rQHpvzahpnua8VVCt3ww0pBt4h12kifOCmd6PsTHuBACJEodEM5raYGtDovuo +ENRuPhTtJoWh42ZnZHoi6CvXLNGl89gKVNSdqL1NQREo2D7DzYmGlqLW26txb7Cv +CEJf71U0syOuYkQS4F+ISbCQko0ctu9rhQMkPEHYWIRNoLkH3gTQJOcv9OWIcKQj +IYyFxZMysWABT+R+GuW2v+lPtertosjQs9CiHr/e9T2GyW5L5aTzt3q7j+Ftlqsg +XbbmHSDWKdUCo7QXa3Ppnid9y6iGU/PbHrY1XAMdp8ODi+dqOdgObAb6VpHqGijd +8z7LoxCZeb5nAoIBAF+YAF/3b1DOXQkZAli1Jy6N/Ty0HQBVgodt4ZM0c/hzbGWp +Nm/fMUCyy53e6l6L/Z3jqVUOvu+bkzB64VCi6cj3Y8g9JEYEW7sVuw2h4wJjg50A +FOfucx1aVJRXHORZqM6FyfGxguyZLaPuQOgXrAUG3MqITynTDFxgPWaMJRyw8W/f +z5Nuiq0YBfbIpc8FT2YVNLeCu0MXWUzz1R3X6tPpuBfnopfCuRRWLk9LGLgbSdpx +wTlkfzPyWki/8DZui9i1eE7S46Ybxj8rKcV9BodNIhYaepjWYP8li3po+66jXhML +vfhc0Ybvp3LVFfsC9PYK5HZSAd8jirVA4sfYkhkCggEBAJAVJIjD/KKKHH7FmqjH +WBqfo1h9A0ZAxfZkqAaRow+mHcDmFs6aHDoDq/18z3VNOdGrAt+C3BoVv0NMMXW2 +hfKqqFdNyD2bbHbJlZUBO47BgdPqLkBkWKvDKnPA7W7phcfcAu0lyKCfb3x1+iJS +BF+2V487v06paeGf7M5IsekExGI6MDGvxuBfG1SjyYF9rjcmZCmGcQXaqRm/d6v+ +VC7tgdgU/oJzPKTAZZklt3YVXUvi10RPVIJhalKjvSaUbn4SZdlTK7nK65QiaJyk +vxwlRvZooyZpBNcHNSTIp15Fc3gAPQu1NtNms4TVF6II0lmfrJWyuAN+p4BGe2ei +9KUCggEAV1FYRd7t/fBVzq0qp47LeA0yYeZX139VrM8iSAphqpqGVCbc54h+3GJA +7F7maf2vtBI9+iq8VyWVcMTHtFuwL1G9NXmfYU+RS3vF5SmG5WKD2GYr6fAk8axY +96diomP0suzW/N4gmsInEYFVtOLbeZF2w/9wYtka2JHCGivU5eUC+HVpkxEMpt96 +FNPAcJ9/PPdU/bGBy26fPOI8+cybHqFxbmKfZtz81wKY1neM89MA+bKhEgO2g3na +cr+hZIKWz7gondC4meA0DeGX70VkkIndkPKjIsANM2hKAF+lMc+4xtTVBdC1E+DG +TCBm7jJaYdu2PBza/IFRwL2pASrr/g== +-----END PRIVATE KEY----- diff --git a/libwebapi/tests/data/webapi-test-ca.crt b/libwebapi/tests/data/webapi-test-ca.crt new file mode 100644 index 00000000..d9104dff --- /dev/null +++ b/libwebapi/tests/data/webapi-test-ca.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDKzCCAhOgAwIBAgIUJMv/3xiuChSAX5zbTkcCqdsP/ycwDQYJKoZIhvcNAQEL +BQAwHTEbMBkGA1UEAwwSU3lzaW5zcGVjdCBUZXN0IENBMB4XDTI2MDMyMTE4Mzc1 +M1oXDTI3MDMyMTE4Mzc1M1owHTEbMBkGA1UEAwwSU3lzaW5zcGVjdCBUZXN0IENB +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwo5T0+qqvi/hwn9634ia +QfD19nuZoeFk2mfewzZc4Icwa1oMjtIjMcGr1yTfJQYi+AC258Dtzdpr5B90FZW5 +O7Fb7LuxLDYYB+3MAmJxevu9ZM95y8XDuJ/A4B2NLd0owH+yqKS8irxdAGptw9jJ +YV5mS7J7/i+5JWSV3YkKoJEc6otOhfkTe8pj59h+Pb9frK8fQIgY8TlXqs45IEFw +7IKiQ9+Xm3R12D6ENnn1GKbEfX7igQc1qsdx9p/aqWoKbriyRNbcLjtclqEOsj4l +JeCOSTXLAgCHIJtZRugNVOtS4AB9Lw9qBWcgKQd2DKxJgMehWzBLZpZ2dupYjCjO +4QIDAQABo2MwYTAdBgNVHQ4EFgQUVRO26TCVbDvH9xyONLuTjYo6UYUwHwYDVR0j +BBgwFoAUVRO26TCVbDvH9xyONLuTjYo6UYUwDwYDVR0TAQH/BAUwAwEB/zAOBgNV +HQ8BAf8EBAMCAQYwDQYJKoZIhvcNAQELBQADggEBAHlSgDvnaB2Wynh3v8nSphOa +rmoqExBVOWFrM7VVRK4bKEocbH7wO14oD+wkPckIaR8X72jmlBP8XAj0RDJuT9mf +RL+cYlo1TOuF5nPDGA6+qwFsVJUCY7UykIMRiiDnU27qpJ6gRIVeTZf3K0ywgJC4 +0VHhA1go7RMXiqbVyAEpVqLxvKvzdQasMg1CoD4rCiKqwPaHr+c2vSccVu8RO4oP +jP9UuUND8ilAjVyYf1JvQ9NbGpGYFamvoyaAIFsYF/lXecfbT2UMkvr4NhGeE5l/ +No9Zs+c5RAjgjh9pYmZHgRWcHJ8XJSiH0okwPCnsFt3OvYVrUbBNCjpdCGrpy5o= +-----END CERTIFICATE----- diff --git a/libwebapi/tests/data/webapi-test-client.crt b/libwebapi/tests/data/webapi-test-client.crt new file mode 100644 index 00000000..581e9405 --- /dev/null +++ b/libwebapi/tests/data/webapi-test-client.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDOjCCAiKgAwIBAgIUDCkZ4lZ3QWAUa2uLYq397hC/ookwDQYJKoZIhvcNAQEL +BQAwHTEbMBkGA1UEAwwSU3lzaW5zcGVjdCBUZXN0IENBMB4XDTI2MDMyMTE4Mzc1 +M1oXDTI3MDMyMTE4Mzc1M1owHTEbMBkGA1UEAwwSd2ViYXBpLXRlc3QtY2xpZW50 +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyGfEFvClTxkQQhHIWNNv +mlMfVNqBfYlkDk1ojQg5e7IR25xcBoQL2WhthH4TJbKhCw0H0WzE0egEVrignxlK +yujNqiIeM6yfW8eNxt8Ql9/0LJA9YIW0hTRZ2lqWbFgb+3q7z+WE/OoLmcRLJhwX +VM3I2nlOupdNUniQ08wwll9bbo8ouREP5knpxU8uK34WjbrZ3Q6C+uNKb1m+I0Y9 +ZQiRwu3Wbris3PLQEFMyfDp52DBAFkSgu8kRxrM8zNtXdXZbTW2c3CvxtW6xejO8 +ghJ64zPWRIJgfbQuVSUGqMSswnWpADzn6mZkF+aESKSEMpAgHJ+J04yYjqd7uUm9 +4QIDAQABo3IwcDAJBgNVHRMEAjAAMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUEDDAK +BggrBgEFBQcDAjAdBgNVHQ4EFgQUTRIN0g6fzPe6qGlvV2YRgvZC+dswHwYDVR0j +BBgwFoAUVRO26TCVbDvH9xyONLuTjYo6UYUwDQYJKoZIhvcNAQELBQADggEBACnE +3A5Kg0YsAqjmFLSdF0RIjuVX1X6OoC8SyAF53mnXe3x7jmflQdmzRMnbnplCju8F +7mL6R48lW///wKzJxoDYYpwr90NQPpzmmdf1aC6eV7F2vQ56fnQZiKdmraVsVjuK +Ud/euvNMtDN4gy9ATOQKQ98eWA3+fJ7AOOasQT0fIsxP1yF/ewdEwGMDxlHJUl0S +gOLRF+dcvkktLywJXw+xoGCtLnxYnOHR81b/zAcXkn2JNXA8N2Dkhu/XLQE+hNr1 +VS5zb2qNmz1LJ3+w1puYRKnfLA8gVuHbAFfPHrioRrxcfos8nGeyu3eyOh/0jj5m +p6nLM/Fz3f40SAlP3Ec= +-----END CERTIFICATE----- diff --git a/libwebapi/tests/data/webapi-test-client.key b/libwebapi/tests/data/webapi-test-client.key new file mode 100644 index 00000000..6208f88b --- /dev/null +++ b/libwebapi/tests/data/webapi-test-client.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDIZ8QW8KVPGRBC +EchY02+aUx9U2oF9iWQOTWiNCDl7shHbnFwGhAvZaG2EfhMlsqELDQfRbMTR6ARW +uKCfGUrK6M2qIh4zrJ9bx43G3xCX3/QskD1ghbSFNFnaWpZsWBv7ervP5YT86guZ +xEsmHBdUzcjaeU66l01SeJDTzDCWX1tujyi5EQ/mSenFTy4rfhaNutndDoL640pv +Wb4jRj1lCJHC7dZuuKzc8tAQUzJ8OnnYMEAWRKC7yRHGszzM21d1dltNbZzcK/G1 +brF6M7yCEnrjM9ZEgmB9tC5VJQaoxKzCdakAPOfqZmQX5oRIpIQykCAcn4nTjJiO +p3u5Sb3hAgMBAAECggEAB0W+t69lbFSmQqOHD/QiS2kjTK6+Pro+44b7GY0YGu1I +GR5YN5NQo8PWn5V8p+RO1EoVg8vM66oeCDCcgZGHJYxjtD4XNvxXbxrzgelD3qMN +pxVX6NoJRkEzVolthoJ/B3X5fU6gsBXlNGALcxdXYGg0VvtKeFp3v5uo88qn47kD +JI6Sc8DiKEkKLkBZeWH/NZFz8U37rfylrjeOk8zXJ0xjbFS/LUNYYd3OyhYofzH3 +kteIs2+hQbsQsl/cd84kbH33xzM/KzdslMvqbCrlDaYGs3g3CJP3a8nGrHXCUgl+ +4VRp8nr0HBD/lE/RDDIpZDMefcKAXl2Iz7jcBV/HLQKBgQDO9uKdJx9xm3g5Uz23 +ZkWt8yvpFnZy5JxYT/7TteTbHVgY2bOQtWcz5oEn/Z/VqOF/mIzdyVESWQyiKEZU +leECDIpuIU6/RhUq/L1FWeTx6CJIm4JnOY1INKF0MEFpEVJbzoCQhiJgrWmXAuHQ +jgxyCxGnhvJwTtvdlM6hKmm7/QKBgQD34w0EvXnSewWFybZtn2oFkOFJH0yUmQb2 +Hq8Ih1S9riIeXEB847MyKke2j/Ksk8nrR6xChcA3j3Qim3xlydEb4zb6UEI9ePY1 +mJbPqMpy0IEZVZ1pd7rVaYhwHVyFeYq0wYyZXOqXRlc7VAAPydtPmyCj7HalVTsI +Eu+stE1ktQKBgAEUfL5BNALNwuTZsFrCp95uhG4k9d1HoCE92aCVNGqITqtih3Nb +3vwAWfAxfKIKzZJy41lM8aVc3ZoDB8rtNU1jb11/wv9wiC+/PeWcwHsasQMb/KQ5 +Qql7zNPkZJL9yiY8f6NBb/B9Ny3YkAEcnKgDssXjCGTZpIAVhLaGmCKpAoGBAJ4q +KSxVGV3LUQLEaboYdTWH87cMWXiXC3IOse/nKZK9gNeOVTdasgPYJlm+D0E+KyAM +Y0Uuwi6xQZCzVPQ9iUcZ+wJMI3fFrpMUAWYOdN49W6ImloGs+3EgHQYsNdSUcIRU +2rkgKC7Nmusn9cIdMenhOTpernVfpILKUlMH2DnhAoGAKZZp/3gnu9Xr6iMs6e7W +iCB4lHzCTHYub4GnMqdp5NeKyo2MMo8hUXNH8CuoiDOE67h21fN1Vvd4gDpgOjXx +VY41xDG0Kpbbm8G1m/4BMRNmixjOwTn8+tugnpYSLVMaHTgtpq9XSBLv66z1mCxA +z/OkUDiqgbBJ826N1Mo+NZg= +-----END PRIVATE KEY----- diff --git a/libwebapi/tests/data/webapi-test-server.crt b/libwebapi/tests/data/webapi-test-server.crt new file mode 100644 index 00000000..ce4d5269 --- /dev/null +++ b/libwebapi/tests/data/webapi-test-server.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDTzCCAjegAwIBAgIUEAn1kxoe+1Y76iZuT4lSEg/CJVAwDQYJKoZIhvcNAQEL +BQAwHTEbMBkGA1UEAwwSU3lzaW5zcGVjdCBUZXN0IENBMB4XDTI2MDMyMTE4Mzc1 +M1oXDTI3MDMyMTE4Mzc1M1owFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkq +hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoum06Im2QmRufsEOqB6k3z5dI0VSTdFH +rq4BZJZilU4DwNgiNBf2diNm3xmv0V/5gVypGK8eEuocpWjKtsTFIEd6A1L0bpqt +OmBum4bKl0Iu8wQE/0Wm+U4gM6MTdpLDe5ZIQGKoIEm1X1qrDrIpqJuM+9N1tQxi +N8w9S4NVVP8Bu8AZIUrAmw8x6rJHyR3w0tJo6NVu1pcyABuTlgsBrA4br0rgTyLx +u1HsFLgktI9mx6Ldj05fmaB+yMovKKWQYtd7bE92HJXWtnHRooqtQHFw1XbH8KTo +gwbwu7+T7AiHIw4lbiyUV7KRmhUED971EPCmX2mU2J2D8n3iGSLFCwIDAQABo4GP +MIGMMAkGA1UdEwQCMAAwDgYDVR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUF +BwMBMBoGA1UdEQQTMBGCCWxvY2FsaG9zdIcEfwAAATAdBgNVHQ4EFgQUVzFADb+l +BSIgmxRQGSc1kzkeey4wHwYDVR0jBBgwFoAUVRO26TCVbDvH9xyONLuTjYo6UYUw +DQYJKoZIhvcNAQELBQADggEBAAYkWtC0s/3Tasn04ovf6YhnGfnaocwvNKITZdyA +nGpimlozh0rEq/G/GgdEUqR0qIzFpfoNGqeJo6fG2a6g7k0SfMwLc6vFXGYWZeeL +4Pekvj0rcnSwuihUnsOi/3qrbV1x5DPQbui0OdkxRC7nmFOakwz4F7buFHa4tClp +GETr1n6HfFna/yr1XfuIsRWQeNP/Fwb4hJ1QWUX7a2GKNSLek2X0mt0YM2OPDQPC +RK11hLPtd9qcJWB9ToCoaSwhWqYtSdPAhgh3c2Md3IBCPiEBFh/7Ch9EjfWLHdb5 +Yehnf5SnxOsGEIhkq9xUvk9/IqXi51HfNd+NZsvNhH8YpeY= +-----END CERTIFICATE----- diff --git a/libwebapi/tests/data/webapi-test-server.key b/libwebapi/tests/data/webapi-test-server.key new file mode 100644 index 00000000..e0f5a6e6 --- /dev/null +++ b/libwebapi/tests/data/webapi-test-server.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCi6bToibZCZG5+ +wQ6oHqTfPl0jRVJN0UeurgFklmKVTgPA2CI0F/Z2I2bfGa/RX/mBXKkYrx4S6hyl +aMq2xMUgR3oDUvRumq06YG6bhsqXQi7zBAT/Rab5TiAzoxN2ksN7lkhAYqggSbVf +WqsOsimom4z703W1DGI3zD1Lg1VU/wG7wBkhSsCbDzHqskfJHfDS0mjo1W7WlzIA +G5OWCwGsDhuvSuBPIvG7UewUuCS0j2bHot2PTl+ZoH7Iyi8opZBi13tsT3Yclda2 +cdGiiq1AcXDVdsfwpOiDBvC7v5PsCIcjDiVuLJRXspGaFQQP3vUQ8KZfaZTYnYPy +feIZIsULAgMBAAECggEAFLhz5YWqdExQz3dflVt8bdaBQxysiLA/FUUVcUU9Wa8Y +BB2ZUBXiJ4l+Ko8aUR+LXPw7l6OiSBaVuSYYbmGdjur4ZlbVNwIeWUftmYNt3gox +bYBL4GnsAFaC+v5ZWeH10hC9tM63go/NbUjba92WNdc++cKd/H6MOXuVKjcUkeA/ +DmvkRYsXvid6TE/ErrBrJpIVpVkaTf5hwNjuko8JYzlK8jsjHd76XhVDwVKB2ZMn +xSrL4BM3SXQvSXbjPD0xvvlpDTVuKzdgwTGDutP0GTWoGIoyrRUC5p1Vxg8G7eSj +UmnWyu3hhjPxm0tWvXArR3tbr37xUehIJwwioMySwQKBgQDie7LyMjnYyRKxmaCK +XGYEQ+qeC8O7w34UbfE2cN31QpYm8Z92woz0wcqXwGfAnh7ti/PiDHPTmz3LqHQA +ZDgUGwuBH/nPKGL+G13mNjbqcsgIch+0B45GaraprsEzVYz+oLRniVZEdsbKtIFL +jXQttaSGpImQMVU/RtTxe4V+SwKBgQC4JRKNLRfWce1/+E89req98/yftxn4wdf/ +mraNIg4rUM0hbRgTen5dAmKOhqSZGBUQY0TuvShcJ45NqiLXZzArXU5FzM2xny+y +813YhlWq183iXsFJ5MSCgPkPEOZz+DTEIo4Jup4/qPrkolHEAKlg5Y8Cc1fFHWm2 +N9YI/NicQQKBgHS1oeVFFKIuG8ABlsU2ECwqg4CmN1tOxm3oqeCQEREOGyo+YRpl +7xVBuBCzScPst6tZ73eRSy7EVPfZ+s0o1+0kcq07uROTkE+58o1raqkuNP6FMOko +65xF6ZNPRqgZcerVDaI9F4N4YcCbe/VfE3tqmzn3GByCD5fn/Fvkd0o5AoGBAJEJ +Ef2DwLy0at1aE/9+ld8a5qRdMOWOt7OohZPPeN2A/LARHt9ooVJcaIfdYJL8Nsr7 +hPWMotdCiIB/OoXxziy5hsbPMktuF8GYkRfTZnHzG0PqYc7zkhs/veqx4vEAU38P +wFPFWpLFYybk+gWoh7+7ztGdS0oDiplsjPXzQCCBAoGBAKO4sML3XtzdOkAhR7Z1 +uNmb+8jdkZWpM5RjuhM39luxS9qBpQ+4Nl+LYbxHp7iAXNbt/tkNgr/pq6xiEGzu +QjMxvFo1i/O9FSyP4w61WXV+SNfyog74quL0v5h7sItneDy+m865TkSgrwc4KfRn +uQ89QjhHGMBmy2xzMC0pIP3Y +-----END PRIVATE KEY----- diff --git a/scripts/dev-tls.sh b/scripts/dev-tls.sh new file mode 100755 index 00000000..57fac785 --- /dev/null +++ b/scripts/dev-tls.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env sh +set -eu + +OUT_DIR="${1:-target/dev-tls}" +CERT_FILE="${OUT_DIR}/sysmaster-dev.crt" +KEY_FILE="${OUT_DIR}/sysmaster-dev.key" +DAYS="${DEV_TLS_DAYS:-365}" + +mkdir -p "${OUT_DIR}" + +openssl req \ + -x509 \ + -newkey rsa:4096 \ + -sha256 \ + -days "${DAYS}" \ + -nodes \ + -keyout "${KEY_FILE}" \ + -out "${CERT_FILE}" \ + -subj "/CN=localhost" \ + -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" + +chmod 600 "${KEY_FILE}" +chmod 644 "${CERT_FILE}" + +cat < Self { - SysClientConfiguration { master_url: "http://localhost:4202".to_string() } + SysClientConfiguration { master_url: "https://localhost:4202".to_string() } } } @@ -36,13 +39,13 @@ pub struct AuthRequest { #[derive(Debug, Clone, Deserialize, Serialize)] pub struct AuthResponse { pub status: String, - pub sid: String, + pub access_token: String, + pub token_type: String, pub error: String, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct QueryRequest { - pub sid: String, pub model: String, pub query: String, pub traits: String, @@ -78,22 +81,21 @@ pub struct ModelResponse { pub model: ModelInfo, } -/// SysClient is the main client for interacting with the SysInspect system. -/// It provides methods to set up RSA encryption, manage configurations, and interact with the system. -/// It handles user authentication, key management, and data encryption/decryption. +/// SysClient is the main client for interacting with the SysInspect Web API. +/// It handles authentication and plain JSON request/response flows. /// /// # Fields /// * `cfg` - The configuration for the SysClient, which includes the master URL. -/// * `sid` - The session ID for the authenticated user. +/// * `access_token` - The bearer token for the authenticated user. #[derive(Debug, Clone)] pub struct SysClient { cfg: SysClientConfiguration, - sid: String, + access_token: String, } impl SysClient { pub fn new(cfg: SysClientConfiguration) -> Self { - SysClient { cfg, sid: String::new() } + SysClient { cfg, access_token: String::new() } } /// Authenticate a user with the SysInspect system. @@ -124,7 +126,7 @@ impl SysClient { .await .map_err(|e| SysinspectError::MasterGeneralError(format!("Authentication decode error: {e}")))?; - if response.status != "authenticated" || response.sid.trim().is_empty() { + if response.status != "authenticated" || response.access_token.trim().is_empty() { return Err(SysinspectError::MasterGeneralError(if response.error.is_empty() { "Authentication failed".to_string() } else { @@ -132,14 +134,14 @@ impl SysClient { })); } - self.sid = response.sid; - log::debug!("Authenticated user: {uid}, session ID: {}", self.sid); + self.access_token = response.access_token; + log::debug!("Authenticated user: {uid}"); - Ok(self.sid.clone()) + Ok(self.access_token.clone()) } /// Query the SysInspect system with a given query string. - /// This method requires the client to be authenticated (i.e., `sid` must not be empty). + /// This method requires the client to be authenticated. /// /// # Arguments /// * `query` - The query string to send to the SysInspect system. @@ -152,18 +154,16 @@ impl SysClient { /// * Returns `SysinspectError::MasterGeneralError` if the client is not authenticated (i.e., `sid` is empty), /// * Returns `SysinspectError::MasterGeneralError` if there is an error during the query process, such as network issues or server errors. /// - /// This function constructs a JSON payload containing the session ID and the query, - /// encodes it, and sends it to the SysInspect system using the `query_handler` API. - /// It expects the SysInspect system to respond with a string, which is returned as the result. + /// This function constructs a plain JSON payload containing the query, + /// sends it to the `query_handler` API, and returns the decoded JSON response. pub async fn query( &self, model: &str, query: &str, traits: &str, mid: &str, context: Value, ) -> Result { - if self.sid.is_empty() { + if self.access_token.is_empty() { return Err(SysinspectError::MasterGeneralError("Client is not authenticated".to_string())); } let query_request = QueryRequest { - sid: self.sid.clone(), model: model.to_string(), query: query.to_string(), traits: traits.to_string(), @@ -175,6 +175,7 @@ impl SysClient { .cfg .client() .post(format!("{}/api/v1/query", self.cfg.master_url.trim_end_matches('/'))) + .bearer_auth(&self.access_token) .json(&query_request) .send() .await @@ -200,9 +201,14 @@ impl SysClient { /// Returns a `ModelNameResponse` containing the list of models on success, or a `SysinspectError` if the API call fails. /// This enables the caller to access the models provided by the SysInspect system. pub async fn models(&self) -> Result { + if self.access_token.is_empty() { + return Err(SysinspectError::MasterGeneralError("Client is not authenticated".to_string())); + } + self.cfg .client() .get(format!("{}/api/v1/model/names", self.cfg.master_url.trim_end_matches('/'))) + .bearer_auth(&self.access_token) .send() .await .map_err(|e| SysinspectError::MasterGeneralError(format!("Failed to list models: {e}")))? @@ -214,9 +220,14 @@ impl SysClient { } pub async fn model_descr(&self, name: &str) -> Result { + if self.access_token.is_empty() { + return Err(SysinspectError::MasterGeneralError("Client is not authenticated".to_string())); + } + self.cfg .client() .get(format!("{}/api/v1/model/descr", self.cfg.master_url.trim_end_matches('/'))) + .bearer_auth(&self.access_token) .query(&[("name", name)]) .send() .await diff --git a/sysclient/src/lib_ut.rs b/sysclient/src/lib_ut.rs new file mode 100644 index 00000000..f5f23dd2 --- /dev/null +++ b/sysclient/src/lib_ut.rs @@ -0,0 +1,57 @@ +use super::{SysClient, SysClientConfiguration}; +use libcommon::SysinspectError; +use serde_json::json; + +#[test] +fn default_configuration_uses_https_localhost() { + assert_eq!(SysClientConfiguration::default().master_url, "https://localhost:4202"); +} + +#[tokio::test] +async fn query_requires_authentication_first() { + let client = SysClient::new(SysClientConfiguration { master_url: "https://localhost:4202".to_string() }); + let err = client.query("cm/file-ops", "*", "", "", json!({})).await.unwrap_err().to_string(); + + assert!(err.contains("not authenticated")); +} + +#[tokio::test] +async fn models_require_authentication_first() { + let client = SysClient::new(SysClientConfiguration { master_url: "https://localhost:4202".to_string() }); + let err = client.models().await.unwrap_err().to_string(); + + assert!(err.contains("not authenticated")); +} + +#[tokio::test] +async fn model_descr_requires_authentication_first() { + let client = SysClient::new(SysClientConfiguration { master_url: "https://localhost:4202".to_string() }); + let err = client.model_descr("cm").await.unwrap_err().to_string(); + + assert!(err.contains("not authenticated")); +} + +#[test] +fn query_context_must_be_json_object() { + let err = match SysClient::context_map(json!(["not", "an", "object"])) { + Ok(_) => panic!("expected serialization error"), + Err(SysinspectError::SerializationError(err)) => err, + Err(other) => panic!("unexpected error: {other}"), + }; + + assert!(err.contains("JSON object")); +} + +#[test] +fn query_context_stringifies_non_string_values() { + let context = SysClient::context_map(json!({ + "n": 42, + "b": true, + "s": "text" + })) + .unwrap(); + + assert_eq!(context.get("n"), Some(&"42".to_string())); + assert_eq!(context.get("b"), Some(&"true".to_string())); + assert_eq!(context.get("s"), Some(&"text".to_string())); +} diff --git a/sysclient/src/main.rs b/sysclient/src/main.rs index 6e33dcf0..82caf99c 100644 --- a/sysclient/src/main.rs +++ b/sysclient/src/main.rs @@ -2,7 +2,7 @@ //! //! This example demonstrates how to use the Sysinspect client to authenticate a user. //! It prompts the user for their username and password, then attempts to authenticate with the Sysinspect -//! server. If successful, it prints the session ID; otherwise, it indicates that authentication failed. +//! server. If successful, it prints the bearer token; otherwise, it indicates that authentication failed. //! use libcommon::SysinspectError; @@ -29,8 +29,8 @@ async fn main() -> Result<(), SysinspectError> { let mut client = SysClient::new(cfg); match client.authenticate(&uid, &pwd).await { - Ok(sid) => { - println!("Authentication successful, session ID: {sid}"); + Ok(token) => { + println!("Authentication successful, bearer token: {token}"); } Err(e) => { return Err(SysinspectError::MasterGeneralError(format!("Authentication error: {e}"))); diff --git a/sysclient/tests/client_contract.rs b/sysclient/tests/client_contract.rs new file mode 100644 index 00000000..74392a01 --- /dev/null +++ b/sysclient/tests/client_contract.rs @@ -0,0 +1,85 @@ +use actix_web::{App, HttpServer, web}; +use async_trait::async_trait; +use libdatastore::{cfg::DataStorageConfig, resources::DataStorage}; +use libsysinspect::cfg::mmconf::MasterConfig; +use libwebapi::{MasterInterface, MasterInterfaceType, api::{self, ApiVersions}}; +use std::{fs, path::Path, sync::Arc}; +use sysinspect_client::{ModelNameResponse, QueryResponse, SysClient, SysClientConfiguration}; +use tokio::{sync::Mutex, task::JoinHandle, time::{Duration, sleep}}; + +struct TestMaster { + cfg: MasterConfig, + queries: Arc>>, + datastore: Arc>, +} + +#[async_trait] +impl MasterInterface for TestMaster { + async fn cfg(&self) -> &MasterConfig { + &self.cfg + } + + async fn query(&mut self, query: String) -> Result<(), libcommon::SysinspectError> { + self.queries.lock().await.push(query); + Ok(()) + } + + async fn datastore(&self) -> Arc> { + Arc::clone(&self.datastore) + } +} + +fn write_cfg(root: &Path) -> MasterConfig { + let cfg_path = root.join("sysinspect.conf"); + fs::write( + &cfg_path, + "config:\n master:\n fileserver.models: [cm, net]\n api.bind.ip: 127.0.0.1\n api.bind.port: 4202\n api.devmode: true\n", + ) + .unwrap(); + MasterConfig::new(cfg_path).unwrap() +} + +async fn spawn_http_server() -> (String, Arc>>, JoinHandle>) { + let root = tempfile::tempdir().unwrap(); + let cfg = write_cfg(root.path()); + let queries = Arc::new(Mutex::new(Vec::new())); + let datastore = Arc::new(Mutex::new(DataStorage::new(DataStorageConfig::new(), root.path().join("datastore")).unwrap())); + let master: MasterInterfaceType = Arc::new(Mutex::new(TestMaster { cfg, queries: Arc::clone(&queries), datastore })); + let server = HttpServer::new(move || { + let scope = api::get(true, ApiVersions::V1).unwrap().load(web::scope("")); + App::new().app_data(web::Data::new(master.clone())).service(scope) + }) + .bind(("127.0.0.1", 0)) + .unwrap(); + let addr = server.addrs()[0]; + let handle = tokio::spawn(server.run()); + sleep(Duration::from_millis(100)).await; + + (format!("http://{}", addr), queries, handle) +} + +#[tokio::test] +async fn client_authenticates_and_executes_plain_json_query() { + let (base, queries, handle) = spawn_http_server().await; + let mut client = SysClient::new(SysClientConfiguration { master_url: base, }); + + let token = client.authenticate("dev", "dev").await.unwrap(); + let response: QueryResponse = client.query("cm/file-ops", "*", "", "", serde_json::json!({"reason":"test"})).await.unwrap(); + + assert_eq!(token, "dev-token"); + assert_eq!(response.status, "success"); + assert_eq!(queries.lock().await.as_slice(), ["cm/file-ops;*;;;reason:test"]); + handle.abort(); +} + +#[tokio::test] +async fn client_lists_models_using_bearer_auth() { + let (base, _, handle) = spawn_http_server().await; + let mut client = SysClient::new(SysClientConfiguration { master_url: base }); + client.authenticate("dev", "dev").await.unwrap(); + + let models: ModelNameResponse = client.models().await.unwrap(); + + assert_eq!(models.models, vec!["cm".to_string(), "net".to_string()]); + handle.abort(); +} diff --git a/sysmaster/src/master.rs b/sysmaster/src/master.rs index 630a4ae1..b574c32d 100644 --- a/sysmaster/src/master.rs +++ b/sysmaster/src/master.rs @@ -1211,7 +1211,7 @@ pub(crate) async fn master(cfg: MasterConfig) -> Result<(), SysinspectError> { log::info!("Fileserver started on directory {}", cfg.fileserver_root().to_str().unwrap_or_default()); // Start web API (if configured/enabled) - libwebapi::start_webapi(cfg.clone(), master.clone())?; + libwebapi::start_embedded_webapi(cfg.clone(), master.clone())?; // Start services let ipc = SysMaster::do_ipc_service(Arc::clone(&master)).await; diff --git a/sysmaster/src/master_ut.rs b/sysmaster/src/master_ut.rs index 46a3ce8a..b222f885 100644 --- a/sysmaster/src/master_ut.rs +++ b/sysmaster/src/master_ut.rs @@ -6,7 +6,7 @@ use libsysinspect::{ TransportKeyExchangeModel, TransportPeerState, TransportProvisioningMode, TransportRotationStatus, secure_bootstrap::SecureBootstrapSession, }, }; -use libsysproto::secure::{SECURE_PROTOCOL_VERSION, SecureBootstrapHello, SecureFrame, SecureSessionBinding}; +use libsysproto::secure::{SECURE_PROTOCOL_VERSION, SECURE_SUPPORTED_PROTOCOL_VERSIONS, SecureBootstrapHello, SecureFrame, SecureSessionBinding}; use rsa::RsaPublicKey; use std::{collections::HashMap, time::Instant}; @@ -47,7 +47,8 @@ fn unsupported_peer_bounces_secure_bootstrap_hello() { "nonce-1".to_string(), fresh_timestamp(), ), - session_key_cipher: "cipher".to_string(), + supported_versions: SECURE_SUPPORTED_PROTOCOL_VERSIONS.to_vec(), + client_ephemeral_pubkey: "pubkey".to_string(), binding_signature: "sig".to_string(), key_id: Some("kid-1".to_string()), })) diff --git a/sysmaster/src/transport.rs b/sysmaster/src/transport.rs index dfeedbf5..b248f72a 100644 --- a/sysmaster/src/transport.rs +++ b/sysmaster/src/transport.rs @@ -13,7 +13,7 @@ use libsysinspect::{ use libsysproto::{ MasterMessage, MinionMessage, ProtoConversion, rqtypes::RequestType, - secure::{SecureBootstrapHello, SecureFrame, SecureSessionBinding}, + secure::{SECURE_SUPPORTED_PROTOCOL_VERSIONS, SecureBootstrapHello, SecureFrame, SecureSessionBinding}, }; use rsa::RsaPublicKey; use std::{ @@ -21,6 +21,10 @@ use std::{ time::{Duration as StdDuration, Instant}, }; +#[cfg(test)] +#[path = "transport_ut.rs"] +mod transport_ut; + const BOOTSTRAP_MALFORMED_WINDOW: StdDuration = StdDuration::from_secs(30); const BOOTSTRAP_REPLAY_WINDOW: StdDuration = StdDuration::from_secs(300); @@ -132,8 +136,8 @@ impl PeerTransport { .ok_or_else(|| SysinspectError::ProtoError(format!("Peer transport state for {peer_addr} disappeared")))? .channel .open_bytes(raw); - if decoded.is_err() { - log::warn!("Session for peer {} became invalid; dropping channel state", peer_addr); + if let Err(err) = &decoded { + log::warn!("Session for peer {} became invalid: {}; dropping channel state", peer_addr, err); self.peers.remove(peer_addr); } return decoded.map(IncomingFrame::Forward); @@ -147,7 +151,16 @@ impl PeerTransport { if let Some(diag) = Self::plaintext_diag(raw) { return Ok(IncomingFrame::Reply(diag)); } - Ok(IncomingFrame::Forward(raw.to_vec())) + match serde_json::from_slice::(raw) { + Ok(req) if matches!(req.req_type(), RequestType::Add) => Ok(IncomingFrame::Forward(raw.to_vec())), + Ok(_) => Ok(IncomingFrame::Reply( + serde_json::to_vec(&SecureBootstrapDiagnostics::unsupported_version( + "Plaintext minion traffic is not allowed; secure bootstrap is required", + )) + .map_err(SysinspectError::from)?, + )), + Err(_) => Err(SysinspectError::ProtoError("Unsupported pre-bootstrap peer traffic".to_string())), + } } /// Accept a bootstrap hello from a registered minion and store the resulting session for that peer. @@ -155,6 +168,19 @@ impl PeerTransport { &mut self, peer_addr: &str, hello: &SecureBootstrapHello, cfg: &MasterConfig, mkr: &mut MinionsKeyRegistry, ) -> Result, SysinspectError> { let now = Instant::now(); + if hello.supported_versions.is_empty() { + return serde_json::to_vec(&SecureBootstrapDiagnostics::unsupported_version( + "Secure bootstrap hello did not advertise any supported protocol versions", + )) + .map_err(SysinspectError::from); + } + if !hello.supported_versions.iter().any(|version| SECURE_SUPPORTED_PROTOCOL_VERSIONS.contains(version)) { + return serde_json::to_vec(&SecureBootstrapDiagnostics::unsupported_version(format!( + "No common secure transport protocol version exists between peer {:?} and local {:?}", + hello.supported_versions, SECURE_SUPPORTED_PROTOCOL_VERSIONS + ))) + .map_err(SysinspectError::from); + } if self.peers.iter().any(|(addr, peer)| addr != peer_addr && peer.minion_id == hello.binding.minion_id) { log::warn!("Rejecting duplicate bootstrap for minion {} from {}", hello.binding.minion_id, peer_addr); return serde_json::to_vec(&SecureBootstrapDiagnostics::duplicate_session(format!( @@ -172,7 +198,7 @@ impl PeerTransport { .ok_or_else(|| SysinspectError::ProtoError(format!("No managed transport state exists for {}", hello.binding.minion_id)))?; let master_prk = mkr.master_private_key()?; let minion_pbk = mkr.minion_public_key(&hello.binding.minion_id)?; - let (bootstrap, ack) = SecureBootstrapSession::accept( + let (bootstrap, ack) = match SecureBootstrapSession::accept( &state, hello, &master_prk, @@ -180,17 +206,29 @@ impl PeerTransport { None, state.active_key_id.clone().or_else(|| state.last_key_id.clone()), None, - )?; + ) { + Ok(result) => result, + Err(err) => { + log::warn!( + "Secure bootstrap authentication failed for minion {} from {}: {}", + hello.binding.minion_id, + peer_addr, + err + ); + return Err(err); + } + }; Self::record_bootstrap_replay(&mut self.bootstrap_replay_cache, &hello.binding, now); Self::promote_bootstrap_key(cfg, mkr, &mut state, hello, bootstrap.key_id())?; TransportStore::for_master_minion(cfg, &hello.binding.minion_id)?.save(&state)?; log::info!( - "Session established for minion {} from {} using key {} and protocol v{}", + "Session established for minion {} from {} using key {} and protocol v{} (rotation state: {:?})", hello.binding.minion_id, peer_addr, bootstrap.key_id(), - hello.binding.protocol_version + hello.binding.protocol_version, + state.rotation ); self.peers.insert( peer_addr.to_string(), @@ -250,8 +288,26 @@ impl PeerTransport { let _ = rotator.retire_elapsed_keys(Utc::now(), overlap)?; *state = rotator.state().clone(); state.set_pending_rotation_context(None); + log::info!( + "Applied staged transport rotation for {} using key {} with {}s overlap", + hello.binding.minion_id, + bootstrap_key_id, + payload.grace_seconds + ); return Ok(()); } + if let Some(context) = state.pending_rotation_context.clone() + && let Ok(payload) = serde_json::from_str::(&context) + && payload.intent.intent().next_key_id() != bootstrap_key_id + { + log::warn!( + "Minion {} bootstrapped with key {} while staged rotation expects {}; leaving rotation state as {:?}", + hello.binding.minion_id, + bootstrap_key_id, + payload.intent.intent().next_key_id(), + state.rotation + ); + } state.upsert_key(bootstrap_key_id, libsysinspect::transport::TransportKeyStatus::Active); Ok(()) } diff --git a/sysmaster/src/transport_ut.rs b/sysmaster/src/transport_ut.rs new file mode 100644 index 00000000..6d16b4a5 --- /dev/null +++ b/sysmaster/src/transport_ut.rs @@ -0,0 +1,151 @@ +use super::{PeerConnection, PeerTransport}; +use chrono::Utc; +use libsysinspect::{ + rsa::keys::{get_fingerprint, keygen}, + transport::{ + TransportKeyExchangeModel, TransportPeerState, TransportProvisioningMode, TransportRotationStatus, + secure_bootstrap::SecureBootstrapSession, + secure_channel::{SecureChannel, SecurePeerRole}, + }, +}; +use libsysproto::{ + MasterMessage, ProtoConversion, + rqtypes::RequestType, + secure::{SECURE_PROTOCOL_VERSION, SecureFrame}, +}; +use rsa::RsaPublicKey; + +fn state(master_pbk: &RsaPublicKey, minion_pbk: &RsaPublicKey) -> TransportPeerState { + TransportPeerState { + minion_id: "mid-1".to_string(), + master_rsa_fingerprint: get_fingerprint(master_pbk).unwrap(), + minion_rsa_fingerprint: get_fingerprint(minion_pbk).unwrap(), + protocol_version: SECURE_PROTOCOL_VERSION, + key_exchange: TransportKeyExchangeModel::EphemeralSessionKeys, + provisioning: TransportProvisioningMode::Automatic, + approved_at: Some(Utc::now()), + active_key_id: Some("kid-1".to_string()), + last_key_id: Some("kid-1".to_string()), + last_handshake_at: None, + rotation: TransportRotationStatus::Idle, + pending_rotation_context: None, + updated_at: Utc::now(), + keys: vec![], + } +} + +fn channels() -> (SecureChannel, SecureChannel) { + let (master_prk, master_pbk) = keygen(2048).unwrap(); + let (minion_prk, minion_pbk) = keygen(2048).unwrap(); + let state = state(&master_pbk, &minion_pbk); + let (opening, hello) = SecureBootstrapSession::open(&state, &minion_prk, &master_pbk).unwrap(); + let accepted = SecureBootstrapSession::accept( + &state, + match &hello { + SecureFrame::BootstrapHello(hello) => hello, + _ => panic!("expected bootstrap hello"), + }, + &master_prk, + &minion_pbk, + Some("sid-1".to_string()), + Some("kid-1".to_string()), + None, + ) + .unwrap(); + let ack = match accepted.1 { + SecureFrame::BootstrapAck(ack) => ack, + _ => panic!("expected bootstrap ack"), + }; + let minion = opening.verify_ack(&state, &ack, &master_pbk).unwrap(); + let master = accepted.0; + + ( + SecureChannel::new(SecurePeerRole::Master, &master).unwrap(), + SecureChannel::new(SecurePeerRole::Minion, &minion).unwrap(), + ) +} + +#[test] +fn encode_message_stays_plaintext_without_session() { + let mut transport = PeerTransport::new(); + let msg = MasterMessage::new(RequestType::Ping, serde_json::json!("general")); + + let encoded = transport.encode_message("127.0.0.1:4200", &msg).unwrap(); + + assert_eq!(encoded, msg.sendable().unwrap()); +} + +#[test] +fn encode_message_seals_payload_with_active_session() { + let mut transport = PeerTransport::new(); + let (master_channel, mut minion_channel) = channels(); + transport.peers.insert( + "127.0.0.1:4200".to_string(), + PeerConnection { minion_id: "mid-1".to_string(), channel: master_channel }, + ); + let msg = MasterMessage::new(RequestType::Ping, serde_json::json!("general")); + + let encoded = transport.encode_message("127.0.0.1:4200", &msg).unwrap(); + let opened = minion_channel.open_bytes(&encoded).unwrap(); + + assert_eq!(opened, msg.sendable().unwrap()); +} + +#[test] +fn decode_frame_drops_invalid_secure_session_state() { + let mut transport = PeerTransport::new(); + let (master_channel, mut minion_channel) = channels(); + transport.peers.insert( + "127.0.0.1:4200".to_string(), + PeerConnection { minion_id: "mid-1".to_string(), channel: master_channel }, + ); + let mut frame = minion_channel.seal(&serde_json::json!({"hello":"world"})).unwrap(); + frame.pop(); + + let root = tempfile::tempdir().unwrap(); + let err = transport + .decode_frame( + "127.0.0.1:4200", + &frame, + &libsysinspect::cfg::mmconf::MasterConfig::default(), + &mut crate::registry::mkb::MinionsKeyRegistry::new(root.path().to_path_buf()).unwrap(), + ) + .unwrap_err() + .to_string(); + + assert!(err.contains("decode secure frame") || err.contains("decode secure payload") || err.contains("Expected encrypted secure data frame")); + assert!(!transport.peers.contains_key("127.0.0.1:4200")); +} + +#[test] +fn remove_peer_clears_secure_and_plaintext_tracking() { + let mut transport = PeerTransport::new(); + let (master_channel, _) = channels(); + transport.allow_plaintext("127.0.0.1:4200"); + transport.peers.insert( + "127.0.0.1:4200".to_string(), + PeerConnection { minion_id: "mid-1".to_string(), channel: master_channel }, + ); + + transport.remove_peer("127.0.0.1:4200"); + + assert!(!transport.peers.contains_key("127.0.0.1:4200")); + assert!(!transport.plaintext_peers.contains("127.0.0.1:4200")); +} + +#[test] +fn plaintext_diag_rejects_non_registration_traffic() { + let diag = PeerTransport::plaintext_diag(br#"{"id":"mid-1","r":"ehlo","d":{},"c":0,"sid":"sid-1"}"#).unwrap(); + + assert!(matches!( + serde_json::from_slice::(&diag).unwrap(), + SecureFrame::BootstrapDiagnostic(frame) if frame.message.contains("secure bootstrap is required") + )); +} + +#[test] +fn bootstrap_diag_ignores_non_secure_plaintext() { + let mut failures = std::collections::HashMap::new(); + + assert!(PeerTransport::bootstrap_diag_with_state(&mut failures, "127.0.0.1:4200", br#"{"hello":"world"}"#).is_none()); +} diff --git a/sysminion/src/minion.rs b/sysminion/src/minion.rs index 5f28ccc3..44b3c477 100644 --- a/sysminion/src/minion.rs +++ b/sysminion/src/minion.rs @@ -376,6 +376,7 @@ impl SysMinion { let (opening, hello) = match SecureBootstrapSession::open(&state, &self.kman.private_key()?, &master_pbk) { Ok(opening) => opening, Err(err) => { + log::error!("Unable to prepare secure bootstrap for master {}: {}", self.cfg.master(), err); self.mark_broken_transport(&store, &mut state, None); return Err(err); } @@ -393,6 +394,12 @@ impl SysMinion { let session = match opening.verify_ack(&state, &ack, &master_pbk) { Ok(session) => session, Err(err) => { + log::error!( + "Secure bootstrap ack verification failed for master {} using key {}: {}", + self.cfg.master(), + opening_key_id, + err + ); self.mark_broken_transport(&store, &mut state, Some(&opening_key_id)); return Err(err); } @@ -408,10 +415,19 @@ impl SysMinion { Ok(()) } SecureFrame::BootstrapDiagnostic(diag) => { + log::error!( + "Master {} rejected secure bootstrap with {:?}: {} (retryable={}, rate_limit={})", + self.cfg.master(), + diag.code, + diag.message, + diag.failure.retryable, + diag.failure.rate_limit + ); self.mark_broken_transport(&store, &mut state, Some(&opening_key_id)); Err(SysinspectError::ProtoError(format!("Master rejected secure bootstrap with {:?}: {}", diag.code, diag.message))) } _ => { + log::error!("Master {} replied with a non-bootstrap frame during secure bootstrap", self.cfg.master()); self.mark_broken_transport(&store, &mut state, Some(&opening_key_id)); Err(SysinspectError::ProtoError("Master replied with a non-bootstrap frame during secure bootstrap".to_string())) } diff --git a/sysminion/src/minion_ut.rs b/sysminion/src/minion_ut.rs index 83c861d5..006dbf1c 100644 --- a/sysminion/src/minion_ut.rs +++ b/sysminion/src/minion_ut.rs @@ -634,4 +634,65 @@ mod tests { // Should not panic minion.request(b"abc".to_vec()).await; } + + #[tokio::test] + async fn bootstrap_secure_fails_on_truncated_reply_frame() { + let _guard = TEST_LOCK.lock().await; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + tokio::spawn(async move { + let (mut sock, _) = listener.accept().await.unwrap(); + let mut lenb = [0u8; 4]; + sock.read_exact(&mut lenb).await.unwrap(); + let mut hello = vec![0u8; u32::from_be_bytes(lenb) as usize]; + sock.read_exact(&mut hello).await.unwrap(); + assert!(!hello.is_empty()); + + sock.write_all(&32u32.to_be_bytes()).await.unwrap(); + sock.write_all(br#"{"kind":"bootstrap_ack""#).await.unwrap(); + sock.flush().await.unwrap(); + }); + + let tmp = tempfile::tempdir().unwrap(); + let (_, master_pbk) = keygen(2048).unwrap(); + key_to_file(&Public(master_pbk), tmp.path().to_str().unwrap(), CFG_MASTER_KEY_PUB).unwrap(); + + let mut cfg = MinionConfig::default(); + cfg.set_master_ip(&addr.ip().to_string()); + cfg.set_master_port(addr.port().into()); + cfg.set_root_dir(tmp.path().to_str().unwrap()); + + let dpq = Arc::new(DiskPersistentQueue::open(tmp.path().join("pending-tasks")).unwrap()); + let minion = SysMinion::new(cfg, None, dpq).await.unwrap(); + let err = minion.bootstrap_secure().await.unwrap_err().to_string(); + + assert!(err.contains("decode secure bootstrap reply") || err.contains("early eof")); + } + + #[tokio::test] + async fn repeated_reconnect_signals_do_not_hang_instance_shutdown() { + let _guard = TEST_LOCK.lock().await; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + tokio::spawn(async move { + let (_sock, _) = listener.accept().await.unwrap(); + tokio::time::sleep(Duration::from_secs(60)).await; + }); + + let tmp = tempfile::tempdir().unwrap(); + let cfg = mk_cfg(format!("{addr}"), "127.0.0.1:1".to_string(), tmp.path()); + let dpq = Arc::new(DiskPersistentQueue::open(tmp.path().join("pending-tasks")).unwrap()); + let handle = tokio::spawn(async move { _minion_instance(cfg, None, dpq).await }); + + tokio::time::sleep(Duration::from_millis(200)).await; + let _ = CONNECTION_TX.send(()); + let _ = CONNECTION_TX.send(()); + let _ = CONNECTION_TX.send(()); + + assert!(timeout(Duration::from_secs(2), handle).await.is_ok(), "instance did not exit under reconnect storm"); + } }