diff --git a/CHANGELOG.md b/CHANGELOG.md index 96fdcee..05064ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes to ESPressio Security are documented in this file. +## [0.2.0] - 2026-08-20 + +### Added + +- Added `ITransportSecurityObserver` for externally meaningful transport-security lifecycle notifications. +- Added observable notifications for configuration changes, security-session reset/establishment, replay-protection reset, and security failures. +- Added ESPressio Observable as the foundational observer dependency. +- Added optional ESPressio Event bridge support through ESPressio Event 5.8.0. + +### Changed + +- Security failure paths now publish observer notifications without changing existing return-value semantics. + ## [0.1.0] - 2026-08-20 ### Added diff --git a/README.md b/README.md index 0330057..6ebe45a 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,24 @@ Transport-neutral authenticated encryption, authentication, replay protection an ESPressio Security protects **opaque transport payloads** without knowing whether they contain Events, Commands, clock synchronization messages, application packets, or another protocol. Concrete transports such as ESP-NOW, UDP, TCP and WebSockets can therefore opt into the same security layer while higher-level application protocols remain independent of cryptography. +## 0.2.0 Development Update — Observable Callback Coverage + +The `feature/observable-callback-coverage` branch targets **ESPressio Security 0.2.0**. The stable-release information below remains the historical 0.1.0 documentation until 0.2.0 is released. + +For 0.2.0, ESPressio Security adds a required dependency on **ESPressio Observable >= 3.0.1 and < 4.0.0** and introduces `ITransportSecurityObserver`. `TransportSecurity` now exposes synchronous observations for material configuration changes, security-session reset/establishment, replay-protection reset, and Security failures while preserving the existing `SecurityResult` return contract. + +ESPressio Event remains **optional**. ESPressio Event 5.8.0 adds `TransportSecurityEventBridge`, which binds to a specific `TransportSecurity` instance and converts those observations into asynchronous Events without making Event a Security dependency. Key material is never exposed through the observer or Event surfaces. + +Development-branch PlatformIO dependencies are therefore: + +```ini +lib_deps = + https://github.com/Flowduino/ESPressio-Security.git#feature/observable-callback-coverage + flowduino/ESPressio-Observable@^3.0.1 +``` + +The 0.2.0 host-test suite includes dedicated observable lifecycle coverage. See [CHANGELOG.md](CHANGELOG.md) for the complete 0.2.0 change list. + ## Latest Stable Version ESPressio Security is currently **0.1.0**. @@ -39,7 +57,7 @@ See [LICENSE](LICENSE) for details. ## ESPressio Library Dependencies -ESPressio Security has **no required ESPressio dependencies**. +ESPressio Security has **no required ESPressio dependencies** in the stable 0.1.0 release. **The 0.2.0 development branch adds ESPressio Observable >= 3.0.1 and < 4.0.0 as a required dependency**, as described in the development update above. It is intentionally foundational and transport-neutral. Concrete communication libraries should depend optionally on Security, rather than Security depending on them: @@ -49,7 +67,7 @@ ESPressio Sockets - - -> ESPressio Security future transports - - -> ESPressio Security ``` -Event, Command and Timing do not need to depend directly on Security merely because their messages may be transported securely. +Event, Command and Timing do not need to depend directly on Security merely because their messages may be transported securely. ESPressio Event 5.8.0's Security bridge remains opt-in. See [ESPRESSIO_DEPENDENCY_CHART.md](ESPRESSIO_DEPENDENCY_CHART.md). @@ -73,10 +91,11 @@ Principal public types include: - `ReplayWindow` — per-sender/per-key/per-session sliding replay detector. - `ITransportSecurityCarrier` — minimal concrete-transport adapter contract. - `SecureTransportDecorator` — generic secure wrapper for a carrier. +- `ITransportSecurityObserver` — 0.2.0 synchronous observer for externally meaningful Security lifecycle changes. ## PlatformIO -Add the published library with: +For the stable 0.1.0 release: ```ini lib_deps = @@ -89,6 +108,8 @@ build_unflags = -std=gnu++11 ``` +For the 0.2.0 release generation, add ESPressio Observable 3.x as shown in the development update above. + To deliberately consume the current repository instead of a release: ```ini @@ -136,7 +157,7 @@ Algorithms are resolved through `AeadCipherRegistry`, allowing new implementatio ## Included AEAD Implementations -When supported by the platform's mbedTLS build, 0.1.0 provides: +When supported by the platform's mbedTLS build, Security provides: | Algorithm | Key | Nonce | Tag | Class | | --- | ---: | ---: | ---: | --- | @@ -220,6 +241,33 @@ boot B: sender X / session B / sequence 1, 2, 3 ... The restarted sequence is accepted because session B is a distinct authenticated replay domain; replaying either session's already-seen packets is still rejected. +## Observable Security Lifecycle (0.2.0) + +`TransportSecurity` now accepts `ITransportSecurityObserver` registrations: + +```cpp +class SecurityObserver final : + public ESPressio::Security::ITransportSecurityObserver { +public: + void OnTransportSecuritySessionEstablished(uint64_t sessionID) override { + // Session lifecycle observation. + } + + void OnTransportSecurityFailure( + const ESPressio::Security::SecurityResult& result + ) override { + // Diagnostics / metrics / audit handling. + } +}; + +SecurityObserver observer; +auto observerHandle = security.RegisterObserver(&observer); +``` + +The observer surface supplements rather than replaces `SecurityResult`. Observer exceptions are isolated from Security processing so a diagnostics consumer cannot interrupt a cryptographic state transition. + +When ESPressio Event 5.8.0 is selected, `ESPressio_TransportSecurityEventBridge.hpp` converts the same observations into asynchronous Event instances without changing Security's dependency direction. + ## Protecting a Payload ```cpp @@ -292,7 +340,7 @@ Example keys are demonstration-only. Do not copy hard-coded example key material Host-side CMake/CTest coverage uses a deterministic **test-only** AEAD implementation contained exclusively under `tests/`. -Coverage includes protect/open round trips, authenticated metadata, ciphertext/tag/header/session tampering, protocol binding, replay rejection, in-window reordering, sender reboot/session rollover, explicit and automatically generated sessions, key rotation, Required/Preferred/Disabled policy behavior, malformed envelopes, payload limits and generic decorator flow. +Coverage includes protect/open round trips, authenticated metadata, ciphertext/tag/header/session tampering, protocol binding, replay rejection, in-window reordering, sender reboot/session rollover, explicit and automatically generated sessions, key rotation, Required/Preferred/Disabled policy behavior, malformed envelopes, payload limits, generic decorator flow, and 0.2.0 observable lifecycle behavior. A separate production-cipher contract target instantiates all included mbedTLS-backed cipher classes against API-compatible host stubs. GitHub Actions also compile the ESP32 examples so the actual Arduino-ESP32 mbedTLS API surface is validated in addition to host abstraction tests. diff --git a/component.mk b/component.mk index e71db13..b2452c9 100644 --- a/component.mk +++ b/component.mk @@ -3,6 +3,6 @@ COMPONENT_SRCDIRS := CXXFLAGS += -std=gnu++17 ESPRESSIO_SECURITY_VERSION_MAJOR := 0 -ESPRESSIO_SECURITY_VERSION_MINOR := 1 +ESPRESSIO_SECURITY_VERSION_MINOR := 2 ESPRESSIO_SECURITY_VERSION_PATCH := 0 -ESPRESSIO_SECURITY_VERSION := 0.1.0 +ESPRESSIO_SECURITY_VERSION := 0.2.0 diff --git a/library.json b/library.json index c626706..8f65b7d 100644 --- a/library.json +++ b/library.json @@ -1,12 +1,19 @@ { "name": "ESPressio-Security", - "version": "0.1.0", + "version": "0.2.0", "description": "Transport-neutral authenticated encryption, authentication and replay protection for the ESPressio Development Platform", - "keywords": "espressio,security,encryption,aead,aes,gcm,ccm,chacha20,poly1305,transport,authentication,replay", + "keywords": "espressio,security,encryption,aead,aes,gcm,ccm,chacha20,poly1305,transport,authentication,replay,observable", "repository": {"type": "git", "url": "https://github.com/Flowduino/ESPressio-Security.git"}, "authors": {"name": "Flowduino", "maintainer": true, "url": "https://flowduino.com"}, "license": "Apache-2.0", "frameworks": "*", "platforms": "*", - "build": {"flags": ["-std=gnu++17", "-DESPRESSIO_SECURITY"]} + "build": {"flags": ["-std=gnu++17", "-DESPRESSIO_SECURITY"]}, + "dependencies": [ + { + "name": "Flowduino ESPressio-Observable", + "version": ">=3.0.1 <4.0.0", + "url": "https://github.com/Flowduino/ESPressio-Observable.git" + } + ] } diff --git a/library.properties b/library.properties index 0dacf42..2ec80fe 100644 --- a/library.properties +++ b/library.properties @@ -1,10 +1,11 @@ name=ESPressio-Security -version=0.1.0 +version=0.2.0 author=Flowduino maintainer=Flowduino sentence=Transport-neutral authenticated encryption and replay protection for ESPressio. -paragraph=Provides pluggable AEAD algorithms, key providers, authenticated transport envelopes, replay protection, security policies, and generic secure transport decoration. Includes mbedTLS AES-GCM, AES-CCM and ChaCha20-Poly1305 implementations when available. +paragraph=Provides pluggable AEAD algorithms, key providers, authenticated transport envelopes, replay protection, security policies, observable security lifecycle notifications, and generic secure transport decoration. Includes mbedTLS AES-GCM, AES-CCM and ChaCha20-Poly1305 implementations when available. category=Communication url=https://github.com/Flowduino/ESPressio-Security architectures=* includes=ESPressio_Security.hpp +depends=Flowduino ESPressio-Observable (>=3.0.1) diff --git a/src/ESPressio_ITransportSecurityObserver.hpp b/src/ESPressio_ITransportSecurityObserver.hpp new file mode 100644 index 0000000..4942f4d --- /dev/null +++ b/src/ESPressio_ITransportSecurityObserver.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include + +#include + +#include "ESPressio_SecurityTypes.hpp" + +namespace ESPressio::Security { + +class ITransportSecurityObserver : + public virtual Observable::IObserver { +public: + virtual ~ITransportSecurityObserver() = default; + + virtual void OnTransportSecurityConfigurationChanged( + const TransportSecurityConfig&, + const TransportSecurityConfig& + ) {} + + virtual void OnTransportSecuritySessionReset( + uint64_t + ) {} + + virtual void OnTransportSecuritySessionEstablished( + uint64_t + ) {} + + virtual void OnTransportSecurityReplayProtectionReset() {} + + virtual void OnTransportSecurityFailure( + const SecurityResult& + ) {} +}; + +} // namespace ESPressio::Security diff --git a/src/ESPressio_Security.hpp b/src/ESPressio_Security.hpp index a02ee77..58cd9eb 100644 --- a/src/ESPressio_Security.hpp +++ b/src/ESPressio_Security.hpp @@ -7,6 +7,7 @@ #include "ESPressio_StaticKeyProvider.hpp" #include "ESPressio_IRandomSource.hpp" #include "ESPressio_ReplayWindow.hpp" +#include "ESPressio_ITransportSecurityObserver.hpp" #include "ESPressio_TransportSecurity.hpp" #include "ESPressio_ISecureTransportCarrier.hpp" #include "ESPressio_SecureTransportDecorator.hpp" diff --git a/src/ESPressio_SecurityTypes.hpp b/src/ESPressio_SecurityTypes.hpp index db43981..41792e7 100644 --- a/src/ESPressio_SecurityTypes.hpp +++ b/src/ESPressio_SecurityTypes.hpp @@ -9,9 +9,9 @@ namespace ESPressio::Security { constexpr uint32_t ESPRESSIO_SECURITY_VERSION_MAJOR = 0; -constexpr uint32_t ESPRESSIO_SECURITY_VERSION_MINOR = 1; +constexpr uint32_t ESPRESSIO_SECURITY_VERSION_MINOR = 2; constexpr uint32_t ESPRESSIO_SECURITY_VERSION_PATCH = 0; -constexpr const char* ESPRESSIO_SECURITY_VERSION = "0.1.0"; +constexpr const char* ESPRESSIO_SECURITY_VERSION = "0.2.0"; enum class AeadAlgorithm : uint8_t { Unknown = 0, diff --git a/src/ESPressio_TransportSecurity.hpp b/src/ESPressio_TransportSecurity.hpp index 26202ef..69a9cee 100644 --- a/src/ESPressio_TransportSecurity.hpp +++ b/src/ESPressio_TransportSecurity.hpp @@ -3,12 +3,16 @@ #include #include #include +#include #include #include +#include + #include "ESPressio_AeadCipherRegistry.hpp" #include "ESPressio_IKeyProvider.hpp" #include "ESPressio_IRandomSource.hpp" +#include "ESPressio_ITransportSecurityObserver.hpp" #include "ESPressio_ReplayWindow.hpp" #include "ESPressio_SecurityTypes.hpp" @@ -20,6 +24,98 @@ class TransportSecurity final { static constexpr uint8_t EnvelopeVersion = 1; static constexpr std::size_t FixedHeaderSize = 44; +private: + class SecurityObservable final : public Observable::ThreadSafeObservable { + private: + template + void Notify(Callback&& callback) { + ExecuteNotification([&](NotificationContext& notification) { + notification.WithObservers( + [&](ITransportSecurityObserver* observer) { + try { callback(observer); } catch (...) {} + } + ); + }); + } + + public: + void ConfigurationChanged(const TransportSecurityConfig& before, const TransportSecurityConfig& after) { + Notify([&](ITransportSecurityObserver* observer) { + observer->OnTransportSecurityConfigurationChanged(before, after); + }); + } + void SessionReset(uint64_t previousSessionID) { + Notify([&](ITransportSecurityObserver* observer) { + observer->OnTransportSecuritySessionReset(previousSessionID); + }); + } + void SessionEstablished(uint64_t sessionID) { + Notify([&](ITransportSecurityObserver* observer) { + observer->OnTransportSecuritySessionEstablished(sessionID); + }); + } + void ReplayProtectionReset() { + Notify([](ITransportSecurityObserver* observer) { + observer->OnTransportSecurityReplayProtectionReset(); + }); + } + void Failure(const SecurityResult& result) { + Notify([&](ITransportSecurityObserver* observer) { + observer->OnTransportSecurityFailure(result); + }); + } + }; + + AeadCipherRegistry& _ciphers; + const IKeyProvider& _keys; + IRandomSource& _random; + TransportSecurityConfig _config; + ReplayWindow _replay; + uint64_t _sessionID = 0; + uint64_t _nextSequence = 1; + bool _sessionReady = false; + std::shared_ptr _observable = std::make_shared(); + + SecurityResult ReportFailure(SecurityResult result) { + _observable->Failure(result); + return result; + } + + bool EnsureSessionID() { + if (_sessionReady) return true; + if (_config.SessionID != 0) { + _sessionID = _config.SessionID; + _sessionReady = true; + _observable->SessionEstablished(_sessionID); + return true; + } + for (unsigned attempt = 0; attempt < 4; ++attempt) { + uint64_t candidate = 0; + if (!_random.Fill(reinterpret_cast(&candidate), sizeof(candidate))) return false; + if (candidate != 0) { + _sessionID = candidate; + _sessionReady = true; + _observable->SessionEstablished(_sessionID); + return true; + } + } + return false; + } + + static void SecureErase(std::vector& bytes) noexcept { + volatile uint8_t* p = bytes.empty() ? nullptr : bytes.data(); + for (std::size_t i = 0; p && i < bytes.size(); ++i) p[i] = 0; + bytes.clear(); + } + static void Append16(std::vector& o, uint16_t v) { o.push_back(static_cast(v)); o.push_back(static_cast(v >> 8)); } + static void Append32(std::vector& o, uint32_t v) { for (int i=0;i<4;++i) o.push_back(static_cast(v >> (i*8))); } + static void Append64(std::vector& o, uint64_t v) { for (int i=0;i<8;++i) o.push_back(static_cast(v >> (i*8))); } + static bool Read8(const uint8_t* i,std::size_t s,std::size_t& o,uint8_t& v){if(o+1>s)return false;v=i[o++];return true;} + static bool Read16(const uint8_t* i,std::size_t s,std::size_t& o,uint16_t& v){if(o+2>s)return false;v=static_cast(i[o])|(static_cast(i[o+1])<<8);o+=2;return true;} + static bool Read32(const uint8_t* i,std::size_t s,std::size_t& o,uint32_t& v){if(o+4>s)return false;v=0;for(int n=0;n<4;++n)v|=static_cast(i[o+n])<<(n*8);o+=4;return true;} + static bool Read64(const uint8_t* i,std::size_t s,std::size_t& o,uint64_t& v){if(o+8>s)return false;v=0;for(int n=0;n<8;++n)v|=static_cast(i[o+n])<<(n*8);o+=8;return true;} + +public: TransportSecurity(AeadCipherRegistry& ciphers, const IKeyProvider& keys, IRandomSource& random, TransportSecurityConfig config = {}) : _ciphers(ciphers), _keys(keys), _random(random), _config(std::move(config)), _replay(_config.ReplayWindowSize) { if (_config.SessionID != 0) { @@ -31,20 +127,35 @@ class TransportSecurity final { const TransportSecurityConfig& GetConfig() const noexcept { return _config; } uint64_t GetSessionID() const noexcept { return _sessionID; } + Observable::ObserverHandlePtr RegisterObserver(ITransportSecurityObserver* observer) { + return _observable->RegisterObserver(observer); + } + void UnregisterObserver(ITransportSecurityObserver* observer) { + _observable->UnregisterObserver(observer); + } + void SetConfig(TransportSecurityConfig config) { + const TransportSecurityConfig before = _config; + const uint64_t previousSessionID = _sessionID; _config = std::move(config); _replay = ReplayWindow(_config.ReplayWindowSize); _nextSequence = 1; _sessionID = _config.SessionID; _sessionReady = _sessionID != 0; + _observable->ConfigurationChanged(before, _config); + if (previousSessionID != 0 && previousSessionID != _sessionID) _observable->SessionReset(previousSessionID); + if (_sessionReady && _sessionID != previousSessionID) _observable->SessionEstablished(_sessionID); } - void ResetReplayProtection() { _replay.Reset(); } + void ResetReplayProtection() { + _replay.Reset(); + _observable->ReplayProtectionReset(); + } SecurityResult Protect(uint8_t protocol, const uint8_t* plaintext, std::size_t plaintextSize, std::vector& output) { output.clear(); if ((plaintext == nullptr && plaintextSize != 0) || plaintextSize > _config.MaximumPlaintextBytes) - return SecurityResult::Fail(SecurityError::InvalidArgument, "Invalid or oversized plaintext payload"); + return ReportFailure(SecurityResult::Fail(SecurityError::InvalidArgument, "Invalid or oversized plaintext payload")); if (_config.Policy == TransportSecurityPolicy::Disabled) { if (plaintextSize) output.assign(plaintext, plaintext + plaintextSize); @@ -61,27 +172,27 @@ class TransportSecurity final { return SecurityResult::Ok(false); } SecureErase(key.Bytes); - return SecurityResult::Fail(cipher == nullptr ? SecurityError::UnsupportedAlgorithm : SecurityError::MissingKey, - cipher == nullptr ? "Outbound AEAD algorithm is not registered" : "Outbound key is unavailable"); + return ReportFailure(SecurityResult::Fail(cipher == nullptr ? SecurityError::UnsupportedAlgorithm : SecurityError::MissingKey, + cipher == nullptr ? "Outbound AEAD algorithm is not registered" : "Outbound key is unavailable")); } if (key.Bytes.size() != cipher->KeySize()) { SecureErase(key.Bytes); - return SecurityResult::Fail(SecurityError::InvalidKeyLength, "Outbound key length does not match AEAD algorithm"); + return ReportFailure(SecurityResult::Fail(SecurityError::InvalidKeyLength, "Outbound key length does not match AEAD algorithm")); } if (!EnsureSessionID()) { SecureErase(key.Bytes); - return SecurityResult::Fail(SecurityError::RandomFailure, "Transport security session ID generation failed"); + return ReportFailure(SecurityResult::Fail(SecurityError::RandomFailure, "Transport security session ID generation failed")); } if (_nextSequence == 0 || _nextSequence == std::numeric_limits::max()) { SecureErase(key.Bytes); - return SecurityResult::Fail(SecurityError::SequenceExhausted, "Outbound sequence exhausted; establish a new session before continuing"); + return ReportFailure(SecurityResult::Fail(SecurityError::SequenceExhausted, "Outbound sequence exhausted; establish a new session before continuing")); } const uint64_t sequence = _nextSequence++; std::vector nonce(cipher->NonceSize()); if (!_random.Fill(nonce.data(), nonce.size())) { SecureErase(key.Bytes); SecureErase(nonce); - return SecurityResult::Fail(SecurityError::RandomFailure, "Cryptographic nonce generation failed"); + return ReportFailure(SecurityResult::Fail(SecurityError::RandomFailure, "Cryptographic nonce generation failed")); } std::vector header; @@ -106,7 +217,7 @@ class TransportSecurity final { SecureErase(key.Bytes); if (!encrypted || ciphertext.size() != plaintextSize || tag.size() != cipher->TagSize()) { SecureErase(nonce); SecureErase(ciphertext); SecureErase(tag); - return SecurityResult::Fail(SecurityError::EncryptionFailed, "AEAD encryption failed"); + return ReportFailure(SecurityResult::Fail(SecurityError::EncryptionFailed, "AEAD encryption failed")); } output.reserve(header.size() + nonce.size() + ciphertext.size() + tag.size()); @@ -120,15 +231,15 @@ class TransportSecurity final { SecurityResult Unprotect(uint8_t expectedProtocol, const uint8_t* input, std::size_t inputSize, UnprotectedPayload& output) { output = {}; - if (input == nullptr && inputSize != 0) return SecurityResult::Fail(SecurityError::InvalidArgument, "Invalid protected input"); + if (input == nullptr && inputSize != 0) return ReportFailure(SecurityResult::Fail(SecurityError::InvalidArgument, "Invalid protected input")); if (!LooksProtected(input, inputSize)) { if (_config.Policy == TransportSecurityPolicy::Required) - return SecurityResult::Fail(SecurityError::PlaintextRejected, "Plaintext transport payload rejected by Required policy"); + return ReportFailure(SecurityResult::Fail(SecurityError::PlaintextRejected, "Plaintext transport payload rejected by Required policy")); output.Protocol = expectedProtocol; output.Protected = false; if (inputSize) output.Data.assign(input, input + inputSize); return SecurityResult::Ok(false); } - if (inputSize < FixedHeaderSize) return SecurityResult::Fail(SecurityError::MalformedEnvelope, "Transport security envelope is truncated"); + if (inputSize < FixedHeaderSize) return ReportFailure(SecurityResult::Fail(SecurityError::MalformedEnvelope, "Transport security envelope is truncated")); std::size_t offset = 0; uint32_t magic = 0, keyID = 0, ciphertextLength = 0; @@ -140,27 +251,27 @@ class TransportSecurity final { !Read64(input,inputSize,offset,senderID)||!Read64(input,inputSize,offset,sessionID)||!Read64(input,inputSize,offset,sequence)|| !Read8(input,inputSize,offset,nonceLength)||!Read8(input,inputSize,offset,tagLength)||!Read16(input,inputSize,offset,reserved)|| !Read32(input,inputSize,offset,ciphertextLength)) - return SecurityResult::Fail(SecurityError::MalformedEnvelope, "Transport security header is malformed"); + return ReportFailure(SecurityResult::Fail(SecurityError::MalformedEnvelope, "Transport security header is malformed")); (void)flags; (void)reserved; if (magic != EnvelopeMagic || version != EnvelopeVersion) - return SecurityResult::Fail(version != EnvelopeVersion ? SecurityError::UnsupportedVersion : SecurityError::MalformedEnvelope, - version != EnvelopeVersion ? "Unsupported transport security envelope version" : "Invalid transport security envelope magic"); - if (ciphertextLength > _config.MaximumPlaintextBytes) return SecurityResult::Fail(SecurityError::BufferLimitExceeded, "Protected payload exceeds configured limit"); + return ReportFailure(SecurityResult::Fail(version != EnvelopeVersion ? SecurityError::UnsupportedVersion : SecurityError::MalformedEnvelope, + version != EnvelopeVersion ? "Unsupported transport security envelope version" : "Invalid transport security envelope magic")); + if (ciphertextLength > _config.MaximumPlaintextBytes) return ReportFailure(SecurityResult::Fail(SecurityError::BufferLimitExceeded, "Protected payload exceeds configured limit")); const std::size_t expectedSize = FixedHeaderSize + nonceLength + ciphertextLength + tagLength; if (expectedSize != inputSize || sessionID == 0 || sequence == 0 || keyID == 0) - return SecurityResult::Fail(SecurityError::MalformedEnvelope, "Transport security envelope lengths or identifiers are inconsistent"); + return ReportFailure(SecurityResult::Fail(SecurityError::MalformedEnvelope, "Transport security envelope lengths or identifiers are inconsistent")); const AeadAlgorithm algorithm = static_cast(algorithmRaw); IAeadCipher* cipher = _ciphers.Find(algorithm); - if (cipher == nullptr) return SecurityResult::Fail(SecurityError::UnsupportedAlgorithm, "Inbound AEAD algorithm is not registered"); + if (cipher == nullptr) return ReportFailure(SecurityResult::Fail(SecurityError::UnsupportedAlgorithm, "Inbound AEAD algorithm is not registered")); if (nonceLength != cipher->NonceSize() || tagLength != cipher->TagSize()) - return SecurityResult::Fail(SecurityError::MalformedEnvelope, "Nonce/tag size does not match AEAD algorithm"); + return ReportFailure(SecurityResult::Fail(SecurityError::MalformedEnvelope, "Nonce/tag size does not match AEAD algorithm")); if (!_replay.WouldAccept(senderID, keyID, sessionID, sequence)) - return SecurityResult::Fail(SecurityError::ReplayDetected, "Replay or stale protected transport payload rejected"); + return ReportFailure(SecurityResult::Fail(SecurityError::ReplayDetected, "Replay or stale protected transport payload rejected")); KeyMaterial key; - if (!_keys.GetKey(keyID,algorithm,key)) return SecurityResult::Fail(SecurityError::MissingKey, "Inbound key is unavailable"); - if (key.Bytes.size()!=cipher->KeySize()) { SecureErase(key.Bytes); return SecurityResult::Fail(SecurityError::InvalidKeyLength, "Inbound key length does not match AEAD algorithm"); } + if (!_keys.GetKey(keyID,algorithm,key)) return ReportFailure(SecurityResult::Fail(SecurityError::MissingKey, "Inbound key is unavailable")); + if (key.Bytes.size()!=cipher->KeySize()) { SecureErase(key.Bytes); return ReportFailure(SecurityResult::Fail(SecurityError::InvalidKeyLength, "Inbound key length does not match AEAD algorithm")); } const uint8_t* nonce=input+FixedHeaderSize; const uint8_t* ciphertext=nonce+nonceLength; @@ -168,8 +279,8 @@ class TransportSecurity final { std::vector plaintext; const bool authenticated=cipher->Open(key.Bytes.data(),key.Bytes.size(),nonce,nonceLength,input,FixedHeaderSize,ciphertext,ciphertextLength,tag,tagLength,plaintext); SecureErase(key.Bytes); - if (!authenticated) { SecureErase(plaintext); return SecurityResult::Fail(SecurityError::AuthenticationFailed, "AEAD authentication/decryption failed"); } - if (protocol != expectedProtocol) { SecureErase(plaintext); return SecurityResult::Fail(SecurityError::ProtocolMismatch, "Authenticated payload protocol does not match expected transport protocol"); } + if (!authenticated) { SecureErase(plaintext); return ReportFailure(SecurityResult::Fail(SecurityError::AuthenticationFailed, "AEAD authentication/decryption failed")); } + if (protocol != expectedProtocol) { SecureErase(plaintext); return ReportFailure(SecurityResult::Fail(SecurityError::ProtocolMismatch, "Authenticated payload protocol does not match expected transport protocol")); } _replay.Commit(senderID, keyID, sessionID, sequence); output.Protocol=protocol; @@ -188,45 +299,6 @@ class TransportSecurity final { return (static_cast(input[0]) | (static_cast(input[1])<<8) | (static_cast(input[2])<<16) | (static_cast(input[3])<<24)) == EnvelopeMagic; } - -private: - AeadCipherRegistry& _ciphers; - const IKeyProvider& _keys; - IRandomSource& _random; - TransportSecurityConfig _config; - ReplayWindow _replay; - uint64_t _sessionID = 0; - uint64_t _nextSequence = 1; - bool _sessionReady = false; - - bool EnsureSessionID() { - if (_sessionReady) return true; - if (_config.SessionID != 0) { - _sessionID = _config.SessionID; - _sessionReady = true; - return true; - } - - for (unsigned attempt = 0; attempt < 4; ++attempt) { - uint64_t candidate = 0; - if (!_random.Fill(reinterpret_cast(&candidate), sizeof(candidate))) return false; - if (candidate != 0) { - _sessionID = candidate; - _sessionReady = true; - return true; - } - } - return false; - } - - static void SecureErase(std::vector& bytes) noexcept { volatile uint8_t* p=bytes.empty()?nullptr:bytes.data(); for(std::size_t i=0;p&&i& o,uint16_t v){o.push_back(static_cast(v));o.push_back(static_cast(v>>8));} - static void Append32(std::vector& o,uint32_t v){for(int i=0;i<4;++i)o.push_back(static_cast(v>>(i*8)));} - static void Append64(std::vector& o,uint64_t v){for(int i=0;i<8;++i)o.push_back(static_cast(v>>(i*8)));} - static bool Read8(const uint8_t*i,std::size_t s,std::size_t&o,uint8_t&v){if(o+1>s)return false;v=i[o++];return true;} - static bool Read16(const uint8_t*i,std::size_t s,std::size_t&o,uint16_t&v){if(o+2>s)return false;v=static_cast(i[o])|(static_cast(i[o+1])<<8);o+=2;return true;} - static bool Read32(const uint8_t*i,std::size_t s,std::size_t&o,uint32_t&v){if(o+4>s)return false;v=0;for(int n=0;n<4;++n)v|=static_cast(i[o+n])<<(n*8);o+=4;return true;} - static bool Read64(const uint8_t*i,std::size_t s,std::size_t&o,uint64_t&v){if(o+8>s)return false;v=0;for(int n=0;n<8;++n)v|=static_cast(i[o+n])<<(n*8);o+=8;return true;} }; -} +} // namespace ESPressio::Security diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 5b080df..0131149 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -3,13 +3,28 @@ project(ESPressioSecurityTests LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS ON) + +include(FetchContent) +FetchContent_Declare( + ESPressioObservable + GIT_REPOSITORY https://github.com/Flowduino/ESPressio-Observable.git + GIT_TAG 3.0.1 + GIT_SHALLOW TRUE +) +FetchContent_MakeAvailable(ESPressioObservable) +set(ESPRESSIO_OBSERVABLE_INCLUDE ${espressioobservable_SOURCE_DIR}/src) + add_executable(Security test_security.cpp) -target_include_directories(Security PRIVATE ../src) +target_include_directories(Security PRIVATE ../src ${ESPRESSIO_OBSERVABLE_INCLUDE}) add_executable(SecureTransportDecorator test_decorator.cpp) -target_include_directories(SecureTransportDecorator PRIVATE ../src) +target_include_directories(SecureTransportDecorator PRIVATE ../src ${ESPRESSIO_OBSERVABLE_INCLUDE}) add_executable(MbedTLSCompile test_mbedtls_compile.cpp) -target_include_directories(MbedTLSCompile PRIVATE stubs ../src) +target_include_directories(MbedTLSCompile PRIVATE stubs ../src ${ESPRESSIO_OBSERVABLE_INCLUDE}) +add_executable(SecurityObservable test_observable.cpp) +target_include_directories(SecurityObservable PRIVATE ../src ${ESPRESSIO_OBSERVABLE_INCLUDE}) + enable_testing() add_test(NAME Security COMMAND Security) add_test(NAME SecureTransportDecorator COMMAND SecureTransportDecorator) add_test(NAME MbedTLSCompile COMMAND MbedTLSCompile) +add_test(NAME SecurityObservable COMMAND SecurityObservable) diff --git a/tests/test_observable.cpp b/tests/test_observable.cpp new file mode 100644 index 0000000..0b3420e --- /dev/null +++ b/tests/test_observable.cpp @@ -0,0 +1,70 @@ +#include +#include +#include + +#include + +using namespace ESPressio::Security; + +class Observer final : public ITransportSecurityObserver { +public: + int ConfigurationChanged = 0; + int SessionReset = 0; + int SessionEstablished = 0; + int ReplayReset = 0; + int Failures = 0; + uint64_t LastSession = 0; + + void OnTransportSecurityConfigurationChanged( + const TransportSecurityConfig&, + const TransportSecurityConfig& + ) override { ++ConfigurationChanged; } + + void OnTransportSecuritySessionReset(uint64_t) override { ++SessionReset; } + + void OnTransportSecuritySessionEstablished(uint64_t sessionID) override { + ++SessionEstablished; + LastSession = sessionID; + } + + void OnTransportSecurityReplayProtectionReset() override { ++ReplayReset; } + + void OnTransportSecurityFailure(const SecurityResult&) override { ++Failures; } +}; + +int main() { + AeadCipherRegistry ciphers; + StaticKeyProvider keys; + StandardRandomSource random; + TransportSecurity security(ciphers, keys, random); + Observer observer; + auto handle = security.RegisterObserver(&observer); + assert(handle); + + TransportSecurityConfig config = security.GetConfig(); + config.SessionID = 42; + security.SetConfig(config); + assert(observer.ConfigurationChanged == 1); + assert(observer.SessionEstablished == 1); + assert(observer.LastSession == 42); + + config.SessionID = 84; + security.SetConfig(config); + assert(observer.ConfigurationChanged == 2); + assert(observer.SessionReset == 1); + assert(observer.SessionEstablished == 2); + assert(observer.LastSession == 84); + + security.ResetReplayProtection(); + assert(observer.ReplayReset == 1); + + std::vector output; + auto result = security.Protect(1, nullptr, 1, output); + assert(!result.Success); + assert(observer.Failures == 1); + + handle.reset(); + security.ResetReplayProtection(); + assert(observer.ReplayReset == 1); + return 0; +}