From b05d32b730997648e72bfa6734c45ab5fe3e393b Mon Sep 17 00:00:00 2001 From: rigazilla Date: Fri, 25 Sep 2026 18:31:42 +0200 Subject: [PATCH] feat: SASL/SCRAM authentication end-to-end (SCRAM-SHA-1/256/512) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the client to authenticate against a secured Infinispan server. `RemoteCache::setAuthentication(user, pass, realm, serverName, mechanism)` (flat setter, default SCRAM-SHA-256) enables a SASL handshake that runs on every connection the client opens β€” the seed connection and every topology-discovered member β€” inside MultiplexedConnection::connect(), after TCP connect and before the read-loop thread starts. - Generalize SCRAM across the digest family (SHA-1/256/512) over OpenSSL EVP; PBKDF2/HMAC/H lengths derived from EVP_MD_size. SHA-256 stays the default. - New SaslAuthenticator driving the two-turn SCRAM exchange over a testable SaslTransport seam (ConnectionSaslTransport in production). The client completes on its own side after verifying the server signature β€” no confirmation round, ignores the server `completed` flag (mirrors Java AuthHandler; avoids Elytron ELY05037). Non-SCRAM mechanisms throw a typed HotRodClientException at connect(). - New public headers: Authentication.h, AuthCodec.h, SaslAuthenticator.h. - Tests: +19 unit (AuthCodec + parameterized SaslAuthenticator over a fake OpenSSL server, incl. failure paths) and +5 integration (AuthIntegrationTest: SCRAM-SHA-1/256/512 put/get, wrong-password, no-credentials) β€” green live against a Dockerized auth-enabled Infinispan 16. - Docs: new AsciiDoc user guide (documentation/index.adoc + topics/), with a fully written security.adoc; quickstart README auth snippet; STATUS.md, DECISIONS.md, README refreshed. Cross-checked against the authoritative sources: Java client (AuthHandler / AuthOperation) for behavior and Kaitai hotrod40.ksy for byte layout. Co-Authored-By: Claude Opus 4.8 --- CMakeLists.txt | 20 ++ README.md | 21 +- docs/DECISIONS.md | 90 +++++ docs/STATUS.md | 56 +++- documentation/index.adoc | 26 ++ .../topics/building-and-testing.adoc | 42 +++ documentation/topics/cache-operations.adoc | 25 ++ documentation/topics/client-intelligence.adoc | 18 + documentation/topics/connecting.adoc | 22 ++ .../topics/error-handling-and-retry.adoc | 31 ++ documentation/topics/getting-started.adoc | 31 ++ documentation/topics/security.adoc | 150 +++++++++ examples/quickstart/README.md | 31 ++ include/hotrod/AuthCodec.h | 79 +++++ include/hotrod/Authentication.h | 34 ++ include/hotrod/MultiplexedConnection.h | 11 + include/hotrod/RemoteCache.h | 39 +++ include/hotrod/SCRAM.h | 34 +- include/hotrod/SaslAuthenticator.h | 95 ++++++ scripts/start_infinispan_auth.sh | 68 ++++ src/auth/SCRAM.cpp | 86 +++-- src/auth/SaslAuthenticator.cpp | 235 ++++++++++++++ src/operations/RemoteCache.cpp | 2 + src/transport/MultiplexedConnection.cpp | 24 ++ test-configs/auth-groups.properties | 3 + test-configs/auth-users.properties | 5 + test-configs/infinispan-auth.xml | 64 ++++ tests/integration/AuthIntegrationTest.cpp | 157 +++++++++ tests/unit/AuthCodecTest.cpp | 94 ++++++ tests/unit/SaslAuthenticatorTest.cpp | 307 ++++++++++++++++++ 30 files changed, 1853 insertions(+), 47 deletions(-) create mode 100644 documentation/index.adoc create mode 100644 documentation/topics/building-and-testing.adoc create mode 100644 documentation/topics/cache-operations.adoc create mode 100644 documentation/topics/client-intelligence.adoc create mode 100644 documentation/topics/connecting.adoc create mode 100644 documentation/topics/error-handling-and-retry.adoc create mode 100644 documentation/topics/getting-started.adoc create mode 100644 documentation/topics/security.adoc create mode 100644 include/hotrod/AuthCodec.h create mode 100644 include/hotrod/Authentication.h create mode 100644 include/hotrod/SaslAuthenticator.h create mode 100755 scripts/start_infinispan_auth.sh create mode 100644 src/auth/SaslAuthenticator.cpp create mode 100644 test-configs/auth-groups.properties create mode 100644 test-configs/auth-users.properties create mode 100644 test-configs/infinispan-auth.xml create mode 100644 tests/integration/AuthIntegrationTest.cpp create mode 100644 tests/unit/AuthCodecTest.cpp create mode 100644 tests/unit/SaslAuthenticatorTest.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 67cd98c..3f4cb25 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -35,6 +35,7 @@ set(SOURCES src/codec/Codec.cpp src/codec/HeaderCodec.cpp src/auth/SCRAM.cpp + src/auth/SaslAuthenticator.cpp src/topology/TopologyInfo.cpp src/hash/MurmurHash3.cpp src/hash/ConsistentHash.cpp @@ -91,6 +92,8 @@ if(GTest_FOUND) tests/unit/HashAwareRoutingTest.cpp tests/unit/ErrorHandlingTest.cpp tests/unit/ServerSelectionTest.cpp + tests/unit/AuthCodecTest.cpp + tests/unit/SaslAuthenticatorTest.cpp ) target_link_libraries(unit_tests @@ -310,6 +313,18 @@ if(GTest_FOUND) # Note: NOT gtest_main - we provide our own main() ) + # SASL/SCRAM authentication integration tests (auth-enabled single server) + add_executable(auth_integration_tests + tests/integration/AuthIntegrationTest.cpp + ) + + target_link_libraries(auth_integration_tests + PRIVATE + hotrod-client + GTest::gtest + # Note: NOT gtest_main - we provide our own main() + ) + add_test(NAME PingIntegrationTests COMMAND ping_integration_tests) add_test(NAME GetIntegrationTests COMMAND get_integration_tests) add_test(NAME GetWithMetadataIntegrationTests COMMAND getwithmetadata_integration_tests) @@ -327,6 +342,7 @@ if(GTest_FOUND) add_test(NAME ConcurrentMultiServerTests COMMAND concurrent_multiserver_tests) add_test(NAME RetryViewIntegrationTests COMMAND retryview_integration_tests) add_test(NAME PingRetryIntegrationTests COMMAND pingretry_integration_tests) + add_test(NAME AuthIntegrationTests COMMAND auth_integration_tests) # Set working directory so scripts can be found set_tests_properties(PingIntegrationTests PROPERTIES @@ -397,6 +413,10 @@ if(GTest_FOUND) WORKING_DIRECTORY ${CMAKE_BINARY_DIR} LABELS "integration;docker;topology;multi-server;retry" ) + set_tests_properties(AuthIntegrationTests PROPERTIES + WORKING_DIRECTORY ${CMAKE_BINARY_DIR} + LABELS "integration;docker;auth" + ) endif() # NOT WIN32 (integration tests) message(STATUS "Google Test found - unit tests enabled (integration tests: non-Windows only)") diff --git a/README.md b/README.md index 58f547e..03b6390 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,8 @@ A cross-platform C++17 implementation of the Infinispan Hot Rod protocol client. πŸŽ‰ **Smart client** - hash-aware routing, automatic failover, full CRUD + -metadata ops, typed error handling and user-decided retry (keyed & keyless). +metadata ops, typed error handling and user-decided retry (keyed & keyless), +and SASL/SCRAM authentication (SCRAM-SHA-1/256/512). ## Quick Start @@ -114,7 +115,7 @@ ctest -C Release --output-on-failure ## Testing -### Unit Tests (204 tests) +### Unit Tests (223 tests) ```bash cd build ./unit_tests @@ -123,7 +124,7 @@ cd build ctest -R UnitTests --output-on-failure ``` -### Integration Tests (84 tests, 17 suites) +### Integration Tests (89 tests, 18 suites) Requires Docker to run Infinispan server: ```bash cd build @@ -162,8 +163,8 @@ _(Step numbers follow [`../hotrod-foundry/ROADMAP.md`](../hotrod-foundry/ROADMAP any headline count.)_ **Test Results** (verified 2026-09-25): -- Unit Tests: **204/204 passing** βœ… -- Integration Tests: **84/84 passing** βœ… across 17 suites (against live Infinispan via Docker) +- Unit Tests: **223/223 passing** βœ… +- Integration Tests: **89/89 passing** βœ… across 18 suites (against live Infinispan via Docker) ## Features @@ -189,6 +190,9 @@ any headline count.)_ tried nodes) + `isTransient` / `outcomeUncertain` classification (Step 11a) - βœ… **User-decided retry** (`cache.excluding(e)` bound view) for keyed ops and keyless `ping`, with automatic before-send failover (Step 11b/11c) +- βœ… **SASL/SCRAM authentication** (`setAuthentication(...)`) β€” end-to-end for the + SCRAM family (`SCRAM-SHA-1`/`SCRAM-SHA-256`/`SCRAM-SHA-512`), applied to every + connection; see [`documentation/topics/security.adoc`](documentation/topics/security.adoc) - βœ… **Integration test framework** (GoogleTest + Docker + multi-node clusters) ### Full CRUD with Smart Routing and Async Operations @@ -262,10 +266,11 @@ cpp17-client/ β”‚ β”œβ”€β”€ hash/ # MurmurHash3 + consistent hashing β”‚ └── operations/ # Hot Rod operations (with failover) β”œβ”€β”€ tests/ # Test suite -β”‚ β”œβ”€β”€ unit/ # 204 unit tests -β”‚ └── integration/ # 84 integration tests, 17 suites (multi-node clusters) +β”‚ β”œβ”€β”€ unit/ # 223 unit tests +β”‚ └── integration/ # 89 integration tests, 18 suites (multi-node clusters) +β”œβ”€β”€ documentation/ # AsciiDoc user guide (index.adoc + topics/) β”œβ”€β”€ examples/ # Usage examples -β”‚ └── quickstart/ # Simple GET/PUT/REMOVE example +β”‚ └── quickstart/ # Simple GET/PUT/REMOVE + retry + auth examples β”œβ”€β”€ scripts/ # Test infrastructure β”‚ β”œβ”€β”€ start_cluster.sh # Start multi-node cluster β”‚ β”œβ”€β”€ add_cluster_node.sh # Add node dynamically diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 0210995..d1a8627 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -614,3 +614,93 @@ With slices 1–3 done, **Step 11c is complete**: keyless ops mirror the keyed two-tier retry model (auto before-send failover, user-decided after-send), the implementation (`selectAnyServer`) is generic for future non-key ops, and the behaviour is unit-, integration-, and example-covered. + +## 2026-09-25 β€” SASL/SCRAM authentication, end-to-end (whole SCRAM family) + +**What shipped.** The client can now authenticate against a secured Infinispan +server. `RemoteCache::setAuthentication(username, password, realm="default", +serverName="infinispan", mechanism="SCRAM-SHA-256")` enables a SASL handshake that +runs on **every** connection the client opens β€” the seed connection *and* every +topology-discovered cluster member β€” inside `MultiplexedConnection::connect()`, +after the TCP connect and **before** the read-loop thread is spawned (the only +synchronous window; nothing consumes bytes before it). New public headers: +`Authentication.h` (pure-data config), `AuthCodec.h` (header-only body +encode/decode, following the `ServerSelection.h` inline precedent), and +`SaslAuthenticator.h`; implementation in `src/auth/SaslAuthenticator.cpp`. The +existing `SCRAM` crypto β€” previously **dead code** (unit-tested but never on any +code path) β€” is now generalized across the digest family and actually driven. + +**Why flat setters, not a builder.** Locked with the user: `setAuthentication(...)` +matches the existing `setClientIntelligence` / `setProtocolVersion` idiom rather +than introducing a Java-style `ConfigurationBuilder`. A builder / multi-seed model +stays a possible future thread. + +**Why the whole SCRAM family.** The API is mechanism-aware (defaults to +`SCRAM-SHA-256`). C++ has no JDK-style SASL provider framework, so each mechanism +is hand-rolled over OpenSSL; the three SCRAM variants share one generalized +implementation. `SCRAM` was parameterized by a `Digest { SHA1, SHA256, SHA512 }` +enum β†’ `EVP_MD*`, with PBKDF2/HMAC/H output lengths derived from `EVP_MD_size` +(SHA-1=20, SHA-256=32, SHA-512=64). The prior SHA-256 behavior stays the default +and the existing `SCRAMTest.cpp` still passes. Any non-SCRAM mechanism (PLAIN, +DIGEST-MD5, EXTERNAL, GSSAPI, OAUTHBEARER, …) throws a clear +`HotRodClientException` ("unsupported SASL mechanism") at connect() time. + +**Load-bearing protocol finding β€” SCRAM is exactly two client turns and the +client completes on its own side.** Confirmed against the two authoritative +sources (`AGENTS.md` order: Java client behavior, then Kaitai byte layout): + +- **Java** (`AuthHandler` / `AuthOperation`): `AuthOperation.createResponse` + returns `complete ? null : challenge`; the loop is driven by the *client* SASL + state, not the server's `completed` flag. client-firstβ†’server-first + (`completed=false`); client-finalβ†’server-final. Infinispan/Elytron sends the + server-final with **`completed=false`** carrying the `v=` server signature, and + expects **no** confirmation round β€” the client finishes once it has verified + that signature (SASL client returns null / nothing more to send). +- **Kaitai** (`hotrod40.ksy`): mech-list request `0x21` = empty body; mech-list + response `0x22` = `vint count` + NΓ—`lp_string`; auth request `0x23` = + `lp_string mech` (re-sent every round) + `lp_bytes response`; auth response + `0x24` = `u1 completed` + `lp_bytes challenge`. + +Two earlier wrong attempts, now recorded so they aren't repeated: (1) requiring +`completed=true` on the server-final hung the handshake ("SASL exchange did not +complete") β€” the server sends `completed=false`; (2) "fixing" that by adding an +empty confirmation round in a completed-flag loop triggered Elytron `ELY05037` +("mechanism client refuses to initiate authentication"). The correct behavior is: +send nothing after verifying the server signature; ignore the server `completed` +flag. This mirrors Java exactly. + +**Testable transport seam.** Hot Rod responses have **no total-length prefix**, so +a response body can only be consumed by reading its fields in order β€” the SASL +driver therefore reads *typed* fields, not a length-delimited blob. To keep the +state machine unit-testable without a socket, `SaslAuthenticator::authenticate` +runs against an abstract `SaslTransport` (send + typed reads + +`readResponseHeader()`); `ConnectionSaslTransport` is the production adapter over a +live blocking `Connection`, and its `readResponseHeader()` mirrors the read loop's +header parse (magic / messageId / opcode / status / topology-change marker, +consuming any topology update so the stream stays aligned) and surfaces server +ERROR/failed-status as a typed `HotRodClientException`. Unit tests drive a +`FakeScramServer` that computes the server side with OpenSSL, parameterized across +SCRAM-SHA-1/256/512, plus mech-not-offered / unsupported-mech / +bad-server-signature failure paths. + +**Server config gotchas (Infinispan 16), discovered while standing up the +integration fixture.** The SCRAM family derives keys from the *cleartext* +password, so the realm must store plain-text passwords: a `properties-realm` with +`plain-text="true"`. A single-port endpoint requires **both** a `hotrod-connector` +and a `rest-connector` ("There must be a REST route!"), and the connectors nest +inside `` inside ``. The image entrypoint auto-generates +`conf/users.properties`, so the test mounts non-default filenames +(`test-users.properties` / `test-groups.properties`) to avoid a read-only clash. +Auth succeeding does not create a cache, so the config pre-defines a +`distributed-cache name="authcache"` the test targets (no admin round-trip on a +secured server). See `test-configs/infinispan-auth.xml` and +`scripts/start_infinispan_auth.sh` (starts with `-p 0:11222` for an ephemeral host +port, avoiding the fixed-port cluster clash noted elsewhere). + +**Tests.** +19 unit (`AuthCodecTest` + parameterized `SaslAuthenticatorTest`) and ++5 integration (`AuthIntegrationTest`: SCRAM-SHA-1/256/512 put/get round-trips, +wrong-password throws at connect, no-credentials-against-secured-server fails), +all green live against a Dockerized auth-enabled Infinispan 16.0.7. Documented in +`documentation/topics/security.adoc` (new AsciiDoc user guide, +`documentation/index.adoc`) with a runnable snippet in +`examples/quickstart/README.md`. diff --git a/docs/STATUS.md b/docs/STATUS.md index f32b265..f9288dc 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -33,7 +33,24 @@ _(Full per-session workflow: [`WORKFLOW.md`](WORKFLOW.md).)_ _The 1–3 concrete things to do next. Keep this short and current._ -1. **Step 11c COMPLETE β€” keyless-op retry (ping) shipped (slices 1–3).** Keyless +1. **SASL/SCRAM authentication COMPLETE β€” end-to-end for the whole SCRAM + family.** `RemoteCache::setAuthentication(username, password, realm="default", + serverName="infinispan", mechanism="SCRAM-SHA-256")` (flat setter, matching + `setClientIntelligence`) enables a SASL handshake on **every** connection the + client opens β€” the seed connection and each topology-discovered member β€” run + inside `MultiplexedConnection::connect()` before the read loop starts. The + `SaslAuthenticator` drives the two-turn SCRAM exchange over a testable + `SaslTransport` seam (`ConnectionSaslTransport` in production); `SCRAM` is + generalized across `SCRAM-SHA-1/256/512` (OpenSSL EVP, digest-derived + lengths). It completes on the client side after verifying the server signature + (no confirmation round; ignores the server `completed` flag β€” mirrors Java + `AuthHandler`). Any non-SCRAM mechanism throws a clear `HotRodClientException` + at connect(). New public headers: `Authentication.h`, `AuthCodec.h`, + `SaslAuthenticator.h`. Documented in `documentation/topics/security.adoc`. + Rationale in [`DECISIONS.md`](DECISIONS.md) (2026-09-25 SASL/SCRAM entry). + **Next: pick a new roadmap step β€” Step 12 (bulk ops) or benchmark the + multiplexing path.** +2. **Step 11c COMPLETE β€” keyless-op retry (ping) shipped (slices 1–3).** Keyless operations now mirror the keyed retry strategy: automatic before-send failover across all servers, user-decided after-send retry (D2). `ping()` routes through `selectAnyServer(ctx, triedOut)`, which orders candidates via @@ -145,6 +162,17 @@ pull from here next. Step numbers follow `PingRetryIntegrationTest` (3 tests) + a keyless `pingWithRetry` in the retry example. Java ref: `org.infinispan.client.hotrod.exceptions.*`. +- [x] **SASL/SCRAM authentication β€” COMPLETE.** End-to-end auth for the whole + SCRAM family (`SCRAM-SHA-1/256/512`). `setAuthentication(...)` (flat setter) + runs the AUTH_MECH_LIST + two-turn SCRAM handshake on every connection (seed + + topology-discovered), inside `MultiplexedConnection::connect()` before the read + loop; `SaslAuthenticator` over a testable `SaslTransport` seam; `SCRAM` + generalized across the digest family (OpenSSL EVP). Completes on the client + side after verifying the server signature (no confirmation round). Non-SCRAM + mechanisms throw at connect(). New headers `Authentication.h`, `AuthCodec.h`, + `SaslAuthenticator.h`; docs `documentation/topics/security.adoc`; 19 unit + + 5 integration tests. This supersedes the earlier "SCRAM crypto exists but is + dead code" state. Java ref: `AuthHandler` / `AuthOperation`; Kaitai `hotrod40.ksy`. - [ ] **Step 12 β€” Bulk operations.** `GET_ALL` (0x2F), `PUT_ALL` (0x2D), `BULK_GET` (0x1F, iterator-style). @@ -171,7 +199,11 @@ multiplexing. See "Working and shipped" below._ **Working and shipped:** - Wire primitives (vInt, vLong, strings, byte arrays) - Protocol 4.0 headers (all conditional fields) -- SCRAM-SHA-256 authentication (RFC 5802, OpenSSL) +- **SASL/SCRAM authentication (end-to-end)** β€” `setAuthentication(...)` runs the + AUTH_MECH_LIST + two-turn SCRAM handshake on every connection (seed + + topology-discovered), for the whole family (`SCRAM-SHA-1/256/512`, RFC 5802, + OpenSSL); non-SCRAM mechanisms throw at connect(). See + `documentation/topics/security.adoc` - Cluster topology awareness (failover, load balancing) - Consistent hashing (MurmurHash3 x64_32, seed 9001) - Hash-aware routing β†’ primary owner, with automatic failover @@ -212,10 +244,14 @@ multiplexing. See "Working and shipped" below._ portability and `-Werror` build parity also landed (Sept 2026). **Test status (verified 2026-09-25):** -- Unit: **204/204** passing (`./build/unit_tests`, <1s) β€” +17 for - `ServerSelectionTest` (`orderKeyCandidates` + `unionNodes`, Step 11b slices 1–3; - +3 keyless-ordering tests for `selectAnyServer`, Step 11c slice 1) -- Integration: **84/84** passing across 17 suites (`ctest`, spins up Docker +- Unit: **223/223** passing (`./build/unit_tests`, <1s) β€” +19 for the auth work + (`AuthCodecTest` body round-trips + `SaslAuthenticatorTest`, a parameterized + SCRAM-SHA-1/256/512 handshake driven by a fake OpenSSL server, plus + mech-not-offered / unsupported-mech / bad-server-signature failure paths); the + earlier +17 for `ServerSelectionTest` (`orderKeyCandidates` + `unionNodes`, + Step 11b slices 1–3; +3 keyless-ordering tests for `selectAnyServer`, Step 11c + slice 1) still stands +- Integration: **89/89** passing across 18 suites (`ctest`, spins up Docker Infinispan single-server + multi-node clusters), now also green on Linux CI. +3 for `RetryViewIntegrationTest` (Step 11b: `excluding()` routes around an owner; proxy-disabled owners-exhausted throw; the catchβ†’`excluding(e)` retry @@ -224,11 +260,15 @@ multiplexing. See "Working and shipped" below._ to another server; excluding all servers throws BeforeSend with `ownersExhausted=false`; the catchβ†’`excluding(e).ping()` loop recovers after the first-candidate node is killed) β€” verified 3/3 this session. + +5 for `AuthIntegrationTest` (SASL/SCRAM against an auth-enabled Infinispan 16 + server started by `scripts/start_infinispan_auth.sh`): SCRAM-SHA-1/256/512 + put/get round-trips (3, parameterized), wrong-password throws at connect(), and + no-credentials-against-a-secured-server fails β€” verified 5/5 live this session. The interlaced distributed tests (`ConcurrentMultiServerTest`) were fixed to tolerate a GET racing ahead of its PUT β€” a `nullopt` is expected, only a present-but-wrong value is an error. - Full run: `ctest --test-dir build --output-on-failure` β†’ 100% pass - (18 ctest tests: 1 unit + 17 integration suites) + (19 ctest tests: 1 unit + 18 integration suites) - **Concurrency validated (2026-09-25):** the concurrent suites pass 8/8 (incl. `ConcurrentWithFailover`), and a ThreadSanitizer run (`build-tsan/`) reports **no races in production code** β€” slice-4's `stateMutex_`/`shared_ptr` @@ -249,6 +289,8 @@ full list (Steps 10–12, benchmarks, TLS, code TODOs). | What | Where | |------|-------| | Public API | `include/hotrod/RemoteCache.h` | +| Authentication (SASL/SCRAM) | `include/hotrod/Authentication.h`, `AuthCodec.h`, `SaslAuthenticator.h`; `src/auth/SaslAuthenticator.cpp`, `SCRAM.cpp` | +| User guide (AsciiDoc) | `documentation/index.adoc` + `documentation/topics/` | | Retry API (bound view) | `include/hotrod/RetryView.h`, `RetryContext.h`; routing helpers in `ServerSelection.h` | | Async transport core | `src/transport/MultiplexedConnection.cpp` | | Operations (PING/GET/PUT/REMOVE) | `src/operations/RemoteCache.cpp` | diff --git a/documentation/index.adoc b/documentation/index.adoc new file mode 100644 index 0000000..f48cb59 --- /dev/null +++ b/documentation/index.adoc @@ -0,0 +1,26 @@ += Infinispan Hot Rod C++17 Client Guide +:toc: left +:toclevels: 3 + +This guide covers the Infinispan Hot Rod C++17 client for the Hot Rod binary +protocol. The client speaks protocol 4.0/4.1, routes keys to their primary owner +(hash-aware), fails over automatically, and exposes every operation as a +`std::future`. + +NOTE: The single source of truth for *what is implemented today* is +`docs/STATUS.md` in the repository root. If a chapter here describes a feature as +a stub, STATUS.md is authoritative for whether it has shipped. + +include::topics/getting-started.adoc[] + +include::topics/connecting.adoc[] + +include::topics/security.adoc[] + +include::topics/cache-operations.adoc[] + +include::topics/client-intelligence.adoc[] + +include::topics/error-handling-and-retry.adoc[] + +include::topics/building-and-testing.adoc[] diff --git a/documentation/topics/building-and-testing.adoc b/documentation/topics/building-and-testing.adoc new file mode 100644 index 0000000..a13a838 --- /dev/null +++ b/documentation/topics/building-and-testing.adoc @@ -0,0 +1,42 @@ +== Building and Testing + +NOTE: This chapter is a stub. See `README.md` for full platform-specific +instructions. + +=== Building + +[source,shell] +---- +cmake -S . -B build +cmake --build build +---- + +The build is strict: `-Wall -Wextra -Werror -pedantic`, C++17. The default build +type is `RelWithDebInfo` so local builds surface the same optimizer/`-Werror` +warnings as CI. + +=== Unit tests + +[source,shell] +---- +./build/unit_tests +# or +ctest --test-dir build -R UnitTests --output-on-failure +---- + +=== Integration tests + +Integration tests drive a real Infinispan server via Docker and are built on +non-Windows platforms only. + +[source,shell] +---- +ctest --test-dir build --output-on-failure +---- + +The SASL/SCRAM auth integration suite (`auth_integration_tests`) starts an +auth-enabled server via `scripts/start_infinispan_auth.sh` and exercises the +SCRAM-SHA-1/256/512 round-trip, wrong-password, and no-credentials cases. + +CAUTION: The multi-node cluster suites bind fixed host ports (11222/11322/…); +free 11222 before running them locally. diff --git a/documentation/topics/cache-operations.adoc b/documentation/topics/cache-operations.adoc new file mode 100644 index 0000000..7f78330 --- /dev/null +++ b/documentation/topics/cache-operations.adoc @@ -0,0 +1,25 @@ +== Cache Operations + +NOTE: This chapter is a stub. See `include/hotrod/RemoteCache.h` for the full, +current operation set. + +Keys and values are `ByteArray`. Every operation is asynchronous and returns a +`std::future`. + +=== Basic operations + +[source,cpp] +---- +cache.put(key, value).get(); // store +auto v = cache.get(key).get(); // std::optional +bool removed = cache.remove(key).get(); // delete +cache.ping().get(); // connectivity check +---- + +=== Metadata and version-based (CAS) operations + +The client also implements `getWithMetadata`, `putIfAbsent`, `replace`, +`containsKey`, `removeWithVersion`, and `replaceWithVersion` (Step 10). The +version returned by `getWithMetadata` is the input to the conditional +version-based writes. See STATUS.md and the header for exact signatures and +return types. diff --git a/documentation/topics/client-intelligence.adoc b/documentation/topics/client-intelligence.adoc new file mode 100644 index 0000000..6ff71ce --- /dev/null +++ b/documentation/topics/client-intelligence.adoc @@ -0,0 +1,18 @@ +== Client Intelligence + +NOTE: This chapter is a stub. See `include/hotrod/Types.h` (`ClientIntelligence`) +and `include/hotrod/RemoteCache.h`. + +Client intelligence controls how much cluster awareness the client uses: + +* `BASIC` -- talk only to the seed server; no topology or routing. +* `TOPOLOGY_AWARE` -- learn the cluster membership and fail over across it. +* `HASH_DISTRIBUTION_AWARE` -- additionally route each key straight to its primary + owner via consistent hashing (MurmurHash3 x64_32). + +[source,cpp] +---- +cache.setClientIntelligence(ClientIntelligence::HASH_DISTRIBUTION_AWARE); +---- + +Set it before `connect()`. diff --git a/documentation/topics/connecting.adoc b/documentation/topics/connecting.adoc new file mode 100644 index 0000000..d8e2f12 --- /dev/null +++ b/documentation/topics/connecting.adoc @@ -0,0 +1,22 @@ +== Connecting + +NOTE: This chapter is a stub. See `include/hotrod/RemoteCache.h` for the full API +and `examples/quickstart/` for working code. + +You create a `RemoteCache` for a seed server and a cache name, tune it with +setters, then `connect()`: + +[source,cpp] +---- +RemoteCache cache("localhost", 11222, "my-cache"); +cache.setClientIntelligence(ClientIntelligence::HASH_DISTRIBUTION_AWARE); +cache.setAuthentication("username", "password"); // optional; see Security +cache.connect(); +// ... operations ... +cache.disconnect(); +---- + +All settings (client intelligence, protocol version, authentication) must be +configured **before** `connect()`. Once connected, the client discovers the rest +of the cluster from the server's topology updates and pools a connection per +member. diff --git a/documentation/topics/error-handling-and-retry.adoc b/documentation/topics/error-handling-and-retry.adoc new file mode 100644 index 0000000..4d54f4c --- /dev/null +++ b/documentation/topics/error-handling-and-retry.adoc @@ -0,0 +1,31 @@ +== Error Handling and Retry + +NOTE: This chapter is a stub. The full design lives in +`docs/ERROR_HANDLING_DESIGN.md`; a runnable example is +`examples/quickstart/retry.cpp`. + +Server errors and transport failures surface as `HotRodClientException` (still +catchable as `std::runtime_error`). It carries pure data: a `FailurePhase` +(`BeforeSend` / `AfterSend` / `ServerError`), the server status, the nodes tried, +and an `ownersExhausted` flag. Two free helpers classify a failure: + +* `isTransient(e)` -- is a retry potentially useful? +* `outcomeUncertain(e)` -- might the operation have taken effect despite the error? + +Retry is **user-decided**, not automatic. On a caught exception, feed it back as +the exclusion set for the next attempt via the bound-view API: + +[source,cpp] +---- +try { + cache.put(key, value).get(); +} catch (const HotRodClientException& e) { + if (isTransient(e)) { + cache.excluding(e).put(key, value).get(); // retry, avoiding tried nodes + } else { + throw; + } +} +---- + +The same `excluding(e)` pattern works for keyless operations such as `ping()`. diff --git a/documentation/topics/getting-started.adoc b/documentation/topics/getting-started.adoc new file mode 100644 index 0000000..c38b69d --- /dev/null +++ b/documentation/topics/getting-started.adoc @@ -0,0 +1,31 @@ +== Getting Started + +NOTE: This chapter is a stub. For a complete, runnable example today see +`examples/quickstart/` and `README.md` in the repository root. + +The Hot Rod C++17 client is a static library (`hotrod-client`) built with CMake. +It requires a C++17 compiler, CMake 3.15+, and OpenSSL 1.1.1+ or 3.x. + +A minimal program: + +[source,cpp] +---- +#include "hotrod/RemoteCache.h" + +using namespace hotrod; + +int main() { + RemoteCache cache("localhost", 11222, "my-cache"); + cache.connect(); + + cache.put(ByteArray{'k'}, ByteArray{'v'}).get(); + auto value = cache.get(ByteArray{'k'}).get(); // std::optional + + cache.disconnect(); + return 0; +} +---- + +Keys and values are `ByteArray` (a `std::vector`); the caller marshals +its own types. Every operation returns a `std::future`; call `.get()` to block +for the result. diff --git a/documentation/topics/security.adoc b/documentation/topics/security.adoc new file mode 100644 index 0000000..2ce7d98 --- /dev/null +++ b/documentation/topics/security.adoc @@ -0,0 +1,150 @@ +== Security + +=== Authentication + +The client authenticates over SASL. The **SCRAM family** is implemented: + +* **SCRAM-SHA-256** -- the default when you call `setAuthentication(...)`. +* **SCRAM-SHA-1** +* **SCRAM-SHA-512** + +Any other mechanism (`PLAIN`, `DIGEST-MD5`, `EXTERNAL`, `GSSAPI`, +`OAUTHBEARER`, …) throws a `HotRodClientException` at `connect()` time with a +clear "unsupported SASL mechanism" message. C++ has no JDK-style SASL provider +framework, so each mechanism is hand-rolled over OpenSSL; only the SCRAM variants +share the generalized implementation today. + +==== Configuring authentication + +Authentication is configured with a flat setter on `RemoteCache`, matching the +existing `setClientIntelligence` / `setProtocolVersion` idiom. Call it **before +`connect()`**: + +[source,cpp] +---- +#include "hotrod/RemoteCache.h" +#include "hotrod/HotRodClientException.h" + +using namespace hotrod; + +RemoteCache cache("localhost", 11222, "my-cache"); + +// Username + password are all SCRAM needs. Mechanism defaults to SCRAM-SHA-256. +cache.setAuthentication("username", "password"); + +try { + cache.connect(); +} catch (const HotRodClientException& e) { + // Wrong credentials, an unsupported mechanism, or a mechanism the server + // does not offer all surface here, at connect() time. + std::cerr << "Authentication failed: " << e.what() << '\n'; + return 1; +} + +// Every operation now runs over an authenticated connection. +cache.put(ByteArray{'k'}, ByteArray{'v'}).get(); +---- + +The full setter signature is: + +[source,cpp] +---- +void setAuthentication(const std::string& username, + const std::string& password, + const std::string& realm = "default", + const std::string& serverName = "infinispan", + const std::string& mechanism = "SCRAM-SHA-256"); +---- + +To use a different SCRAM variant, pass the mechanism explicitly: + +[source,cpp] +---- +cache.setAuthentication("username", "password", + "default", "infinispan", "SCRAM-SHA-512"); +---- + +==== Defaults + +[cols="1,2"] +|=== +| Parameter | Default + +| `mechanism` | `SCRAM-SHA-256` +| `realm` | `default` +| `serverName` | `infinispan` +|=== + +These match the Java client's `AuthenticationConfiguration` defaults. + +==== realm and serverName are unused by SCRAM + +For the SCRAM mechanisms **only `username` and `password` enter the exchange**. +`realm` and `serverName` are accepted and stored for parity with the Java client +(and for future mechanisms that need them), but the SCRAM handshake does not use +them. You can leave them at their defaults. + +=== Credentials apply to every connection + +Authentication is a property of the client, not of a single socket. When +authentication is enabled, the client runs the SASL handshake +(`AUTH_MECH_LIST` + `AUTH`) on **every connection it opens** β€” the seed +connection you named in the constructor *and* every cluster member the client +later discovers through topology updates β€” before that connection carries any +operation. You configure credentials once; the client re-authenticates +transparently as it pools new connections during failover and rebalancing. + +=== How the SCRAM handshake works + +Per connection, before the read loop starts: + +. `AUTH_MECH_LIST_REQUEST` β†’ the client reads the server's offered mechanisms and + verifies the configured one is present (otherwise it throws). +. **client-first** β†’ **server-first**: the client sends its nonce; the server + replies with the combined nonce, salt, and iteration count. +. **client-final** β†’ **server-final**: the client sends its proof; the server + replies with its signature, which the client verifies for mutual + authentication. + +SCRAM completes on the **client** side once it has verified the server signature +(mirroring the Java client's `AuthHandler`): the client sends no confirmation +round and does not depend on the server's `completed` flag. Infinispan/Elytron +sends the server-final with `completed=false`; sending an extra empty +confirmation triggers an Elytron error (`ELY05037`). + +The wire layout was cross-checked against the two authoritative sources: the Java +client (`AuthHandler` / `AuthOperation`) for behavior and the Kaitai schema +`hotrod40.ksy` for byte layout. + +=== Server configuration + +The SCRAM family derives keys from the cleartext password, so the server's +security realm must store passwords in a form SCRAM can use β€” a properties realm +with `plain-text="true"` is the simplest. A minimal Hot Rod endpoint offering the +SCRAM family looks like this (see `test-configs/infinispan-auth.xml` for the full +tested config): + +[source,xml] +---- + + + + + + +... + + + + + + + + +---- + +=== TLS / mutual TLS + +Transport encryption (TLS/SSL) is **not yet implemented**. See `docs/STATUS.md` +for the current roadmap. diff --git a/examples/quickstart/README.md b/examples/quickstart/README.md index 1c4c4c0..8448179 100644 --- a/examples/quickstart/README.md +++ b/examples/quickstart/README.md @@ -198,6 +198,37 @@ the retry path (the same scenarios are covered by `tests/integration/RetryViewIntegrationTest.cpp` for keyed ops and `tests/integration/PingRetryIntegrationTest.cpp` for keyless `ping`). +## Authentication (SASL/SCRAM) + +To talk to a **secured** server, configure credentials before `connect()` with +`setAuthentication(...)`. Nothing else about the API changes β€” every operation +then runs over an authenticated connection (the seed connection and every +cluster member the client discovers): + +```cpp +RemoteCache cache("localhost", 11222, "my-cache"); + +// Username + password are all SCRAM needs. Mechanism defaults to SCRAM-SHA-256. +cache.setAuthentication("username", "password"); + +try { + cache.connect(); +} catch (const HotRodClientException& e) { + // Wrong credentials / unsupported or unoffered mechanism all surface here. + std::cerr << "Authentication failed: " << e.what() << '\n'; + return 1; +} + +cache.put(key, value).get(); // over an authenticated connection +``` + +Supported mechanisms are the SCRAM family β€” `SCRAM-SHA-256` (default), +`SCRAM-SHA-1`, `SCRAM-SHA-512`; pass a different one as the fifth argument. For +SCRAM only username/password enter the exchange (`realm`/`serverName` are accepted +for parity but unused). Full details and server-config guidance are in +[`documentation/topics/security.adoc`](../../documentation/topics/security.adoc); +the end-to-end tests live in `tests/integration/AuthIntegrationTest.cpp`. + ## Files - `quickstart.cpp` - Basic PUT/GET/REMOVE example diff --git a/include/hotrod/AuthCodec.h b/include/hotrod/AuthCodec.h new file mode 100644 index 0000000..788ab40 --- /dev/null +++ b/include/hotrod/AuthCodec.h @@ -0,0 +1,79 @@ +#pragma once + +#include "Types.h" +#include "Codec.h" +#include +#include + +namespace hotrod { + +/** + * Encode/decode the *bodies* of the SASL authentication messages (the request + * header is built separately by HeaderCodec). Pure, I/O-free helpers so they can + * be unit-tested directly β€” same header-only inline precedent as ServerSelection.h. + * + * Wire layout (authoritative: Kaitai hotrod40.ksy; all length prefixes are vint): + * - AUTH_MECH_LIST_REQUEST (0x21): header only, empty body. + * - AUTH_MECH_LIST_RESPONSE (0x22): vint mech_count, then mech_count x lp_string. + * - AUTH_REQUEST (0x23): lp_string mech + lp_bytes response_data. + * - AUTH_RESPONSE (0x24): u1 completed (0 = false) + lp_bytes challenge_data. + * + * Reference: + * - Java: AuthMechListOperation / AuthOperation (writeOperationRequest / createResponse) + */ + +/** + * Result of decoding an AUTH_RESPONSE (0x24) body. + */ +struct AuthResponseBody { + bool completed = false; // SASL exchange complete (server set the flag) + ByteArray challenge; // server challenge / final SASL bytes (may be empty) +}; + +/** + * AUTH_MECH_LIST_REQUEST (0x21) body β€” empty by protocol. + */ +inline ByteArray encodeAuthMechListRequestBody() { + return {}; +} + +/** + * Decode an AUTH_MECH_LIST_RESPONSE (0x22) body into the offered mechanism names. + */ +inline std::vector decodeAuthMechListResponse(const ByteArray& body) { + size_t offset = 0; + VInt count = Codec::readVInt(body, offset); + std::vector mechs; + mechs.reserve(count); + for (VInt i = 0; i < count; ++i) { + mechs.push_back(Codec::readString(body, offset)); + } + return mechs; +} + +/** + * Encode an AUTH_REQUEST (0x23) body: mechanism name + SASL response token. + * The mechanism name is (re-)sent on every round of the exchange. + */ +inline ByteArray encodeAuthRequestBody(const std::string& mechanism, + const ByteArray& saslResponse) { + ByteArray body; + Codec::writeString(body, mechanism); + Codec::writeByteArray(body, saslResponse); + return body; +} + +/** + * Decode an AUTH_RESPONSE (0x24) body: completed flag + challenge token. + */ +inline AuthResponseBody decodeAuthResponse(const ByteArray& body) { + AuthResponseBody out; + // completed is a plain 1-byte u1 flag (not a vint), followed by lp_bytes. + size_t offset = 0; + out.completed = body.at(offset) != 0; + ++offset; + out.challenge = Codec::readByteArray(body, offset); + return out; +} + +} // namespace hotrod diff --git a/include/hotrod/Authentication.h b/include/hotrod/Authentication.h new file mode 100644 index 0000000..23d8caa --- /dev/null +++ b/include/hotrod/Authentication.h @@ -0,0 +1,34 @@ +#pragma once + +#include + +namespace hotrod { + +/** + * SASL authentication configuration for a client connection. + * + * Pure data, applied before connect(). When `enabled` is true the client runs + * the SASL handshake (AUTH_MECH_LIST + AUTH) on every connection it opens β€” the + * seed connection and every topology-discovered cluster member β€” before the + * connection is used for operations. + * + * Only the SCRAM family is implemented (SCRAM-SHA-1 / SCRAM-SHA-256 / + * SCRAM-SHA-512); any other configured mechanism throws a HotRodClientException + * at connect time. For the SCRAM mechanisms only `username` and `password` + * actually enter the exchange; `realm` and `serverName` are carried for parity + * with the Java client (and future mechanisms) but are unused by SCRAM. + * + * Reference: + * - Java: org.infinispan.client.hotrod.configuration.AuthenticationConfiguration + * (defaults: mechanism "SCRAM-SHA-256", realm "default", serverName "infinispan") + */ +struct Authentication { + bool enabled = false; + std::string username; + std::string password; + std::string realm = "default"; + std::string serverName = "infinispan"; + std::string mechanism = "SCRAM-SHA-256"; +}; + +} // namespace hotrod diff --git a/include/hotrod/MultiplexedConnection.h b/include/hotrod/MultiplexedConnection.h index 1a9f35f..3632e00 100644 --- a/include/hotrod/MultiplexedConnection.h +++ b/include/hotrod/MultiplexedConnection.h @@ -3,6 +3,7 @@ #include "Types.h" #include "TopologyInfo.h" #include "HeaderCodec.h" +#include "Authentication.h" #include #include #include @@ -141,6 +142,13 @@ class MultiplexedConnection { */ void setClientIntelligence(ClientIntelligence intelligence); + /** + * Set SASL authentication configuration. + * Must be called before connect(). When auth.enabled is true, connect() runs + * the SASL handshake on the socket before starting the read loop. + */ + void setAuthentication(const Authentication& auth); + /** * Get current topology ID. */ @@ -178,6 +186,9 @@ class MultiplexedConnection { ClientIntelligence clientIntelligence_{ClientIntelligence::BASIC}; std::atomic topologyId_{0}; + // SASL authentication (disabled by default). Applied during connect(). + Authentication auth_; + /** * Read loop (runs in dedicated thread). * Continuously reads responses and dispatches to waiters. diff --git a/include/hotrod/RemoteCache.h b/include/hotrod/RemoteCache.h index e13b5fd..02e2387 100644 --- a/include/hotrod/RemoteCache.h +++ b/include/hotrod/RemoteCache.h @@ -6,6 +6,7 @@ #include "HeaderCodec.h" #include "ConsistentHash.h" #include "RetryContext.h" +#include "Authentication.h" #include #include #include @@ -323,6 +324,43 @@ class RemoteCache { return clientIntelligence_; } + /** + * Configure SASL authentication. Must be called before connect(). + * + * Enables the SASL handshake on every connection the client opens (the seed + * connection and every topology-discovered cluster member). Only the SCRAM + * family is supported β€” SCRAM-SHA-1 / SCRAM-SHA-256 / SCRAM-SHA-512 β€” any + * other mechanism throws a HotRodClientException at connect() time. + * + * For SCRAM only username/password enter the exchange; realm and serverName + * are accepted for parity with the Java client but are unused by SCRAM. + * + * @param username SASL authentication username + * @param password SASL authentication password + * @param realm Security realm (default "default"; unused by SCRAM) + * @param serverName SASL server name (default "infinispan"; unused by SCRAM) + * @param mechanism SASL mechanism (default "SCRAM-SHA-256") + */ + void setAuthentication(const std::string& username, + const std::string& password, + const std::string& realm = "default", + const std::string& serverName = "infinispan", + const std::string& mechanism = "SCRAM-SHA-256") { + auth_.enabled = true; + auth_.username = username; + auth_.password = password; + auth_.realm = realm; + auth_.serverName = serverName; + auth_.mechanism = mechanism; + } + + /** + * Current authentication configuration (auth_.enabled is false by default). + */ + const Authentication& getAuthentication() const { + return auth_; + } + /** * Control non-owner proxy fallback for hash-aware routing (Step 11b, D5). * @@ -443,6 +481,7 @@ class RemoteCache { uint8_t protocolVersion_; // Protocol version (default: VERSION_41) ClientIntelligence clientIntelligence_; // Client intelligence level bool proxyToNonOwner_ = true; // Owner->non-owner fallback in routing (D5) + Authentication auth_; // SASL auth config (disabled by default); applied at connect() // stateMutex_ guards the routing/pool state below (topology_, consistentHash_, // connectionPool_) against concurrent access by the read-loop thread (topology diff --git a/include/hotrod/SCRAM.h b/include/hotrod/SCRAM.h index 7a10ffb..7a66718 100644 --- a/include/hotrod/SCRAM.h +++ b/include/hotrod/SCRAM.h @@ -14,6 +14,19 @@ namespace hotrod { */ class SCRAM { public: + /** + * SCRAM digest family. Selects the underlying hash for HMAC / PBKDF2 / H, + * and therefore the SaltedPassword / key / signature lengths: + * SHA-1 = 20, SHA-256 = 32, SHA-512 = 64 bytes. + */ + enum class Digest { SHA1, SHA256, SHA512 }; + + /** + * Map a SASL mechanism name to its digest. + * @throws std::runtime_error if the mechanism is not a supported SCRAM variant + */ + static Digest digestForMechanism(const std::string& mechanism); + /** * Generate a cryptographically secure random nonce. * @param length Number of bytes for the nonce @@ -55,6 +68,7 @@ class SCRAM { * @param nonce Combined nonce from server * @param salt Base64-encoded salt * @param iterations PBKDF2 iteration count + * @param digest SCRAM digest family (defaults to SHA-256) * @return Client-final-message */ static std::string createClientFinalMessage(const std::string& password, @@ -62,7 +76,8 @@ class SCRAM { const std::string& serverFirstMessage, const std::string& nonce, const std::string& salt, - int iterations); + int iterations, + Digest digest = Digest::SHA256); /** * Verify the server-final-message. @@ -73,21 +88,26 @@ class SCRAM { * @param authMessage The authentication message (client-first-bare + "," + server-first + "," + client-final-without-proof) * @param salt Base64-encoded salt * @param iterations PBKDF2 iteration count + * @param digest SCRAM digest family (defaults to SHA-256) * @return true if server signature is valid */ static bool verifyServerFinalMessage(const std::string& message, const std::string& password, const std::string& authMessage, const std::string& salt, - int iterations); + int iterations, + Digest digest = Digest::SHA256); private: - // PBKDF2-HMAC-SHA256 key derivation - static ByteArray pbkdf2(const std::string& password, const ByteArray& salt, int iterations, size_t keyLength); + // PBKDF2-HMAC key derivation; output length follows the chosen digest. + static ByteArray pbkdf2(const std::string& password, const ByteArray& salt, int iterations, Digest digest); + + // HMAC keyed by the chosen digest. + static ByteArray hmac(const ByteArray& key, const std::string& message, Digest digest); + static ByteArray hmac(const ByteArray& key, const ByteArray& message, Digest digest); - // HMAC-SHA256 - static ByteArray hmacSha256(const ByteArray& key, const std::string& message); - static ByteArray hmacSha256(const ByteArray& key, const ByteArray& message); + // H (plain hash) with the chosen digest β€” used for StoredKey. + static ByteArray hash(const ByteArray& data, Digest digest); // Base64 encoding/decoding static std::string base64Encode(const ByteArray& data); diff --git a/include/hotrod/SaslAuthenticator.h b/include/hotrod/SaslAuthenticator.h new file mode 100644 index 0000000..3bcd37d --- /dev/null +++ b/include/hotrod/SaslAuthenticator.h @@ -0,0 +1,95 @@ +#pragma once + +#include "Types.h" +#include "Authentication.h" +#include "HeaderCodec.h" // ClientIntelligence +#include +#include +#include + +namespace hotrod { + +class Connection; + +/** + * Transport seam for the SASL handshake. + * + * The Hot Rod wire has no total-length prefix on a response body, so a response + * can only be consumed by reading its fields in order. The driver therefore + * talks to the transport in typed reads rather than "read the whole body": the + * production adapter (ConnectionSaslTransport) reads from a live blocking + * Connection; unit tests supply a fake that serves canned frames. This keeps the + * SCRAM state machine fully testable without a socket. + */ +class SaslTransport { +public: + virtual ~SaslTransport() = default; + + // Send an AUTH-family request: full frame (header + body) under `opcode`. + virtual void sendRequest(uint8_t opcode, const ByteArray& body) = 0; + + // Read the next response header (magic / messageId / opcode / status, and + // consume any topology-change payload). Throws HotRodClientException on an + // ERROR (0x50) frame or a non-NO_ERROR status. Returns the response opcode + // for the caller to validate. + virtual uint8_t readResponseHeader() = 0; + + // Typed body reads (mirror Connection's helpers). + virtual uint8_t readByte() = 0; + virtual VInt readVInt() = 0; + virtual std::string readString() = 0; // lp_string + virtual ByteArray readByteArray() = 0; // lp_bytes +}; + +/** + * Production SaslTransport over a live, blocking Connection. + * + * Header parsing mirrors MultiplexedConnection::readLoop so the stream stays in + * sync even if a topology update rides along on an AUTH response; the update is + * consumed and discarded (auth runs per-connection before the read loop; fresh + * topology is picked up on the first real operation). + */ +class ConnectionSaslTransport : public SaslTransport { +public: + ConnectionSaslTransport(Connection* conn, + uint8_t protocolVersion, + ClientIntelligence intelligence, + const std::string& host, + uint16_t port); + + void sendRequest(uint8_t opcode, const ByteArray& body) override; + uint8_t readResponseHeader() override; + uint8_t readByte() override; + VInt readVInt() override; + std::string readString() override; + ByteArray readByteArray() override; + +private: + Connection* conn_; + uint8_t protocolVersion_; + ClientIntelligence intelligence_; + std::string host_; + uint16_t port_; + uint64_t nextMessageId_ = 0; +}; + +/** + * Drives a SASL SCRAM (SHA-1 / SHA-256 / SHA-512) handshake to completion over a + * SaslTransport. Every failure β€” unsupported/unoffered mechanism, bad server + * signature, or a server ERROR status β€” surfaces as HotRodClientException so + * callers handle auth failures like any other connection failure. + */ +class SaslAuthenticator { +public: + SaslAuthenticator(Authentication auth, std::string host, uint16_t port); + + // Perform the full handshake. Throws HotRodClientException on any failure. + void authenticate(SaslTransport& transport); + +private: + Authentication auth_; + std::string host_; + uint16_t port_; +}; + +} // namespace hotrod diff --git a/scripts/start_infinispan_auth.sh b/scripts/start_infinispan_auth.sh new file mode 100755 index 0000000..49e1663 --- /dev/null +++ b/scripts/start_infinispan_auth.sh @@ -0,0 +1,68 @@ +#!/bin/bash +# Start Infinispan server with SASL/SCRAM authentication enabled. +# +# Mirrors start_infinispan_noauth.sh but mounts a security-realm config plus the +# plain-text user/group property files, and exports ISPN_AUTH=true along with the +# credentials the auth integration test uses. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CONFIG_DIR="$SCRIPT_DIR/../test-configs" +CONFIG_FILE="$CONFIG_DIR/infinispan-auth.xml" +USERS_FILE="$CONFIG_DIR/auth-users.properties" +GROUPS_FILE="$CONFIG_DIR/auth-groups.properties" + +# Credentials baked into auth-users.properties. +AUTH_USER=testuser +AUTH_PASS=testpassword + +for f in "$CONFIG_FILE" "$USERS_FILE" "$GROUPS_FILE"; do + if [ ! -f "$f" ]; then + echo "ERROR: Config file not found: $f" >&2 + exit 1 + fi +done + +CONF=/opt/infinispan/server/conf + +# Start container with the auth config + property files mounted into the conf dir. +CONTAINER_ID=$(docker run -d \ + -p 0:11222 \ + -v "$CONFIG_FILE:$CONF/infinispan.xml:ro" \ + -v "$USERS_FILE:$CONF/test-users.properties:ro" \ + -v "$GROUPS_FILE:$CONF/test-groups.properties:ro" \ + quay.io/infinispan/server:latest) + +# Wait for server ready (ISPN080001 = "Infinispan Server started") +echo "Waiting for Infinispan server to start (auth mode)..." >&2 +timeout 120 bash -c " +while ! docker logs $CONTAINER_ID 2>&1 | grep -q 'ISPN080001'; do + sleep 1 +done +" || { + echo "ERROR: Server failed to start within 120 seconds" >&2 + docker logs $CONTAINER_ID >&2 + docker rm -f $CONTAINER_ID >&2 + exit 1 +} + +# Get mapped port +HOST=localhost +PORT=$(docker port $CONTAINER_ID 11222 | cut -d: -f2) + +if [ -z "$PORT" ]; then + echo "ERROR: Failed to get mapped port" >&2 + docker rm -f $CONTAINER_ID >&2 + exit 1 +fi + +# Export for C++ tests (stdout only - for parsing) +echo "export ISPN_CONTAINER_ID=$CONTAINER_ID" +echo "export ISPN_HOST=$HOST" +echo "export ISPN_PORT=$PORT" +echo "export ISPN_AUTH=true" +echo "export ISPN_USER=$AUTH_USER" +echo "export ISPN_PASS=$AUTH_PASS" + +echo "Server started: $HOST:$PORT (container: $CONTAINER_ID, auth mode, user: $AUTH_USER)" >&2 diff --git a/src/auth/SCRAM.cpp b/src/auth/SCRAM.cpp index 6cf62ae..c0bc00c 100644 --- a/src/auth/SCRAM.cpp +++ b/src/auth/SCRAM.cpp @@ -10,6 +10,26 @@ namespace hotrod { +namespace { +// Map a SCRAM digest to its OpenSSL message digest. +const EVP_MD* mdFor(SCRAM::Digest digest) { + switch (digest) { + case SCRAM::Digest::SHA1: return EVP_sha1(); + case SCRAM::Digest::SHA256: return EVP_sha256(); + case SCRAM::Digest::SHA512: return EVP_sha512(); + } + return EVP_sha256(); // unreachable; keeps the compiler happy +} +} // namespace + +// Map a SASL mechanism name to its SCRAM digest. +SCRAM::Digest SCRAM::digestForMechanism(const std::string& mechanism) { + if (mechanism == "SCRAM-SHA-1") return Digest::SHA1; + if (mechanism == "SCRAM-SHA-256") return Digest::SHA256; + if (mechanism == "SCRAM-SHA-512") return Digest::SHA512; + throw std::runtime_error("Unsupported SASL mechanism: " + mechanism); +} + // Generate cryptographically secure random nonce std::string SCRAM::generateNonce(size_t length) { ByteArray randomBytes(length); @@ -66,7 +86,8 @@ std::string SCRAM::createClientFinalMessage(const std::string& password, const std::string& serverFirstMessage, const std::string& nonce, const std::string& salt, - int iterations) { + int iterations, + Digest digest) { // Channel binding: "c=biws" (base64 of "n,,") std::string channelBinding = "biws"; @@ -76,21 +97,18 @@ std::string SCRAM::createClientFinalMessage(const std::string& password, // AuthMessage = client-first-message-bare + "," + server-first-message + "," + client-final-without-proof std::string authMessage = clientFirstMessageBare + "," + serverFirstMessage + "," + clientFinalWithoutProof; - // SaltedPassword = PBKDF2(password, salt, iterations) + // SaltedPassword = PBKDF2(password, salt, iterations); length follows the digest ByteArray saltBytes = base64Decode(salt); - ByteArray saltedPassword = pbkdf2(password, saltBytes, iterations, 32); // SHA-256 = 32 bytes + ByteArray saltedPassword = pbkdf2(password, saltBytes, iterations, digest); // ClientKey = HMAC(SaltedPassword, "Client Key") - ByteArray clientKey = hmacSha256(saltedPassword, "Client Key"); + ByteArray clientKey = hmac(saltedPassword, "Client Key", digest); - // StoredKey = SHA256(ClientKey) - ByteArray storedKey(EVP_MD_size(EVP_sha256())); - unsigned int storedKeyLen = 0; - EVP_Digest(clientKey.data(), clientKey.size(), storedKey.data(), &storedKeyLen, EVP_sha256(), nullptr); - storedKey.resize(storedKeyLen); + // StoredKey = H(ClientKey) + ByteArray storedKey = hash(clientKey, digest); // ClientSignature = HMAC(StoredKey, AuthMessage) - ByteArray clientSignature = hmacSha256(storedKey, authMessage); + ByteArray clientSignature = hmac(storedKey, authMessage, digest); // ClientProof = ClientKey XOR ClientSignature ByteArray clientProof = xorBytes(clientKey, clientSignature); @@ -104,7 +122,8 @@ bool SCRAM::verifyServerFinalMessage(const std::string& message, const std::string& password, const std::string& authMessage, const std::string& salt, - int iterations) { + int iterations, + Digest digest) { // Extract server signature from message if (message.size() < 2 || message[0] != 'v' || message[1] != '=') { return false; @@ -113,27 +132,29 @@ bool SCRAM::verifyServerFinalMessage(const std::string& message, // Calculate expected server signature ByteArray saltBytes = base64Decode(salt); - ByteArray saltedPassword = pbkdf2(password, saltBytes, iterations, 32); + ByteArray saltedPassword = pbkdf2(password, saltBytes, iterations, digest); // ServerKey = HMAC(SaltedPassword, "Server Key") - ByteArray serverKey = hmacSha256(saltedPassword, "Server Key"); + ByteArray serverKey = hmac(saltedPassword, "Server Key", digest); // ServerSignature = HMAC(ServerKey, AuthMessage) - ByteArray expectedSignature = hmacSha256(serverKey, authMessage); + ByteArray expectedSignature = hmac(serverKey, authMessage, digest); // Compare with received signature ByteArray receivedSignature = base64Decode(serverSignatureB64); return expectedSignature == receivedSignature; } -// PBKDF2-HMAC-SHA256 -ByteArray SCRAM::pbkdf2(const std::string& password, const ByteArray& salt, int iterations, size_t keyLength) { +// PBKDF2-HMAC; derived-key length follows the digest (SHA-1=20, SHA-256=32, SHA-512=64). +ByteArray SCRAM::pbkdf2(const std::string& password, const ByteArray& salt, int iterations, Digest digest) { + const EVP_MD* md = mdFor(digest); + const size_t keyLength = static_cast(EVP_MD_size(md)); ByteArray key(keyLength); if (PKCS5_PBKDF2_HMAC(password.c_str(), static_cast(password.size()), salt.data(), static_cast(salt.size()), iterations, - EVP_sha256(), + md, static_cast(keyLength), key.data()) != 1) { throw std::runtime_error("SCRAM::pbkdf2: PBKDF2 derivation failed"); @@ -142,21 +163,36 @@ ByteArray SCRAM::pbkdf2(const std::string& password, const ByteArray& salt, int return key; } -// HMAC-SHA256 (string message) -ByteArray SCRAM::hmacSha256(const ByteArray& key, const std::string& message) { - return hmacSha256(key, ByteArray(message.begin(), message.end())); +// HMAC (string message) +ByteArray SCRAM::hmac(const ByteArray& key, const std::string& message, Digest digest) { + return hmac(key, ByteArray(message.begin(), message.end()), digest); } -// HMAC-SHA256 (byte array message) -ByteArray SCRAM::hmacSha256(const ByteArray& key, const ByteArray& message) { - ByteArray result(EVP_MD_size(EVP_sha256())); +// HMAC (byte array message) +ByteArray SCRAM::hmac(const ByteArray& key, const ByteArray& message, Digest digest) { + const EVP_MD* md = mdFor(digest); + ByteArray result(EVP_MD_size(md)); unsigned int resultLen = 0; - if (HMAC(EVP_sha256(), + if (HMAC(md, key.data(), static_cast(key.size()), message.data(), message.size(), result.data(), &resultLen) == nullptr) { - throw std::runtime_error("SCRAM::hmacSha256: HMAC computation failed"); + throw std::runtime_error("SCRAM::hmac: HMAC computation failed"); + } + + result.resize(resultLen); + return result; +} + +// H (plain hash) with the chosen digest. +ByteArray SCRAM::hash(const ByteArray& data, Digest digest) { + const EVP_MD* md = mdFor(digest); + ByteArray result(EVP_MD_size(md)); + unsigned int resultLen = 0; + + if (EVP_Digest(data.data(), data.size(), result.data(), &resultLen, md, nullptr) != 1) { + throw std::runtime_error("SCRAM::hash: digest computation failed"); } result.resize(resultLen); diff --git a/src/auth/SaslAuthenticator.cpp b/src/auth/SaslAuthenticator.cpp new file mode 100644 index 0000000..2bbca74 --- /dev/null +++ b/src/auth/SaslAuthenticator.cpp @@ -0,0 +1,235 @@ +#include "hotrod/SaslAuthenticator.h" +#include "hotrod/Connection.h" +#include "hotrod/HeaderCodec.h" +#include "hotrod/HotRodClientException.h" +#include "hotrod/AuthCodec.h" +#include "hotrod/SCRAM.h" +#include "hotrod/TopologyInfo.h" +#include + +namespace hotrod { + +// --------------------------------------------------------------------------- +// ConnectionSaslTransport β€” production adapter over a live blocking Connection. +// --------------------------------------------------------------------------- + +ConnectionSaslTransport::ConnectionSaslTransport(Connection* conn, + uint8_t protocolVersion, + ClientIntelligence intelligence, + const std::string& host, + uint16_t port) + : conn_(conn) + , protocolVersion_(protocolVersion) + , intelligence_(intelligence) + , host_(host) + , port_(port) +{ +} + +void ConnectionSaslTransport::sendRequest(uint8_t opcode, const ByteArray& body) { + RequestHeader header; + header.messageId = nextMessageId_++; + header.version = protocolVersion_; + header.opcode = opcode; + header.cacheName = ""; // AUTH ops are not cache-scoped + header.flags = 0; + header.clientIntelligence = intelligence_; + header.topologyId = 0; // pre-topology handshake + header.keyMediaType = 0; + header.valueMediaType = 0; + + ByteArray frame; + HeaderCodec::writeRequestHeader(frame, header); + frame.insert(frame.end(), body.begin(), body.end()); + + try { + conn_->send(frame); + } catch (const std::exception& e) { + throw HotRodClientException(std::string("Auth send failed: ") + e.what(), + FailurePhase::AfterSend, std::nullopt, + {{host_, port_}}); + } +} + +uint8_t ConnectionSaslTransport::readResponseHeader() { + // Mirror MultiplexedConnection::readLoop's header parse so the byte stream + // stays in sync (magic / messageId / opcode / status / topology marker). + ByteArray magicByte = conn_->receive(1); + if (magicByte[0] != Protocol::RESPONSE_MAGIC) { + throw HotRodClientException( + "Auth: invalid magic byte in response: " + std::to_string(magicByte[0]), + FailurePhase::AfterSend, std::nullopt, {{host_, port_}}); + } + + (void)conn_->receiveVLong(); // messageId (handshake is strictly request/response) + + ByteArray headerTail = conn_->receive(3); + uint8_t opcode = headerTail[0]; + uint8_t status = headerTail[1]; + uint8_t topologyMarker = headerTail[2]; + + // Consume (and discard) any topology update riding along, exactly as the + // read loop would, so the stream stays aligned for the next read. + if (topologyMarker != 0 && + (intelligence_ == ClientIntelligence::TOPOLOGY_AWARE || + intelligence_ == ClientIntelligence::HASH_DISTRIBUTION_AWARE)) { + TopologyInfo topo; + uint8_t hashFunctionVersion = 0; + VInt numSegments = 0; + std::vector> segmentOwners; + topo.parseTopologyInfo(conn_, intelligence_, hashFunctionVersion, + numSegments, segmentOwners); + } + + // A server ERROR carries a length-prefixed message; surface it typed. + if (opcode == Opcode::ERROR_RESPONSE) { + std::string errorMessage = conn_->receiveString(); + throw HotRodClientException( + "Authentication failed (status " + std::to_string(status) + "): " + errorMessage, + FailurePhase::ServerError, status, {{host_, port_}}); + } + + if (status != Status::NO_ERROR) { + throw HotRodClientException( + "Authentication failed with status " + std::to_string(status), + FailurePhase::ServerError, status, {{host_, port_}}); + } + + return opcode; +} + +uint8_t ConnectionSaslTransport::readByte() { + return conn_->receive(1)[0]; +} + +VInt ConnectionSaslTransport::readVInt() { + return conn_->receiveVInt(); +} + +std::string ConnectionSaslTransport::readString() { + return conn_->receiveString(); +} + +ByteArray ConnectionSaslTransport::readByteArray() { + return conn_->receiveByteArray(); +} + +// --------------------------------------------------------------------------- +// SaslAuthenticator β€” the SCRAM state machine. +// --------------------------------------------------------------------------- + +SaslAuthenticator::SaslAuthenticator(Authentication auth, std::string host, uint16_t port) + : auth_(std::move(auth)) + , host_(std::move(host)) + , port_(port) +{ +} + +void SaslAuthenticator::authenticate(SaslTransport& transport) { + const std::vector node{{host_, port_}}; + + // 1. Only SCRAM-SHA-1/256/512 are supported this session. + SCRAM::Digest digest; + try { + digest = SCRAM::digestForMechanism(auth_.mechanism); + } catch (const std::exception&) { + throw HotRodClientException( + "Unsupported SASL mechanism: " + auth_.mechanism + + " (supported: SCRAM-SHA-1, SCRAM-SHA-256, SCRAM-SHA-512)", + FailurePhase::BeforeSend, std::nullopt, node); + } + + // 2. Mech-list exchange: verify the server offers our mechanism. + transport.sendRequest(Opcode::AUTH_MECH_LIST_REQUEST, encodeAuthMechListRequestBody()); + if (transport.readResponseHeader() != Opcode::AUTH_MECH_LIST_RESPONSE) { + throw HotRodClientException("Auth: expected AUTH_MECH_LIST_RESPONSE", + FailurePhase::AfterSend, std::nullopt, node); + } + VInt mechCount = transport.readVInt(); + bool offered = false; + for (VInt i = 0; i < mechCount; ++i) { + if (transport.readString() == auth_.mechanism) { + offered = true; + // keep reading to drain the rest of the list off the wire + } + } + if (!offered) { + throw HotRodClientException( + "SASL mechanism " + auth_.mechanism + " is not offered by the server", + FailurePhase::AfterSend, std::nullopt, node); + } + + // 3. SCRAM client-first (initial response carried in the first AUTH request). + std::string nonce = SCRAM::generateNonce(); + std::string clientFirst = SCRAM::createClientFirstMessage(auth_.username, nonce); + // client-first-message-bare = client-first without the "n,," GS2 header. + std::string clientFirstBare = clientFirst.substr(3); + + // Reads one AUTH_RESPONSE: {completed flag, challenge bytes}. + auto readAuth = [&](bool& completed) -> std::string { + if (transport.readResponseHeader() != Opcode::AUTH_RESPONSE) { + throw HotRodClientException("Auth: expected AUTH_RESPONSE", + FailurePhase::AfterSend, std::nullopt, node); + } + completed = transport.readByte() != 0; + ByteArray challenge = transport.readByteArray(); + return std::string(challenge.begin(), challenge.end()); + }; + auto sendAuth = [&](const std::string& token) { + transport.sendRequest( + Opcode::AUTH_REQUEST, + encodeAuthRequestBody(auth_.mechanism, ByteArray(token.begin(), token.end()))); + }; + + // 4. Two SCRAM client turns, mirroring the Java client (AuthHandler): the + // exchange completes on the *client* side once it has verified the server + // signature β€” the client sends no further message and does not depend on the + // server's `completed` flag (Infinispan/Elytron sends the server-final with + // completed=false and expects no confirmation round). + bool completed = false; + + // Turn 1: client-first -> server-first. + sendAuth(clientFirst); + std::string serverFirst = readAuth(completed); + if (completed) { + throw HotRodClientException( + "Auth: server completed the exchange before the client-final message", + FailurePhase::ServerError, std::nullopt, node); + } + + std::string combinedNonce; + std::string salt; + int iterations = 0; + try { + SCRAM::parseServerFirstMessage(serverFirst, combinedNonce, salt, iterations); + } catch (const std::exception& e) { + throw HotRodClientException(std::string("Auth: bad server-first message: ") + e.what(), + FailurePhase::ServerError, std::nullopt, node); + } + // The server nonce must extend our client nonce (RFC 5802). + if (combinedNonce.rfind(nonce, 0) != 0) { + throw HotRodClientException("Auth: server nonce does not match client nonce", + FailurePhase::ServerError, std::nullopt, node); + } + + // Turn 2: client-final -> server-final; verify the server signature. + std::string clientFinal = SCRAM::createClientFinalMessage( + auth_.password, clientFirstBare, serverFirst, combinedNonce, salt, iterations, digest); + std::string clientFinalWithoutProof = clientFinal.substr(0, clientFinal.find(",p=")); + std::string authMessage = clientFirstBare + "," + serverFirst + "," + clientFinalWithoutProof; + + sendAuth(clientFinal); + std::string serverFinal = readAuth(completed); + if (serverFinal.empty()) { + throw HotRodClientException("Auth: server did not send a server-final signature", + FailurePhase::ServerError, std::nullopt, node); + } + if (!SCRAM::verifyServerFinalMessage(serverFinal, auth_.password, authMessage, + salt, iterations, digest)) { + throw HotRodClientException("Auth: server signature verification failed", + FailurePhase::ServerError, std::nullopt, node); + } + // Verified: authentication is complete on the client side. +} + +} // namespace hotrod diff --git a/src/operations/RemoteCache.cpp b/src/operations/RemoteCache.cpp index 86fdbea..028cbab 100644 --- a/src/operations/RemoteCache.cpp +++ b/src/operations/RemoteCache.cpp @@ -69,6 +69,7 @@ namespace hotrod { connection_->setProtocolVersion(protocolVersion_); connection_->setClientIntelligence(clientIntelligence_); + connection_->setAuthentication(auth_); connection_->connect(); } @@ -1078,6 +1079,7 @@ namespace hotrod auto newConnection = std::make_shared(server.host, server.port, topologyCallback); newConnection->setProtocolVersion(protocolVersion_); newConnection->setClientIntelligence(clientIntelligence_); + newConnection->setAuthentication(auth_); // connect() can throw if server is unreachable - let it propagate to caller newConnection->connect(); diff --git a/src/transport/MultiplexedConnection.cpp b/src/transport/MultiplexedConnection.cpp index e28ced9..03caf4b 100644 --- a/src/transport/MultiplexedConnection.cpp +++ b/src/transport/MultiplexedConnection.cpp @@ -3,6 +3,7 @@ #include "hotrod/HeaderCodec.h" #include "hotrod/HotRodClientException.h" #include "hotrod/Codec.h" +#include "hotrod/SaslAuthenticator.h" #include #include @@ -31,6 +32,22 @@ void MultiplexedConnection::connect() { connection_ = std::make_unique(host_, port_); connection_->connect(); + // SASL handshake (synchronous, before the read thread consumes any bytes). + // Runs only when authentication is configured; a failure here throws + // HotRodClientException and leaves the connection cleanly closed. + if (auth_.enabled) { + try { + SaslAuthenticator authenticator(auth_, host_, port_); + ConnectionSaslTransport transport(connection_.get(), protocolVersion_, + clientIntelligence_, host_, port_); + authenticator.authenticate(transport); + } catch (...) { + connection_->close(); + connection_.reset(); + throw; + } + } + connected_ = true; stopReadLoop_ = false; @@ -83,6 +100,13 @@ void MultiplexedConnection::setClientIntelligence(ClientIntelligence intelligenc clientIntelligence_ = intelligence; } +void MultiplexedConnection::setAuthentication(const Authentication& auth) { + if (connected_) { + throw std::runtime_error("Cannot change authentication after connect()"); + } + auth_ = auth; +} + int32_t MultiplexedConnection::getTopologyId() const { return topologyId_.load(); } diff --git a/test-configs/auth-groups.properties b/test-configs/auth-groups.properties new file mode 100644 index 0000000..5360805 --- /dev/null +++ b/test-configs/auth-groups.properties @@ -0,0 +1,3 @@ +# Group/role assignments for the auth integration test user. +# Format: = +testuser=admin diff --git a/test-configs/auth-users.properties b/test-configs/auth-users.properties new file mode 100644 index 0000000..827d0ce --- /dev/null +++ b/test-configs/auth-users.properties @@ -0,0 +1,5 @@ +# Plain-text user store for the auth integration tests. +# Consumed by in infinispan-auth.xml, +# so SCRAM-SHA-1/256/512 (which need the cleartext password server-side) work. +# Format: = +testuser=testpassword diff --git a/test-configs/infinispan-auth.xml b/test-configs/infinispan-auth.xml new file mode 100644 index 0000000..0d9c879 --- /dev/null +++ b/test-configs/infinispan-auth.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/integration/AuthIntegrationTest.cpp b/tests/integration/AuthIntegrationTest.cpp new file mode 100644 index 0000000..a350398 --- /dev/null +++ b/tests/integration/AuthIntegrationTest.cpp @@ -0,0 +1,157 @@ +/** + * SASL/SCRAM Authentication Integration Tests (Docker-backed). + * + * Runs against an auth-enabled Infinispan server started by + * scripts/start_infinispan_auth.sh (security realm with plain-text passwords so + * the SCRAM family works; user testuser/testpassword). Verifies the end-to-end + * handshake against a real server: + * 1. Correct credentials -> connect + put/get round-trip succeeds. + * 2. Wrong password -> connect() throws HotRodClientException. + * 3. No credentials -> operations against a secured server fail. + * + * Excluded from the default CI unit run via the "integration;docker;auth" label. + */ +#include +#include "hotrod/RemoteCache.h" +#include "hotrod/HotRodClientException.h" + +#include +#include +#include +#include +#include + +using namespace hotrod; + +namespace { + +/** + * Starts/stops the auth-enabled Infinispan container once for the whole suite. + * Separate from InfinispanTestEnvironment because that one hardcodes the + * no-auth start script; here we drive start_infinispan_auth.sh and also carry + * the credentials it exports. + */ +class AuthServerEnvironment : public ::testing::Environment { +public: + static std::string containerID; + static std::string host; + static int port; + static std::string user; + static std::string pass; + + void SetUp() override { + int result = system("./scripts/start_infinispan_auth.sh > /tmp/ispn-auth-env.sh 2>&1"); + if (result != 0) { + throw std::runtime_error("Failed to start auth Infinispan server. Check Docker is running."); + } + + std::ifstream env("/tmp/ispn-auth-env.sh"); + if (!env.is_open()) { + throw std::runtime_error("Failed to open /tmp/ispn-auth-env.sh"); + } + + std::string line; + while (std::getline(env, line)) { + if (line.rfind("export ISPN_CONTAINER_ID=", 0) == 0) { + containerID = line.substr(25); + } else if (line.rfind("export ISPN_HOST=", 0) == 0) { + host = line.substr(17); + } else if (line.rfind("export ISPN_PORT=", 0) == 0) { + port = std::stoi(line.substr(17)); + } else if (line.rfind("export ISPN_USER=", 0) == 0) { + user = line.substr(17); + } else if (line.rfind("export ISPN_PASS=", 0) == 0) { + pass = line.substr(17); + } + } + + if (containerID.empty() || port == 0) { + throw std::runtime_error("Failed to parse auth server info from startup script"); + } + } + + void TearDown() override { + if (!containerID.empty()) { + std::string cmd = "./scripts/stop_infinispan.sh " + containerID + " >/dev/null"; + std::ignore = system(cmd.c_str()); + } + } +}; + +std::string AuthServerEnvironment::containerID; +std::string AuthServerEnvironment::host = "localhost"; +int AuthServerEnvironment::port = 0; +std::string AuthServerEnvironment::user; +std::string AuthServerEnvironment::pass; + +} // namespace + +// Cache pre-defined in infinispan-auth.xml. +constexpr const char* kAuthCache = "authcache"; + +// 1. Correct credentials: connect, then a put/get round-trip succeeds β€” for each +// mechanism in the SCRAM family the server offers. +class AuthMechanismRoundTrip : public ::testing::TestWithParam {}; + +TEST_P(AuthMechanismRoundTrip, PutGet) { + const std::string& mechanism = GetParam(); + + RemoteCache cache(AuthServerEnvironment::host, AuthServerEnvironment::port, kAuthCache); + cache.setAuthentication(AuthServerEnvironment::user, AuthServerEnvironment::pass, + "default", "infinispan", mechanism); + cache.connect(); + + // Key is mechanism-specific so parallel runs don't collide. + ByteArray key(mechanism.begin(), mechanism.end()); + const ByteArray value{'v', '1'}; + + cache.put(key, value).get(); + auto got = cache.get(key).get(); + + ASSERT_TRUE(got.has_value()); + EXPECT_EQ(*got, value); + + cache.disconnect(); +} + +INSTANTIATE_TEST_SUITE_P( + ScramFamily, AuthMechanismRoundTrip, + ::testing::Values("SCRAM-SHA-1", "SCRAM-SHA-256", "SCRAM-SHA-512"), + [](const ::testing::TestParamInfo& info) { + std::string n = info.param; + for (char& ch : n) if (ch == '-') ch = '_'; + return n; + }); + +// 2. Wrong password: the SASL handshake fails at connect() time. +TEST(AuthIntegrationTest, WrongPasswordThrowsAtConnect) { + RemoteCache cache(AuthServerEnvironment::host, AuthServerEnvironment::port); + cache.setAuthentication(AuthServerEnvironment::user, "definitely-wrong-password"); + + EXPECT_THROW(cache.connect(), HotRodClientException); +} + +// 3. No credentials against a secured server: operations must fail. +TEST(AuthIntegrationTest, NoCredentialsFails) { + RemoteCache cache(AuthServerEnvironment::host, AuthServerEnvironment::port, kAuthCache); + // No setAuthentication(): the client skips the handshake; the server rejects + // unauthenticated operations. Whether it surfaces at connect() or on the + // first op, an exception must be thrown before a value round-trips. + bool threw = false; + try { + cache.connect(); + const ByteArray key{'n', 'o', 'a', 'u', 't', 'h'}; + cache.put(key, ByteArray{'x'}).get(); + } catch (const std::exception&) { + threw = true; + } + EXPECT_TRUE(threw); + + cache.disconnect(); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + ::testing::AddGlobalTestEnvironment(new AuthServerEnvironment()); + return RUN_ALL_TESTS(); +} diff --git a/tests/unit/AuthCodecTest.cpp b/tests/unit/AuthCodecTest.cpp new file mode 100644 index 0000000..f244699 --- /dev/null +++ b/tests/unit/AuthCodecTest.cpp @@ -0,0 +1,94 @@ +/** + * Unit tests for the SASL auth message body codec (AuthCodec.h). + * + * Verifies the wire layout against the authoritative Kaitai schema + * (hotrod40.ksy): mech-list response = vint count + lp_string*; auth request = + * lp_string mech + lp_bytes response; auth response = u1 completed + lp_bytes. + */ +#include +#include "hotrod/AuthCodec.h" +#include "hotrod/Codec.h" + +using namespace hotrod; + +namespace { + +// Build a mech-list response body: vint count followed by that many lp_strings. +ByteArray buildMechListBody(const std::vector& mechs) { + ByteArray body; + Codec::writeVInt(body, static_cast(mechs.size())); + for (const auto& m : mechs) { + Codec::writeString(body, m); + } + return body; +} + +// Build an auth response body: 1-byte completed flag + lp_bytes challenge. +ByteArray buildAuthResponseBody(bool completed, const ByteArray& challenge) { + ByteArray body; + body.push_back(completed ? 1 : 0); + Codec::writeByteArray(body, challenge); + return body; +} + +} // namespace + +TEST(AuthCodecTest, MechListRequestBodyIsEmpty) { + EXPECT_TRUE(encodeAuthMechListRequestBody().empty()); +} + +TEST(AuthCodecTest, DecodeMechListResponseMultiple) { + auto body = buildMechListBody({"SCRAM-SHA-512", "SCRAM-SHA-256", "SCRAM-SHA-1", "PLAIN"}); + auto mechs = decodeAuthMechListResponse(body); + ASSERT_EQ(mechs.size(), 4u); + EXPECT_EQ(mechs[0], "SCRAM-SHA-512"); + EXPECT_EQ(mechs[1], "SCRAM-SHA-256"); + EXPECT_EQ(mechs[2], "SCRAM-SHA-1"); + EXPECT_EQ(mechs[3], "PLAIN"); +} + +TEST(AuthCodecTest, DecodeMechListResponseEmpty) { + auto body = buildMechListBody({}); + EXPECT_TRUE(decodeAuthMechListResponse(body).empty()); +} + +TEST(AuthCodecTest, EncodeAuthRequestRoundTrips) { + const std::string mech = "SCRAM-SHA-256"; + const ByteArray response = {'n', ',', ',', 'n', '=', 'u', ',', 'r', '=', 'x'}; + + ByteArray body = encodeAuthRequestBody(mech, response); + + // Decode back with the primitive codec: lp_string mech + lp_bytes response. + size_t offset = 0; + EXPECT_EQ(Codec::readString(body, offset), mech); + EXPECT_EQ(Codec::readByteArray(body, offset), response); + EXPECT_EQ(offset, body.size()); // no trailing bytes +} + +TEST(AuthCodecTest, EncodeAuthRequestEmptyResponse) { + ByteArray body = encodeAuthRequestBody("SCRAM-SHA-1", {}); + size_t offset = 0; + EXPECT_EQ(Codec::readString(body, offset), "SCRAM-SHA-1"); + EXPECT_TRUE(Codec::readByteArray(body, offset).empty()); + EXPECT_EQ(offset, body.size()); +} + +TEST(AuthCodecTest, DecodeAuthResponseNotCompleted) { + const ByteArray challenge = {'r', '=', 'a', 'b', ',', 's', '=', 'x', 'y'}; + auto parsed = decodeAuthResponse(buildAuthResponseBody(false, challenge)); + EXPECT_FALSE(parsed.completed); + EXPECT_EQ(parsed.challenge, challenge); +} + +TEST(AuthCodecTest, DecodeAuthResponseCompletedWithVerifier) { + const ByteArray verifier = {'v', '=', 'z', 'z', 'z'}; + auto parsed = decodeAuthResponse(buildAuthResponseBody(true, verifier)); + EXPECT_TRUE(parsed.completed); + EXPECT_EQ(parsed.challenge, verifier); +} + +TEST(AuthCodecTest, DecodeAuthResponseCompletedEmptyChallenge) { + auto parsed = decodeAuthResponse(buildAuthResponseBody(true, {})); + EXPECT_TRUE(parsed.completed); + EXPECT_TRUE(parsed.challenge.empty()); +} diff --git a/tests/unit/SaslAuthenticatorTest.cpp b/tests/unit/SaslAuthenticatorTest.cpp new file mode 100644 index 0000000..f641f63 --- /dev/null +++ b/tests/unit/SaslAuthenticatorTest.cpp @@ -0,0 +1,307 @@ +/** + * Unit tests for the SASL SCRAM handshake driver (SaslAuthenticator). + * + * The driver runs against a transport seam (SaslTransport). Here we supply a + * FakeScramServer that speaks the server half of the exchange, computing the + * server-first / server-final and validating the client proof with OpenSSL + * *independently* of the client's SCRAM code. A green round-trip therefore + * proves the client's generalized SCRAM (SHA-1/256/512) interoperates with a + * correct peer β€” not merely that it agrees with itself. + * + * Coverage: + * - Positive round-trip, parameterized across SCRAM-SHA-1/256/512. + * - Wrong password -> server rejects the proof (ERROR frame) -> throws. + * - Bad server signature -> client verification fails -> throws. + * - Mechanism not offered by the server -> throws. + * - Unsupported mechanism configured (e.g. PLAIN) -> throws before any I/O. + */ +#include +#include "hotrod/SaslAuthenticator.h" +#include "hotrod/Authentication.h" +#include "hotrod/AuthCodec.h" +#include "hotrod/Codec.h" +#include "hotrod/HeaderCodec.h" +#include "hotrod/HotRodClientException.h" + +#include +#include +#include +#include + +#include +#include +#include + +using namespace hotrod; + +namespace { + +// --- Minimal OpenSSL helpers for the *server* side of the exchange ---------- + +std::string b64Encode(const ByteArray& data) { + BIO* b64 = BIO_new(BIO_f_base64()); + BIO* bio = BIO_new(BIO_s_mem()); + bio = BIO_push(b64, bio); + BIO_set_flags(bio, BIO_FLAGS_BASE64_NO_NL); + BIO_write(bio, data.data(), static_cast(data.size())); + BIO_flush(bio); + BUF_MEM* buf; + BIO_get_mem_ptr(bio, &buf); + std::string out(buf->data, buf->length); + BIO_free_all(bio); + return out; +} + +ByteArray b64Decode(const std::string& enc) { + BIO* b64 = BIO_new(BIO_f_base64()); + BIO* bio = BIO_new_mem_buf(enc.data(), static_cast(enc.size())); + bio = BIO_push(b64, bio); + BIO_set_flags(bio, BIO_FLAGS_BASE64_NO_NL); + ByteArray out(enc.size()); + int n = BIO_read(bio, out.data(), static_cast(out.size())); + BIO_free_all(bio); + out.resize(n < 0 ? 0 : static_cast(n)); + return out; +} + +ByteArray svPbkdf2(const std::string& pw, const ByteArray& salt, int iters, const EVP_MD* md) { + ByteArray key(static_cast(EVP_MD_size(md))); + PKCS5_PBKDF2_HMAC(pw.c_str(), static_cast(pw.size()), + salt.data(), static_cast(salt.size()), + iters, md, EVP_MD_size(md), key.data()); + return key; +} + +ByteArray svHmac(const ByteArray& key, const std::string& msg, const EVP_MD* md) { + ByteArray out(static_cast(EVP_MD_size(md))); + unsigned int len = 0; + HMAC(md, key.data(), static_cast(key.size()), + reinterpret_cast(msg.data()), msg.size(), + out.data(), &len); + out.resize(len); + return out; +} + +ByteArray svHash(const ByteArray& data, const EVP_MD* md) { + ByteArray out(static_cast(EVP_MD_size(md))); + unsigned int len = 0; + EVP_Digest(data.data(), data.size(), out.data(), &len, md, nullptr); + out.resize(len); + return out; +} + +// Extract the value of a "=..." token from a comma-separated SCRAM message. +std::string field(const std::string& msg, char key) { + std::istringstream ss(msg); + std::string tok; + while (std::getline(ss, tok, ',')) { + if (tok.size() >= 2 && tok[0] == key && tok[1] == '=') { + return tok.substr(2); + } + } + return {}; +} + +// --- Fake SCRAM server driven through the SaslTransport seam ----------------- + +struct FakeScramServer : public SaslTransport { + // Server configuration. + std::vector mechs{"SCRAM-SHA-512", "SCRAM-SHA-256", "SCRAM-SHA-1", "PLAIN"}; + std::string username = "user"; + std::string password = "pencil"; + const EVP_MD* md = EVP_sha256(); + ByteArray salt = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}; + int iterations = 4096; + std::string serverNonceSuffix = "3rfcNHYJY1ZVvWVs7j"; + + // Fault injection. + bool corruptServerSignature = false; + + // A pending response frame the client will read next. + struct Frame { + uint8_t opcode = 0; + ByteArray body; + bool isError = false; + std::string errorMsg; + }; + std::deque outbox; + ByteArray cursorBody; + size_t cursorOffset = 0; + + // Carried across rounds so the server can rebuild the auth message. + std::string clientFirstBare; + std::string serverFirst; + + enum class Phase { MechList, ClientFirst, ClientFinal, Done } phase = Phase::MechList; + + // --- SaslTransport --- + void sendRequest(uint8_t opcode, const ByteArray& body) override { + if (opcode == Opcode::AUTH_MECH_LIST_REQUEST) { + Frame f; + f.opcode = Opcode::AUTH_MECH_LIST_RESPONSE; + Codec::writeVInt(f.body, static_cast(mechs.size())); + for (const auto& m : mechs) Codec::writeString(f.body, m); + outbox.push_back(std::move(f)); + phase = Phase::ClientFirst; + return; + } + ASSERT_EQ(opcode, Opcode::AUTH_REQUEST); + + size_t off = 0; + std::string mech = Codec::readString(body, off); + ByteArray tokenBytes = Codec::readByteArray(body, off); + std::string token(tokenBytes.begin(), tokenBytes.end()); + + if (phase == Phase::ClientFirst) { + // token = "n,,n=,r=" + clientFirstBare = token.substr(3); + std::string cnonce = field(token, 'r'); + std::string combined = cnonce + serverNonceSuffix; + serverFirst = "r=" + combined + ",s=" + b64Encode(salt) + + ",i=" + std::to_string(iterations); + Frame f; + f.opcode = Opcode::AUTH_RESPONSE; + f.body.push_back(0); // completed = false + Codec::writeByteArray(f.body, ByteArray(serverFirst.begin(), serverFirst.end())); + outbox.push_back(std::move(f)); + phase = Phase::ClientFinal; + return; + } + + // Phase::ClientFinal β€” token = "c=biws,r=,p=" + std::string proof = field(token, 'p'); + std::string clientFinalWithoutProof = token.substr(0, token.find(",p=")); + std::string authMessage = clientFirstBare + "," + serverFirst + "," + clientFinalWithoutProof; + + ByteArray saltedPassword = svPbkdf2(password, salt, iterations, md); + ByteArray clientKey = svHmac(saltedPassword, "Client Key", md); + ByteArray storedKey = svHash(clientKey, md); + ByteArray clientSig = svHmac(storedKey, authMessage, md); + + ByteArray expectedProof(clientKey.size()); + for (size_t i = 0; i < clientKey.size(); ++i) expectedProof[i] = clientKey[i] ^ clientSig[i]; + + if (b64Decode(proof) != expectedProof) { + // Wrong password (proof mismatch) β€” server rejects with an ERROR frame. + Frame f; + f.opcode = Opcode::ERROR_RESPONSE; + f.isError = true; + f.errorMsg = "Authentication failed: proof mismatch"; + outbox.push_back(std::move(f)); + phase = Phase::Done; + return; + } + + ByteArray serverKey = svHmac(saltedPassword, "Server Key", md); + ByteArray serverSig = svHmac(serverKey, authMessage, md); + if (corruptServerSignature && !serverSig.empty()) serverSig[0] ^= 0xFF; + std::string serverFinal = "v=" + b64Encode(serverSig); + + Frame f; + f.opcode = Opcode::AUTH_RESPONSE; + f.body.push_back(1); // completed = true + Codec::writeByteArray(f.body, ByteArray(serverFinal.begin(), serverFinal.end())); + outbox.push_back(std::move(f)); + phase = Phase::Done; + } + + uint8_t readResponseHeader() override { + if (outbox.empty()) throw std::runtime_error("FakeScramServer: no response queued"); + Frame f = std::move(outbox.front()); + outbox.pop_front(); + if (f.isError) { + throw HotRodClientException("Authentication failed: " + f.errorMsg, + FailurePhase::ServerError, Status::SERVER_ERROR, + {{"fake", 0}}); + } + cursorBody = std::move(f.body); + cursorOffset = 0; + return f.opcode; + } + + uint8_t readByte() override { return cursorBody.at(cursorOffset++); } + VInt readVInt() override { return Codec::readVInt(cursorBody, cursorOffset); } + std::string readString() override { return Codec::readString(cursorBody, cursorOffset); } + ByteArray readByteArray() override { return Codec::readByteArray(cursorBody, cursorOffset); } +}; + +Authentication makeAuth(const std::string& mech, const std::string& user, const std::string& pass) { + Authentication a; + a.enabled = true; + a.username = user; + a.password = pass; + a.mechanism = mech; + return a; +} + +} // namespace + +// --- Positive round-trip, parameterized across the SCRAM digest family ------- + +struct ScramCase { + std::string mechanism; + const EVP_MD* md; +}; + +class SaslAuthenticatorFamilyTest : public ::testing::TestWithParam {}; + +TEST_P(SaslAuthenticatorFamilyTest, SuccessfulHandshake) { + const auto& c = GetParam(); + FakeScramServer server; + server.md = c.md; + + SaslAuthenticator auth(makeAuth(c.mechanism, "user", "pencil"), "host", 11222); + EXPECT_NO_THROW(auth.authenticate(server)); + EXPECT_EQ(server.phase, FakeScramServer::Phase::Done); +} + +TEST_P(SaslAuthenticatorFamilyTest, WrongPasswordThrows) { + const auto& c = GetParam(); + FakeScramServer server; + server.md = c.md; // server knows the real password "pencil" + + SaslAuthenticator auth(makeAuth(c.mechanism, "user", "WRONG"), "host", 11222); + EXPECT_THROW(auth.authenticate(server), HotRodClientException); +} + +TEST_P(SaslAuthenticatorFamilyTest, BadServerSignatureThrows) { + const auto& c = GetParam(); + FakeScramServer server; + server.md = c.md; + server.corruptServerSignature = true; + + SaslAuthenticator auth(makeAuth(c.mechanism, "user", "pencil"), "host", 11222); + EXPECT_THROW(auth.authenticate(server), HotRodClientException); +} + +INSTANTIATE_TEST_SUITE_P( + ScramFamily, SaslAuthenticatorFamilyTest, + ::testing::Values( + ScramCase{"SCRAM-SHA-1", EVP_sha1()}, + ScramCase{"SCRAM-SHA-256", EVP_sha256()}, + ScramCase{"SCRAM-SHA-512", EVP_sha512()}), + [](const ::testing::TestParamInfo& info) { + std::string n = info.param.mechanism; + for (char& ch : n) if (ch == '-') ch = '_'; + return n; + }); + +// --- Failure paths independent of digest ------------------------------------- + +TEST(SaslAuthenticatorTest, MechanismNotOfferedThrows) { + FakeScramServer server; + server.mechs = {"PLAIN", "DIGEST-MD5"}; // no SCRAM offered + + SaslAuthenticator auth(makeAuth("SCRAM-SHA-256", "user", "pencil"), "host", 11222); + EXPECT_THROW(auth.authenticate(server), HotRodClientException); +} + +TEST(SaslAuthenticatorTest, UnsupportedMechanismThrowsBeforeIO) { + FakeScramServer server; + + SaslAuthenticator auth(makeAuth("PLAIN", "user", "pencil"), "host", 11222); + EXPECT_THROW(auth.authenticate(server), HotRodClientException); + // Nothing should have been sent to the transport. + EXPECT_TRUE(server.outbox.empty()); + EXPECT_EQ(server.phase, FakeScramServer::Phase::MechList); +}