diff --git a/Cargo.lock b/Cargo.lock index a8fe175..e229637 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1471,7 +1471,6 @@ dependencies = [ "coap-lite", "criterion", "env_logger", - "getrandom 0.2.15", "heapless", "hex", "hkdf", @@ -2646,7 +2645,6 @@ checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" dependencies = [ "curve25519-dalek", "rand_core 0.6.4", - "serde", "zeroize", ] diff --git a/Cargo.toml b/Cargo.toml index 52f10ed..b3bdb8d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,9 +39,9 @@ dilithium = ["dep:pqcrypto-mldsa"] saber = ["pqcrypto-saber"] coap = [] -coap-std = ["coap", "dep:coap-lite"] +coap-std = ["std", "coap", "dep:coap-lite"] -config = ["serde", "toml", "dep:serde_json"] +config = ["std", "serde", "toml", "dep:serde_json"] default = [ "std", "config", @@ -55,10 +55,10 @@ default = [ embedded = ["no-std", "heapless", "serde"] heapless = [] -mqtt = ["rumqttc", "tokio", "dep:rand", "dep:serde_json", "serde_json/std"] +mqtt = ["std", "rumqttc", "tokio", "dep:rand", "dep:serde_json", "serde_json/std"] # ... no-std = [] -std = [] +std = ["rand_core/getrandom", "log/std"] alloc = [] # Profile-specific features @@ -74,7 +74,6 @@ profile-saber-dilithium = [] [dependencies] cfg-if = "1.0" coap-lite = {version = "0.9", optional = true} -getrandom = { version = "0.2", default-features = false, features = ["custom"] } # heapless 0.7 pulls `atomic-polyfill` on some targets (RUSTSEC-2023-0089). 0.9 moved to # `portable-atomic`, reducing exposure to unmaintained crates. heapless = { version = "0.9.2", default-features = false } @@ -103,11 +102,11 @@ base64 = { version = "0.22", default-features = false, features = ["alloc"] } rand = { version = "0.8", optional = true, default-features = false, features = ["std", "std_rng"] } # std_rng for higher quality on OS serde_json = { version = "1.0", optional = true, default-features = false, features = ["alloc"] } sha2 = { version = "0.10", default-features = false } -hkdf = "0.12" -hmac = "0.12" -log = "0.4.29" +hkdf = { version = "0.12", default-features = false } +hmac = { version = "0.12", default-features = false } +log = { version = "0.4.29", default-features = false } hex = { version = "0.4.3", default-features = false, features = ["alloc"] } -x25519-dalek = { version = "2.0.1", features = ["static_secrets"] } +x25519-dalek = { version = "2.0.1", default-features = false, features = ["static_secrets", "zeroize"] } [dev-dependencies] criterion = "0.5" diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 22cac14..7a178dc 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -3,6 +3,7 @@ - [Introduction](index.md) - [Security Architecture](architecture.md) - [Threat Model](security/threat_model.md) + - [Security Invariants](security/invariants.md) - [FIPS 140-3 Compliance](security/compliance.md) - [Usage Guide](usage.md) - [MQTT Integration](usage/mqtt.md) @@ -32,4 +33,3 @@ - [Deep IIoT Scenarios](usage/deep_iiot_scenarios.md) - [Abyssal Threat Model (Math & Lattice)](architecture/threat_model_abyssal.md) - [Space-Grade Physics (TMR & Hybrid)](architecture/space_grade_physics.md) - diff --git a/docs/src/security/invariants.md b/docs/src/security/invariants.md new file mode 100644 index 0000000..1b52847 --- /dev/null +++ b/docs/src/security/invariants.md @@ -0,0 +1,197 @@ +# Security Invariants (Contract) + +This document is the *contract* for security-relevant behavior in PQC-IIoT. The intent is to make the system’s trust boundaries, assumptions, and “must-hold” properties explicit and regression-testable. + +If a change violates an invariant, it is a **security bug**, even if unit tests still pass. + +## Scope + +This contract covers the reference protocol surfaces implemented in this repository: + +- MQTT key announcements and encrypted payload delivery (`src/mqtt_secure.rs`) +- MQTT authenticated sessions + symmetric ratchet (forward secrecy building block) (`src/mqtt_secure.rs`) +- Provisioning-backed identity (`src/provisioning.rs`) +- Signed audit logging (`src/security/audit.rs`) +- CoAP payload authenticity shim + session-based secure mode (`src/coap_secure.rs`) + +Out of scope (by design, today): + +- OSCORE / DTLS transport security for CoAP (required for confidentiality + replay protection) +- Secure time / monotonic counters backed by TPM/TEE/HSM (we only implement a best-effort monotonic floor) +- Strong post-compromise security (PCS) for MQTT/CoAP (needs a DH ratchet; periodic re-handshake exists but is not PCS) + +## Trust Boundaries + +### 1) `SecurityProvider` boundary + +Long-term secrets live behind `SecurityProvider` (`src/security/provider.rs`). Anything outside that boundary must be treated as hostile input: + +- MQTT broker and network traffic +- local filesystem state (identity, keystore, audit log) unless sealed in a rollback-resistant provider + +Critical deployment note: +`SecureMqttClient::new()` uses an exportable software identity for demos/tests. For production fleets, +use `SecureMqttClient::new_with_provider()` with a TPM/HSM/TEE-backed `SecurityProvider` so: + +- identity keys are non-exportable, and +- sealed state (time floor, keystore anti-rollback counters, revocation sequence) is rollback-resistant. + +### 2) MQTT broker is *not trusted* + +Assume the broker can: + +- reorder, retain, replay, and duplicate publishes +- inject arbitrary topics/payloads +- drop packets (liveness loss) + +Therefore: + +- no TOFU-by-accident in strict mode +- message authenticity cannot depend on broker ordering +- replay protection must be bounded and deterministic + +## Invariants + +### I0 — Peer identifiers are bounded and sanitized + +Peer IDs appear: + +- as MQTT topic suffixes (`pqc/keys/`) +- inside encrypted packet prefixes (`[id_len][peer_id]...`) +- as keystore hashmap keys and log/metric dimensions + +**Invariant**: + +- `peer_id` MUST be ASCII `[A-Za-z0-9_.-]` and `len(peer_id) <= 128`. +- Invalid IDs MUST be dropped *before* any expensive operation or state insertion. + +**Enforced in**: `src/mqtt_secure.rs` (wire ID validation + early drops). + +### I1 — Strict mode eliminates TOFU + +**Invariant**: + +- With `strict_mode=true` (default), a peer MUST NOT become trusted/ready unless: + - an `OperationalCertificate` is present, and + - the certificate verifies under a pinned CA public key, and + - the certificate subject binds to `peer_id` (topic suffix), and + - the announced keys match the certificate. + +**Enforced in**: `SecureMqttClient::handle_key_exchange`. + +**Regression tests**: `tests/integration_tests.rs::test_strict_mode`. + +### I2 — Key announcements are identity-bound and non-malleable + +**Invariant**: + +- Key announcements MUST include a detached `key_signature`. +- The signature MUST verify over a canonical payload with explicit domain separation and `peer_id` binding. +- `key_epoch` MUST be monotonic per peer (anti-rollback). +- For the same `key_epoch`, `key_id` MUST be identical (epoch collision rejection). + +**Enforced in**: + +- canonical payload: `key_announcement_payload()` in `src/mqtt_secure.rs` +- anti-rollback: `handle_key_exchange` epoch/key_id checks + +**Regression tests**: + +- `tests/integration_tests.rs::test_key_announcement_binds_peer_id` +- `tests/integration_tests.rs::test_malicious_key_announcement_rejected` + +### I3 — Encrypted MQTT messages have explicit domain separation and topic binding + +**Invariant**: + +- For encrypted MQTT packets, the signature MUST cover a digest of: + - a protocol domain tag (`pqc-iiot:mqtt-msg:v1`) + - `sender_id` + - MQTT `topic` + - `encrypted_blob` + +This prevents cross-protocol confusion and topic re-routing (semantic confusion) attacks. + +**Enforced in**: + +- sender: `SecureMqttClient::publish_encrypted` +- receiver: `SecureMqttClient::process_notification` + +**Regression tests**: + +- `tests/mqtt_invariants.rs::mqtt_signature_binds_topic` + +### I4 — Replay protection is bounded and supports limited reordering + +MQTT delivery can be duplicated and out-of-order in real deployments. Strict monotonic sequencing is not availability-safe. + +**Invariant**: + +- Each peer maintains a sliding replay window (64-bit bitmap) relative to `last_sequence`. +- A sequence number MUST be accepted iff: + - it is within the window, and + - it has not been seen before. +- Messages older than the window MUST be rejected deterministically. + +**Enforced in**: `replay_window_accept()` in `src/mqtt_secure.rs` (persisted in `PeerKeys`). + +**Regression tests**: + +- `tests/mqtt_invariants.rs::mqtt_replay_window_accepts_out_of_order_within_window` + +### I5 — Input size limits exist before parsing / crypto + +**Invariant**: + +- Untrusted payloads MUST be rejected by size before any parsing or expensive cryptography. +- Limits MUST be explicit and configurable, not “implicit by broker defaults”. + +**Enforced in**: `src/mqtt_secure.rs` per-message-type limits: + +- key announcements +- attestation challenge/quote +- encrypted packets + +### I6 — Audit log is signed if it claims tamper-evidence + +A pure hash chain is *not* tamper-evident against an attacker with filesystem write access: they can rewrite the file and recompute the chain. + +**Invariant**: + +- If the audit log is used as evidence, each chained entry MUST be signed with a device identity key that is non-exportable in production (TPM/HSM-backed). +- If a non-exportable signer is not available, the audit log MUST be treated as best-effort observability, not forensics-grade evidence. + +**Enforced in**: + +- signing: `ChainedAuditLogger::new_signed()` in `src/security/audit.rs` +- consumers: `SecureMqttClient` uses the signed logger by default + +### I7 — Distributed revocation updates are authenticated and monotonic + +**Invariant**: + +- Revocation updates MUST verify under a pinned CA signature key and be bound to the configured revocation topic. +- Updates MUST enforce monotonic `seq` to prevent rollback/replay. +- A revoked `(peer_id, key_id)` MUST NOT be able to: + - complete a key exchange in strict mode, or + - send encrypted messages that are accepted by receivers. + +**Enforced in**: + +- message format + verification: `src/security/revocation.rs` +- receiver application: `src/mqtt_secure.rs` (`handle_revocation_update`, key exchange gating, and pre-decrypt key_id checks) + +**Regression tests**: + +- `tests/integration_tests.rs::test_distributed_revocation_blocks_peer` + +## What this contract does *not* guarantee + +This project intentionally does not yet provide: + +- a trusted secure time source (we only enforce a best-effort monotonic floor; validity windows remain weak without TPM/TEE/HSM) +- standardized CoAP transport security (OSCORE/DTLS); the session-based secure CoAP mode is application-level and not OSCORE +- strong post-compromise security (PCS) for MQTT/CoAP (no DH ratchet; only periodic re-handshake) +- revocation removal / unrevocation semantics (revocation is monotonic and additive) + +For critical IIoT deployments, treat these as **blockers**, not “nice-to-haves”. diff --git a/docs/src/security/threat_model.md b/docs/src/security/threat_model.md index 51bff0e..5e6c287 100644 --- a/docs/src/security/threat_model.md +++ b/docs/src/security/threat_model.md @@ -1,32 +1,88 @@ # Threat Model -This document outlines the threats PQC-IIoT is designed to mitigate. +This document defines what PQC-IIoT assumes about adversaries and what security properties the current codebase aims to enforce. -## 1. "Store Now, Decrypt Later" (SNDL) +This is a *systems* threat model: it explicitly treats the network, the broker, and local persistence as attacker-controlled unless proven otherwise. -**Threat**: Attackers record encrypted traffic today, intending to decrypt it years later when a sufficiently powerful quantum computer becomes available. -**Mitigation**: Use of Kyber (KEM) ensures that session keys cannot be retroactively recovered by quantum algorithms like Shor's algorithm. +## Actors and Capabilities -## 2. Identity Impersonation +### A1 — Network attacker (remote) -**Threat**: An attacker attempts to masquerade as a legitimate sensor or controller to inject false data or commands. -**Mitigation**: Falcon digital signatures provide strong, quantum-resistant authentication. Strict Allow-listing of public keys prevents unauthorized devices from joining the network. +Assume an attacker can: -## 3. Replay Attacks +- observe, replay, reorder, and drop traffic +- inject arbitrary packets +- attempt downgrade/TOFU by racing “first contact” messages +- exploit cost asymmetry (force expensive signature/KEM work) -**Threat**: An attacker captures a valid command (e.g., "Open Valve") and re-transmits it later. -**Mitigation**: Encrypted packets contain a sequential counter. The receiver tracks the `last_seen` counter for each peer and rejects duplicates or out-of-order packets. +### A2 — MQTT broker attacker (malicious broker) -## 4. Key Extraction from Memory +Treat the broker as untrusted infrastructure. It can: -**Threat**: Malware or physical access allows an attacker to dump device RAM and extract private keys. -**Mitigation**: -- **Hardware**: Integration with TPM 2.0 ensures keys are non-exportable and operations happen inside the secure element. -- **Software**: The `software` provider uses the `zeroize` crate to wipe keys from memory immediately after use or on drop. +- publish arbitrary topics/payloads +- replay retained messages indefinitely +- rewrite key announcements +- act as a cardinality amplifier (many peer IDs, many topics) -## 5. Side-Channel Attacks (SCA) +### A3 — Local persistence attacker (filesystem write) -**Threat**: Analyzing power consumption or timing to deduce private keys. -**Mitigation**: -- Underlying libraries (`pqcrypto-*`) utilize constant-time implementations where available. -- Hardware offloading (TPM) provides physical resistance to SCA. +If an attacker can modify local files (keystore, audit log, identity), assume they can: + +- truncate or rewrite logs +- roll back state to bypass replay protection +- poison keystore entries to block liveness (availability attack) + +Unless the `SecurityProvider` is backed by a TPM/HSM, filesystem security is best-effort. + +## Primary Threats and Current Mitigations + +### T1 — Harvest-now, decrypt-later (HN-DL) + +**Threat**: capture ciphertext today; attempt decryption in the future with quantum capability. + +**Mitigation**: hybrid KEM uses Kyber/ML-KEM as the PQ component. The goal is to remove reliance on classical DH alone. (Note: no primitive has a proof of “quantum resistance”; security is based on current cryptanalytic consensus and conservative parameterization.) + +### T2 — Identity impersonation / key announcement rewriting + +**Threat**: broker or MITM republishes a valid announcement under a different peer ID, or injects forged keys to impersonate a peer. + +**Mitigations**: + +- strict mode (default) requires `OperationalCertificate` verification under a pinned CA key +- key announcements are signed over a canonical payload with explicit domain separation and peer-id binding +- epoch/key-id checks provide anti-rollback and collision detection + +### T3 — Replay and reordering + +**Threat**: attacker replays a valid encrypted command; or induces out-of-order delivery (common in field networks). + +**Mitigation**: per-peer sliding replay window (bitmap) rejects duplicates while tolerating bounded reordering. + +### T4 — Cross-topic / cross-protocol confusion + +**Threat**: attacker re-routes a valid encrypted blob into a different MQTT topic and changes the semantic meaning at the application layer. + +**Mitigation**: encrypted MQTT packets are signed over a digest that binds `sender_id + topic + encrypted_blob` under an explicit domain tag. + +### T5 — Parsing and allocation DoS + +**Threat**: attacker forces large allocations or pathological parsing via oversized JSON/key announcements. + +**Mitigation**: hard byte limits are enforced *before* parsing/crypto for key announcements, attestation messages, and encrypted packets. + +### T6 — Audit log rewriting / truncation + +**Threat**: attacker with filesystem write access rewrites the audit log and recomputes any unkeyed hash chain. + +**Mitigation**: audit entries are hash-chained and can be additionally signed via a `SecurityProvider` signer (meaningful only when backed by non-exportable keys in TPM/HSM). + +## Known Gaps (Critical for real IIoT deployments) + +These are not paper cuts; they are architectural blockers for safety/security-critical systems: + +- **Secure time / monotonic counters**: without a trusted monotonic source, time-window enforcement and replay state rollback are weak. +- **Post-compromise security (PCS)**: MQTT sessions provide forward secrecy, but there is no DH ratchet / periodic re-key to recover after compromise of a current chain key. +- **CoAP transport standardization**: session-based secure CoAP exists, but OSCORE/DTLS are not implemented; full CoAP option/method binding and standardized replay context are still missing. +- **Distributed policy under partitions**: CA-signed policy/revocation updates exist, but guaranteed catch-up semantics are not implemented; fleets must define TTL/fail-closed behavior under long partitions. + +The concrete invariants enforced by the codebase are specified in `security/invariants.md`. diff --git a/docs/src/usage/coap.md b/docs/src/usage/coap.md index 94e7ef6..360280a 100644 --- a/docs/src/usage/coap.md +++ b/docs/src/usage/coap.md @@ -1,6 +1,15 @@ # Secure CoAP Integration -The `SecureCoapClient` provides secure Request/Response patterns over UDP using Hybrid Encryption. +This repository currently provides two CoAP security modes: + +- `SecureCoapClient`: **payload authenticity** only (Falcon signature appended to the CoAP payload). +- `SecureCoapSessionClient`: **session-based confidentiality + integrity + replay protection** using an authenticated handshake and AEAD (not OSCORE/DTLS). + +`SecureCoapClient` provides payload authenticity for CoAP messages by attaching a Falcon signature to the CoAP payload and verifying responses against a pinned peer public key. + +It is intentionally minimal and does not provide confidentiality or replay protection. + +For safety/security-critical IIoT deployments, you should run CoAP over OSCORE or DTLS (or an equivalent authenticated secure transport). The session-based mode is a pragmatic security context but is not a standards-based OSCORE/DTLS implementation. ## Example: Secure GET Request @@ -10,16 +19,19 @@ use std::net::SocketAddr; fn main() -> Result<(), Box> { // 1. Initialize Client - let client = SecureCoapClient::new()?; + // + // IMPORTANT: you must pin the peer's Falcon public key for response verification. + // In production, obtain this from provisioning (not from the network/broker). + let peer_sig_pk: Vec = vec![]; // provisioned + let client = SecureCoapClient::new()?.with_peer_sig_pk(peer_sig_pk); let server_addr: SocketAddr = "127.0.0.1:5683".parse()?; - // 2. Send Encrypted GET - // Automatically performs handshake if session keys are missing + // 2. Send request (payload is signed by the client) let response = client.get(server_addr, "sensors/temp")?; // 3. Verify Response - // Decrypts payload and verifies Falcon signature + // Verifies Falcon signature using the pinned peer public key and returns the unsigned payload. let payload = client.verify_response(&response)?; println!("Response: {:?}", String::from_utf8_lossy(&payload)); @@ -31,47 +43,38 @@ fn main() -> Result<(), Box> { ## Protocol Specification -PQC-IIoT implements **Object Security for Constrained RESTful Environments (OSCORE)**-inspired application layer security, adapted for Post-Quantum primitives. +This repository does not implement OSCORE/DTLS. The protocol formats below are local to this project. + +### `SecureCoapClient` Payload Format (Request & Response) -### Packet Format (Request & Response) +The CoAP payload is: -The CoAP payload is replaced entirely by the PQC blob. +`[message][signature][sig_len_be_u16]` -#### 1. Secure Request (Client -> Server) +Where: -| Section | Field | Size (Bytes) | Description | -| :--- | :--- | :--- | :--- | -| **Header** | **Version** | 1 | `0x01` | -| | **Capsule** | 1088 | Kyber-768 Ciphertext (Key Exchange) | -| **Body** | **Nonce** | 12 | AES-GCM Nonce | -| | **Ciphertext** | $Len(P) + 16$ | AES-256-GCM Encrypted Payload | -| **Auth** | **Signature** | ~666 | Falcon-512 Signature of entire packet | +- `message` is the application payload bytes +- `signature` is a Falcon detached signature +- `sig_len_be_u16` is the signature length (big-endian) -**Total Overhead**: ~1770 bytes + Payload. -*Note: This necessitates specific CoAP Block-Wise Transfer (Block1) support or high-MTU networks (WiFi/Ethernet).* +This format is simple but incomplete for critical systems: -#### 2. Secure Response (Server -> Client) +- it does not bind method/path/options into the signature +- it does not provide anti-replay (no nonce/counter) +- it does not provide confidentiality -Since the session key is established in the Request, the Response does NOT need a new Kyber Capsule. It reuses the session key derived from the Request (or a derived session key). +If you need those properties, use OSCORE/DTLS and treat this signature layer as redundant defense-in-depth (or remove it to avoid cost). -| Section | Field | Size (Bytes) | Description | -| :--- | :--- | :--- | :--- | -| **Body** | **Nonce** | 12 | New AES-GCM Nonce | -| | **Ciphertext** | $Len(P) + 16$ | AES-256-GCM Encrypted Response | -| **Auth** | **Signature** | ~666 | Falcon-512 Signature | +### `SecureCoapSessionClient` Session Mode (non-OSCORE) -**Total Overhead**: ~680 bytes + Payload. +The session client/server (`SecureCoapSessionClient` / `SecureCoapSessionServer`) implement: -### Handshake Flow +- an authenticated session handshake (Falcon signatures) on `pqc/session/init` +- hybrid forward secrecy from ephemeral Kyber + ephemeral X25519 +- AEAD payload protection using AES-256-GCM with per-message key evolution +- bounded out-of-order receive using a skipped-key window (anti-replay within the session) -1. **Client** generates ephemeral Kyber KeyPair (or uses static if pre-provisioned). -2. **Client** encapsulates against Server's Static Public Key -> `Capsule`, `SharedSecret`. -3. **Client** encrypts Request Payload with `SharedSecret`. -4. **Client** signs `[Capsule | Nonce | Ciphertext]` with Client's Falcon Private Key. -5. **Server** receives, verifies Falcon Signature (Authentication). -6. **Server** decapsulates `Capsule` -> `SharedSecret`. -7. **Server** decrypts Payload. -8. **Server** processes request, generates Response. -9. **Server** encrypts Response with `SharedSecret` (and new Nonce). -10. **Server** signs Response. +This is an application-level security context, not OSCORE: +- it does not define a standardized security context for CoAP options beyond what is bound in the AAD +- it does not persist session replay state across restarts (a reboot drops sessions) diff --git a/src/coap_secure.rs b/src/coap_secure.rs index 8e110eb..fd9011b 100644 --- a/src/coap_secure.rs +++ b/src/coap_secure.rs @@ -7,11 +7,29 @@ #[cfg(feature = "coap-std")] mod std_client { - use crate::crypto::traits::PqcSignature; - use crate::{Error, Falcon, Kyber, Result}; - use coap_lite::{CoapRequest, CoapResponse, Packet, RequestType}; + use crate::crypto::traits::{PqcKEM, PqcSignature}; + use crate::{Error, Falcon, Kyber, KyberSecurityLevel, Result}; + use coap_lite::block_handler::{BlockHandler, BlockHandlerConfig, BlockValue}; + use coap_lite::{CoapOption, CoapRequest, CoapResponse, Packet, RequestType, ResponseType}; + use hkdf::Hkdf; + use rand_core::{OsRng, RngCore}; + use sha2::Sha256; + use std::collections::HashMap; use std::net::{SocketAddr, UdpSocket}; use std::time::Duration; + use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret as X25519StaticSecret}; + use zeroize::{Zeroize, Zeroizing}; + + use aes_gcm::{ + aead::{Aead, KeyInit, Payload}, + Aes256Gcm, Nonce, + }; + + const COAP_SESSION_INIT_PATH: &str = "pqc/session/init"; + const COAP_SESSION_VERSION_V1: u8 = 1; + const COAP_MAX_SESSION_CONTROL_BYTES: usize = 64 * 1024; + const COAP_MAX_SECURE_PAYLOAD_BYTES: usize = 256 * 1024; + const COAP_SECURE_MSG_MAGIC_V2: &[u8] = b"PQCCP2"; /// DTLS Configuration for Secure CoAP. #[derive(Debug, Clone)] @@ -37,6 +55,11 @@ mod std_client { /// /// This implementation requires `std` networking and is intentionally kept simple: /// it signs payloads end-to-end and appends `[signature][sig_len_be_u16]` to the payload. + /// + /// Security note: + /// - This provides **authenticity** of application payloads when the peer's public key is pinned. + /// - It does **not** provide transport confidentiality or replay protection. For critical IIoT use, + /// deploy OSCORE/DTLS (or an equivalent authenticated secure transport) underneath. #[allow(dead_code)] pub struct SecureCoapClient { kyber: Kyber, @@ -45,6 +68,8 @@ mod std_client { // Falcon512 identity keys used for request signing and response verification. sig_sk: Vec, sig_pk: Vec, + /// Pinned peer identity key used to verify responses. + peer_sig_pk: Option>, // Configuration timeout: Duration, @@ -72,6 +97,7 @@ mod std_client { falcon, sig_sk: sk, sig_pk: pk, + peer_sig_pk: None, timeout: Duration::from_secs(2), retransmission_count: 4, block_size: 1024, @@ -118,7 +144,13 @@ mod std_client { self } - fn ensure_socket(&mut self) -> Result<&UdpSocket> { + /// Pin the peer (server) Falcon public key used to verify responses. + pub fn with_peer_sig_pk(mut self, peer_sig_pk: Vec) -> Self { + self.peer_sig_pk = Some(peer_sig_pk); + self + } + + fn ensure_socket(&mut self) -> Result { if self.socket.is_none() { let socket = UdpSocket::bind("0.0.0.0:0").map_err(|e| Error::ClientError(e.to_string()))?; @@ -129,7 +161,9 @@ mod std_client { } self.socket .as_ref() - .ok_or_else(|| Error::ClientError("Socket missing".into())) + .ok_or_else(|| Error::ClientError("Socket missing".into()))? + .try_clone() + .map_err(|e| Error::ClientError(e.to_string())) } fn sign_payload(&self, payload: &[u8]) -> Result> { @@ -220,7 +254,13 @@ mod std_client { /// Verifies a received CoAP response and returns the unsigned message payload. pub fn verify_response(&self, response: &CoapResponse) -> Result> { - verify_signed_payload(&self.falcon, &self.sig_pk, &response.message.payload) + let pk = self.peer_sig_pk.as_deref().ok_or_else(|| { + Error::InvalidInput( + "Missing peer identity key: call SecureCoapClient::with_peer_sig_pk()" + .to_string(), + ) + })?; + verify_signed_payload(&self.falcon, pk, &response.message.payload) } } @@ -242,6 +282,1292 @@ mod std_client { Ok(message.to_vec()) } + const COAP_SESSION_MAX_SKIPPED_KEYS: usize = 50; + const COAP_SESSION_MAX_MESSAGES: u32 = 100_000; + + #[derive(Debug)] + struct CoapSession { + session_id: [u8; 16], + send_chain_key: [u8; 32], + recv_chain_key: [u8; 32], + send_msg_num: u32, + recv_msg_num: u32, + skipped_message_keys: HashMap, + } + + #[derive(Clone, Copy)] + struct CoapMsgBinding<'a> { + sender_id: &'a str, + receiver_id: &'a str, + code: u8, + path: &'a str, + token: &'a [u8], + } + + impl CoapMsgBinding<'_> { + fn aad_v2(&self, session_id: &[u8; 16], msg_num: u32) -> Vec { + let mut aad = Vec::new(); + aad.extend_from_slice(b"pqc-iiot:coap-msg:v2"); + aad.extend_from_slice(&(self.sender_id.len() as u16).to_be_bytes()); + aad.extend_from_slice(self.sender_id.as_bytes()); + aad.extend_from_slice(&(self.receiver_id.len() as u16).to_be_bytes()); + aad.extend_from_slice(self.receiver_id.as_bytes()); + aad.push(self.code); + aad.extend_from_slice(&(self.path.len() as u16).to_be_bytes()); + aad.extend_from_slice(self.path.as_bytes()); + aad.push(self.token.len() as u8); + aad.extend_from_slice(self.token); + aad.extend_from_slice(session_id); + aad.extend_from_slice(&msg_num.to_be_bytes()); + aad + } + } + + impl CoapSession { + fn new(session_id: [u8; 16], send_chain_key: [u8; 32], recv_chain_key: [u8; 32]) -> Self { + Self { + session_id, + send_chain_key, + recv_chain_key, + send_msg_num: 0, + recv_msg_num: 0, + skipped_message_keys: HashMap::new(), + } + } + + fn kdf_ck(ck: &[u8; 32]) -> Result<([u8; 32], [u8; 32])> { + let hkdf = Hkdf::::from_prk(ck) + .map_err(|_| Error::CryptoError("HKDF PRK init failed".into()))?; + let mut mk = [0u8; 32]; + let mut next_ck = [0u8; 32]; + hkdf.expand(b"pqc-iiot:coap-session:v1:mk", &mut mk) + .map_err(|_| Error::CryptoError("HKDF expand failed (mk)".into()))?; + hkdf.expand(b"pqc-iiot:coap-session:v1:ck", &mut next_ck) + .map_err(|_| Error::CryptoError("HKDF expand failed (ck)".into()))?; + Ok((next_ck, mk)) + } + + fn nonce_v2(session_id: &[u8; 16], msg_num: u32) -> [u8; 12] { + let mut nonce = [0u8; 12]; + nonce[..8].copy_from_slice(&session_id[..8]); + nonce[8..].copy_from_slice(&msg_num.to_be_bytes()); + nonce + } + + fn encrypt_v2( + &mut self, + binding: &CoapMsgBinding<'_>, + plaintext: &[u8], + ) -> Result<(u32, Vec)> { + if self.send_msg_num >= COAP_SESSION_MAX_MESSAGES { + return Err(Error::ProtocolError(format!( + "CoAP session {} exhausted message budget (send)", + hex::encode(self.session_id) + ))); + } + + let (next_ck, mk) = Self::kdf_ck(&self.send_chain_key)?; + self.send_chain_key = next_ck; + let msg_num = self.send_msg_num; + self.send_msg_num = self.send_msg_num.saturating_add(1); + + let aad = binding.aad_v2(&self.session_id, msg_num); + let nonce_bytes = Self::nonce_v2(&self.session_id, msg_num); + + let cipher = Aes256Gcm::new(aes_gcm::Key::::from_slice(&mk)); + let ciphertext = cipher + .encrypt( + Nonce::from_slice(&nonce_bytes), + Payload { + msg: plaintext, + aad: &aad, + }, + ) + .map_err(|_| Error::CryptoError("AES-GCM encryption failed".into()))?; + + Ok((msg_num, ciphertext)) + } + + fn decrypt_with_mk_v2( + &self, + binding: &CoapMsgBinding<'_>, + msg_num: u32, + mk: &[u8; 32], + ciphertext: &[u8], + ) -> Result> { + let aad = binding.aad_v2(&self.session_id, msg_num); + let nonce_bytes = Self::nonce_v2(&self.session_id, msg_num); + let cipher = Aes256Gcm::new(aes_gcm::Key::::from_slice(mk)); + cipher + .decrypt( + Nonce::from_slice(&nonce_bytes), + Payload { + msg: ciphertext, + aad: &aad, + }, + ) + .map_err(|_| Error::CryptoError("AES-GCM decryption failed".into())) + } + + fn decrypt_v2( + &mut self, + binding: &CoapMsgBinding<'_>, + msg_num: u32, + ciphertext: &[u8], + ) -> Result> { + if let Some(mk) = self.skipped_message_keys.remove(&msg_num) { + return self.decrypt_with_mk_v2(binding, msg_num, &mk, ciphertext); + } + + if msg_num < self.recv_msg_num { + return Err(Error::CryptoError("Message too old / replay".into())); + } + + let delta = msg_num - self.recv_msg_num; + if delta > COAP_SESSION_MAX_SKIPPED_KEYS as u32 { + return Err(Error::CryptoError( + "Message too far in the future (skip limit exceeded)".into(), + )); + } + + while self.recv_msg_num < msg_num { + let (next_ck, mk) = Self::kdf_ck(&self.recv_chain_key)?; + self.skipped_message_keys.insert(self.recv_msg_num, mk); + self.recv_chain_key = next_ck; + self.recv_msg_num = self.recv_msg_num.saturating_add(1); + } + + let (next_ck, mk) = Self::kdf_ck(&self.recv_chain_key)?; + self.recv_chain_key = next_ck; + self.recv_msg_num = self.recv_msg_num.saturating_add(1); + + self.decrypt_with_mk_v2(binding, msg_num, &mk, ciphertext) + } + } + + impl Drop for CoapSession { + fn drop(&mut self) { + self.send_chain_key.zeroize(); + self.recv_chain_key.zeroize(); + for (_, key) in self.skipped_message_keys.iter_mut() { + key.zeroize(); + } + self.skipped_message_keys.clear(); + } + } + + struct PendingCoapSessionInit { + kem_sk: Zeroizing>, + x25519_sk: X25519StaticSecret, + } + + impl Drop for PendingCoapSessionInit { + fn drop(&mut self) { + self.kem_sk.zeroize(); + self.x25519_sk.zeroize(); + } + } + + #[derive(Debug, Clone)] + struct CachedCoapSessionResponse { + session_seq: u64, + session_id: [u8; 16], + bytes: Vec, + } + + fn vec_to_16(bytes: &[u8]) -> Result<[u8; 16]> { + if bytes.len() != 16 { + return Err(Error::InvalidInput(format!( + "Invalid 16-byte field length: {}", + bytes.len() + ))); + } + let mut out = [0u8; 16]; + out.copy_from_slice(bytes); + Ok(out) + } + + fn vec_to_32(bytes: &[u8]) -> Result<[u8; 32]> { + if bytes.len() != 32 { + return Err(Error::InvalidInput(format!( + "Invalid 32-byte field length: {}", + bytes.len() + ))); + } + let mut out = [0u8; 32]; + out.copy_from_slice(bytes); + Ok(out) + } + + fn kyber_for_pk_len(len: usize) -> Result { + match len { + 800 => Ok(Kyber::new_with_level(KyberSecurityLevel::Kyber512)), + 1184 => Ok(Kyber::new_with_level(KyberSecurityLevel::Kyber768)), + 1568 => Ok(Kyber::new_with_level(KyberSecurityLevel::Kyber1024)), + _ => Err(Error::InvalidInput(format!( + "Invalid Kyber public key length: {}", + len + ))), + } + } + + fn kyber_for_sk_len(len: usize) -> Result { + match len { + 1632 => Ok(Kyber::new_with_level(KyberSecurityLevel::Kyber512)), + 2400 => Ok(Kyber::new_with_level(KyberSecurityLevel::Kyber768)), + 3168 => Ok(Kyber::new_with_level(KyberSecurityLevel::Kyber1024)), + _ => Err(Error::InvalidInput(format!( + "Invalid Kyber secret key length: {}", + len + ))), + } + } + + fn derive_session_chain_keys_v1( + session_id: &[u8; 16], + kem_ss: &[u8], + dh_ss: &[u8], + ) -> Result<([u8; 32], [u8; 32])> { + if kem_ss.len() != 32 || dh_ss.len() != 32 { + return Err(Error::CryptoError(format!( + "Invalid session shared secret lengths: kem_ss={} dh_ss={}", + kem_ss.len(), + dh_ss.len() + ))); + } + let mut ikm = [0u8; 64]; + ikm[..32].copy_from_slice(kem_ss); + ikm[32..].copy_from_slice(dh_ss); + + let hk = Hkdf::::new(Some(session_id), &ikm); + let mut ck_initiator = [0u8; 32]; + let mut ck_responder = [0u8; 32]; + hk.expand(b"pqc-iiot:coap-session:v1:ck-initiator", &mut ck_initiator) + .map_err(|_| Error::CryptoError("HKDF expand failed (ck-initiator)".into()))?; + hk.expand(b"pqc-iiot:coap-session:v1:ck-responder", &mut ck_responder) + .map_err(|_| Error::CryptoError("HKDF expand failed (ck-responder)".into()))?; + + ikm.zeroize(); + + Ok((ck_initiator, ck_responder)) + } + + struct CoapSessionInitSigInput<'a> { + path: &'a str, + session_id: &'a [u8; 16], + session_seq: u64, + initiator_id: &'a str, + responder_id: &'a str, + kem_pk: &'a [u8], + x25519_pk: &'a [u8; 32], + ts: u64, + } + + fn session_init_payload_v1(input: &CoapSessionInitSigInput<'_>) -> Vec { + let mut buf = Vec::new(); + buf.extend_from_slice(b"pqc-iiot:coap-session:init:v1"); + buf.extend_from_slice(&(input.path.len() as u16).to_be_bytes()); + buf.extend_from_slice(input.path.as_bytes()); + buf.extend_from_slice(&(input.initiator_id.len() as u16).to_be_bytes()); + buf.extend_from_slice(input.initiator_id.as_bytes()); + buf.extend_from_slice(&(input.responder_id.len() as u16).to_be_bytes()); + buf.extend_from_slice(input.responder_id.as_bytes()); + buf.extend_from_slice(input.session_id); + buf.extend_from_slice(&input.session_seq.to_be_bytes()); + buf.extend_from_slice(&input.ts.to_be_bytes()); + buf.extend_from_slice(&(input.kem_pk.len() as u32).to_be_bytes()); + buf.extend_from_slice(input.kem_pk); + buf.extend_from_slice(input.x25519_pk); + buf + } + + struct CoapSessionRespSigInput<'a> { + path: &'a str, + session_id: &'a [u8; 16], + session_seq: u64, + initiator_id: &'a str, + responder_id: &'a str, + x25519_pk: &'a [u8; 32], + kem_ciphertext: &'a [u8], + ts: u64, + } + + fn session_resp_payload_v1(input: &CoapSessionRespSigInput<'_>) -> Vec { + let mut buf = Vec::new(); + buf.extend_from_slice(b"pqc-iiot:coap-session:resp:v1"); + buf.extend_from_slice(&(input.path.len() as u16).to_be_bytes()); + buf.extend_from_slice(input.path.as_bytes()); + buf.extend_from_slice(&(input.initiator_id.len() as u16).to_be_bytes()); + buf.extend_from_slice(input.initiator_id.as_bytes()); + buf.extend_from_slice(&(input.responder_id.len() as u16).to_be_bytes()); + buf.extend_from_slice(input.responder_id.as_bytes()); + buf.extend_from_slice(input.session_id); + buf.extend_from_slice(&input.session_seq.to_be_bytes()); + buf.extend_from_slice(&input.ts.to_be_bytes()); + buf.extend_from_slice(input.x25519_pk); + buf.extend_from_slice(&(input.kem_ciphertext.len() as u32).to_be_bytes()); + buf.extend_from_slice(input.kem_ciphertext); + buf + } + + #[derive(Debug)] + struct CoapSessionInit { + initiator_id: String, + responder_id: String, + session_id: [u8; 16], + session_seq: u64, + kem_pk: Vec, + x25519_pk: [u8; 32], + ts: u64, + signature: Vec, + } + + #[derive(Debug)] + struct CoapSessionResponse { + initiator_id: String, + responder_id: String, + session_id: [u8; 16], + session_seq: u64, + x25519_pk: [u8; 32], + kem_ciphertext: Vec, + ts: u64, + signature: Vec, + } + + fn take<'a>(input: &mut &'a [u8], n: usize) -> Result<&'a [u8]> { + if input.len() < n { + return Err(Error::ProtocolError("Truncated message".into())); + } + let (head, rest) = input.split_at(n); + *input = rest; + Ok(head) + } + + fn read_u8(input: &mut &[u8]) -> Result { + Ok(take(input, 1)?[0]) + } + + fn read_u16(input: &mut &[u8]) -> Result { + let b = take(input, 2)?; + Ok(u16::from_be_bytes([b[0], b[1]])) + } + + fn read_u32(input: &mut &[u8]) -> Result { + let b = take(input, 4)?; + Ok(u32::from_be_bytes([b[0], b[1], b[2], b[3]])) + } + + fn read_u64(input: &mut &[u8]) -> Result { + let b = take(input, 8)?; + Ok(u64::from_be_bytes([ + b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], + ])) + } + + fn read_vec_u32(input: &mut &[u8], max_len: usize) -> Result> { + let len = read_u32(input)? as usize; + if len > max_len { + return Err(Error::InvalidInput(format!( + "Field too large: {} > {}", + len, max_len + ))); + } + Ok(take(input, len)?.to_vec()) + } + + fn read_string_u16(input: &mut &[u8], max_len: usize) -> Result { + let len = read_u16(input)? as usize; + if len > max_len { + return Err(Error::InvalidInput(format!( + "String too large: {} > {}", + len, max_len + ))); + } + let bytes = take(input, len)?; + let s = core::str::from_utf8(bytes) + .map_err(|_| Error::InvalidInput("Invalid UTF-8 string".into()))?; + Ok(s.to_string()) + } + + fn encode_session_init_v1(msg: &CoapSessionInit) -> Result> { + let mut out = Vec::new(); + out.push(COAP_SESSION_VERSION_V1); + out.extend_from_slice(&(msg.initiator_id.len() as u16).to_be_bytes()); + out.extend_from_slice(msg.initiator_id.as_bytes()); + out.extend_from_slice(&(msg.responder_id.len() as u16).to_be_bytes()); + out.extend_from_slice(msg.responder_id.as_bytes()); + out.extend_from_slice(&msg.session_id); + out.extend_from_slice(&msg.session_seq.to_be_bytes()); + out.extend_from_slice(&msg.ts.to_be_bytes()); + out.extend_from_slice(&(msg.kem_pk.len() as u32).to_be_bytes()); + out.extend_from_slice(&msg.kem_pk); + out.extend_from_slice(&msg.x25519_pk); + out.extend_from_slice(&(msg.signature.len() as u16).to_be_bytes()); + out.extend_from_slice(&msg.signature); + Ok(out) + } + + fn decode_session_init_v1(bytes: &[u8]) -> Result { + if bytes.len() > COAP_MAX_SESSION_CONTROL_BYTES { + return Err(Error::InvalidInput("Session init too large".into())); + } + let mut input = bytes; + let version = read_u8(&mut input)?; + if version != COAP_SESSION_VERSION_V1 { + return Err(Error::ProtocolError(format!( + "Unsupported CoAP session init version: {}", + version + ))); + } + let initiator_id = read_string_u16(&mut input, 128)?; + let responder_id = read_string_u16(&mut input, 128)?; + let session_id = vec_to_16(take(&mut input, 16)?)?; + let session_seq = read_u64(&mut input)?; + let ts = read_u64(&mut input)?; + let kem_pk = read_vec_u32(&mut input, 4096)?; + let x25519_pk = vec_to_32(take(&mut input, 32)?)?; + let sig_len = read_u16(&mut input)? as usize; + let signature = take(&mut input, sig_len)?.to_vec(); + if !input.is_empty() { + return Err(Error::ProtocolError( + "Trailing bytes in session init".into(), + )); + } + Ok(CoapSessionInit { + initiator_id, + responder_id, + session_id, + session_seq, + kem_pk, + x25519_pk, + ts, + signature, + }) + } + + fn encode_session_response_v1(msg: &CoapSessionResponse) -> Result> { + let mut out = Vec::new(); + out.push(COAP_SESSION_VERSION_V1); + out.extend_from_slice(&(msg.initiator_id.len() as u16).to_be_bytes()); + out.extend_from_slice(msg.initiator_id.as_bytes()); + out.extend_from_slice(&(msg.responder_id.len() as u16).to_be_bytes()); + out.extend_from_slice(msg.responder_id.as_bytes()); + out.extend_from_slice(&msg.session_id); + out.extend_from_slice(&msg.session_seq.to_be_bytes()); + out.extend_from_slice(&msg.ts.to_be_bytes()); + out.extend_from_slice(&msg.x25519_pk); + out.extend_from_slice(&(msg.kem_ciphertext.len() as u32).to_be_bytes()); + out.extend_from_slice(&msg.kem_ciphertext); + out.extend_from_slice(&(msg.signature.len() as u16).to_be_bytes()); + out.extend_from_slice(&msg.signature); + Ok(out) + } + + fn decode_session_response_v1(bytes: &[u8]) -> Result { + if bytes.len() > COAP_MAX_SESSION_CONTROL_BYTES { + return Err(Error::InvalidInput("Session response too large".into())); + } + let mut input = bytes; + let version = read_u8(&mut input)?; + if version != COAP_SESSION_VERSION_V1 { + return Err(Error::ProtocolError(format!( + "Unsupported CoAP session response version: {}", + version + ))); + } + let initiator_id = read_string_u16(&mut input, 128)?; + let responder_id = read_string_u16(&mut input, 128)?; + let session_id = vec_to_16(take(&mut input, 16)?)?; + let session_seq = read_u64(&mut input)?; + let ts = read_u64(&mut input)?; + let x25519_pk = vec_to_32(take(&mut input, 32)?)?; + let kem_ciphertext = read_vec_u32(&mut input, 8192)?; + let sig_len = read_u16(&mut input)? as usize; + let signature = take(&mut input, sig_len)?.to_vec(); + if !input.is_empty() { + return Err(Error::ProtocolError( + "Trailing bytes in session response".into(), + )); + } + Ok(CoapSessionResponse { + initiator_id, + responder_id, + session_id, + session_seq, + x25519_pk, + kem_ciphertext, + ts, + signature, + }) + } + + fn encode_secure_payload_v2( + sender_id: &str, + session_id: &[u8; 16], + msg_num: u32, + ciphertext: &[u8], + ) -> Result> { + if ciphertext.len() > u32::MAX as usize { + return Err(Error::InvalidInput("Ciphertext too large".into())); + } + let sender_id_bytes = sender_id.as_bytes(); + if sender_id_bytes.len() > u16::MAX as usize { + return Err(Error::InvalidInput("Sender id too long".into())); + } + let ct_len = ciphertext.len() as u32; + let mut out = Vec::with_capacity( + COAP_SECURE_MSG_MAGIC_V2.len() + + 2 + + sender_id_bytes.len() + + 1 + + 16 + + 4 + + 4 + + ciphertext.len(), + ); + out.extend_from_slice(COAP_SECURE_MSG_MAGIC_V2); + out.extend_from_slice(&(sender_id_bytes.len() as u16).to_be_bytes()); + out.extend_from_slice(sender_id_bytes); + out.push(2); + out.extend_from_slice(session_id); + out.extend_from_slice(&msg_num.to_be_bytes()); + out.extend_from_slice(&ct_len.to_be_bytes()); + out.extend_from_slice(ciphertext); + Ok(out) + } + + #[derive(Debug, Clone)] + struct CoapSecurePayloadV2 { + sender_id: String, + session_id: [u8; 16], + msg_num: u32, + ciphertext: Vec, + } + + fn decode_secure_payload_v2(bytes: &[u8]) -> Result { + if bytes.len() > COAP_MAX_SECURE_PAYLOAD_BYTES { + return Err(Error::InvalidInput("Secure payload too large".into())); + } + if !bytes.starts_with(COAP_SECURE_MSG_MAGIC_V2) { + return Err(Error::ProtocolError("Missing secure payload magic".into())); + } + let mut input = &bytes[COAP_SECURE_MSG_MAGIC_V2.len()..]; + let sender_len = read_u16(&mut input)? as usize; + if sender_len == 0 || sender_len > 128 { + return Err(Error::InvalidInput(format!( + "Invalid sender id length: {}", + sender_len + ))); + } + let sender_bytes = take(&mut input, sender_len)?; + let sender_id = core::str::from_utf8(sender_bytes) + .map_err(|_| Error::InvalidInput("Invalid sender_id UTF-8".into()))? + .to_string(); + let version = read_u8(&mut input)?; + if version != 2 { + return Err(Error::ProtocolError(format!( + "Unsupported secure payload version: {}", + version + ))); + } + let session_id = vec_to_16(take(&mut input, 16)?)?; + let msg_num = read_u32(&mut input)?; + let ct_len = read_u32(&mut input)? as usize; + let ciphertext = take(&mut input, ct_len)?.to_vec(); + if !input.is_empty() { + return Err(Error::ProtocolError( + "Trailing bytes in secure payload".into(), + )); + } + Ok(CoapSecurePayloadV2 { + sender_id, + session_id, + msg_num, + ciphertext, + }) + } + + /// Session-based Secure CoAP client (confidentiality + integrity + replay protection). + /// + /// This is *not* OSCORE/DTLS, but it provides equivalent primitives at the application layer: + /// - Authenticated session handshake (Falcon signatures) + /// - Forward secrecy from ephemeral X25519 + ephemeral Kyber KEM + /// - AEAD payload encryption (AES-256-GCM) with per-message key evolution + /// - Anti-replay / bounded out-of-order via a skipped-key window + pub struct SecureCoapSessionClient { + kyber: Kyber, + falcon: Falcon, + sig_sk: Zeroizing>, + sig_pk: Vec, + + client_id: String, + peer_id: String, + peer_sig_pk: Vec, + + timeout: Duration, + socket: Option, + + session_seq: u64, + session: Option, + } + + impl SecureCoapSessionClient { + /// Create a new session client. + /// + /// `peer_sig_pk` is the pinned Falcon public key for the remote peer identity. + pub fn new(client_id: &str, peer_id: &str, peer_sig_pk: Vec) -> Result { + let kyber = Kyber::new(); + let falcon = Falcon::new(); + let (sig_pk, sig_sk) = falcon.generate_keypair()?; + Ok(Self { + kyber, + falcon, + sig_sk: Zeroizing::new(sig_sk), + sig_pk, + client_id: client_id.to_string(), + peer_id: peer_id.to_string(), + peer_sig_pk, + timeout: Duration::from_secs(2), + socket: None, + session_seq: 0, + session: None, + }) + } + + /// Return this client's Falcon identity public key. + pub fn identity_sig_pk(&self) -> &[u8] { + &self.sig_pk + } + + /// Set the UDP read timeout used by CoAP exchanges (handshake + secure requests). + pub fn with_timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } + + fn ensure_socket(&mut self) -> Result { + if self.socket.is_none() { + let socket = + UdpSocket::bind("0.0.0.0:0").map_err(|e| Error::ClientError(e.to_string()))?; + socket + .set_read_timeout(Some(self.timeout)) + .map_err(|e| Error::ClientError(e.to_string()))?; + self.socket = Some(socket); + } + self.socket + .as_ref() + .ok_or_else(|| Error::ClientError("Socket missing".into()))? + .try_clone() + .map_err(|e| Error::ClientError(e.to_string())) + } + + fn exchange_blockwise( + &mut self, + server: SocketAddr, + method: RequestType, + path: &str, + token: Vec, + payload: &[u8], + ) -> Result { + const BLOCK_SIZE: usize = 512; + + let socket = self.ensure_socket()?; + + let send_block = |block1: Option, + block2: Option, + chunk: &[u8]| + -> Result { + let mut request: CoapRequest<()> = CoapRequest::new(); + request.set_method(method); + request.set_path(path); + request.message.header.message_id = (OsRng.next_u32() & 0xFFFF) as u16; + request.message.set_token(token.clone()); + request.message.payload = chunk.to_vec(); + + if let Some(b1) = block1 { + request.message.add_option_as(CoapOption::Block1, b1); + } + if let Some(b2) = block2 { + request.message.add_option_as(CoapOption::Block2, b2); + } + + let bytes = request.message.to_bytes().map_err(|e| { + Error::ClientError(format!("Packet serialization failed: {}", e)) + })?; + socket + .send_to(&bytes, server) + .map_err(|e| Error::ClientError(e.to_string()))?; + + let mut buf = vec![0u8; 65535]; + let (amt, _src) = socket + .recv_from(&mut buf) + .map_err(|e| Error::ClientError(e.to_string()))?; + buf.truncate(amt); + Packet::from_bytes(&buf).map_err(|_| Error::ClientError("Invalid packet".into())) + }; + + // Block1 upload if necessary. + let mut response_packet = if payload.len() > BLOCK_SIZE { + let mut num = 0usize; + let mut offset = 0usize; + loop { + let end = core::cmp::min(offset + BLOCK_SIZE, payload.len()); + let more = end < payload.len(); + let block1 = BlockValue::new(num, more, BLOCK_SIZE) + .map_err(|e| Error::ClientError(e.to_string()))?; + let resp = send_block(Some(block1), None, &payload[offset..end])?; + if more { + // Expect 2.31 Continue for intermediate blocks. + let code: u8 = resp.header.code.into(); + let expected: u8 = + coap_lite::MessageClass::Response(ResponseType::Continue).into(); + if code != expected { + // Some stacks may respond differently; fail closed. + return Err(Error::ProtocolError(format!( + "Unexpected block1 response code: {}", + code + ))); + } + } else { + break resp; + } + offset = end; + num = num.saturating_add(1); + } + } else { + // Single-shot request. + send_block(None, None, payload)? + }; + + // Reassemble Block2 response if present. + let mut assembled = Vec::new(); + let mut block2 = response_packet + .get_first_option_as::(CoapOption::Block2) + .and_then(|x| x.ok()); + if let Some(mut b2) = block2.take() { + loop { + let size = b2.size(); + let offset = (b2.num as usize).saturating_mul(size); + if assembled.len() < offset { + assembled.resize(offset, 0); + } + if assembled.len() == offset { + assembled.extend_from_slice(&response_packet.payload); + } else if offset < assembled.len() { + let end = offset.saturating_add(response_packet.payload.len()); + if assembled.len() < end { + assembled.resize(end, 0); + } + assembled[offset..end].copy_from_slice(&response_packet.payload); + } + + if !b2.more { + response_packet.payload = assembled; + break; + } + + let next = (b2.num as u32).saturating_add(1); + let next_b2 = BlockValue { + num: next as u16, + more: false, + size_exponent: b2.size_exponent, + }; + response_packet = send_block(None, Some(next_b2), &[])?; + b2 = response_packet + .get_first_option_as::(CoapOption::Block2) + .and_then(|x| x.ok()) + .ok_or_else(|| { + Error::ProtocolError("Missing Block2 in follow-up response".into()) + })?; + } + } + + Ok(CoapResponse { + message: response_packet, + }) + } + + /// Establish a secure session with `server` if not already connected. + pub fn connect(&mut self, server: SocketAddr) -> Result<()> { + if self.session.is_some() { + return Ok(()); + } + self.session_seq = self.session_seq.saturating_add(1).max(1); + let session_seq = self.session_seq; + + let mut session_id = [0u8; 16]; + OsRng.fill_bytes(&mut session_id); + + let (kem_pk, kem_sk) = self.kyber.generate_keypair()?; + let x25519_sk = X25519StaticSecret::random_from_rng(OsRng); + let x25519_pk = X25519PublicKey::from(&x25519_sk).to_bytes(); + let ts = 0u64; + + let payload = session_init_payload_v1(&CoapSessionInitSigInput { + path: COAP_SESSION_INIT_PATH, + session_id: &session_id, + session_seq, + initiator_id: &self.client_id, + responder_id: &self.peer_id, + kem_pk: &kem_pk, + x25519_pk: &x25519_pk, + ts, + }); + let signature = self.falcon.sign(&self.sig_sk, &payload)?; + + let init = CoapSessionInit { + initiator_id: self.client_id.clone(), + responder_id: self.peer_id.clone(), + session_id, + session_seq, + kem_pk, + x25519_pk, + ts, + signature, + }; + + let bytes = encode_session_init_v1(&init)?; + let mut token = [0u8; 4]; + OsRng.fill_bytes(&mut token); + let resp = self.exchange_blockwise( + server, + RequestType::Post, + COAP_SESSION_INIT_PATH, + token.to_vec(), + &bytes, + )?; + let msg = decode_session_response_v1(&resp.message.payload)?; + + if msg.initiator_id != self.client_id || msg.responder_id != self.peer_id { + return Err(Error::ProtocolError("Session response id mismatch".into())); + } + if msg.session_id != session_id { + return Err(Error::ProtocolError( + "Session response session_id mismatch".into(), + )); + } + if msg.session_seq != session_seq { + return Err(Error::ProtocolError( + "Session response session_seq mismatch".into(), + )); + } + + let resp_payload = session_resp_payload_v1(&CoapSessionRespSigInput { + path: COAP_SESSION_INIT_PATH, + session_id: &session_id, + session_seq, + initiator_id: &self.client_id, + responder_id: &self.peer_id, + x25519_pk: &msg.x25519_pk, + kem_ciphertext: &msg.kem_ciphertext, + ts: msg.ts, + }); + if !self + .falcon + .verify(&self.peer_sig_pk, &resp_payload, &msg.signature)? + { + return Err(Error::SignatureVerification( + "Session response signature invalid".into(), + )); + } + + let kyber = kyber_for_sk_len(kem_sk.len())?; + let kem_ss = kyber.decapsulate(&kem_sk, &msg.kem_ciphertext)?; + let dh_ss = x25519_sk + .diffie_hellman(&X25519PublicKey::from(msg.x25519_pk)) + .to_bytes() + .to_vec(); + + let (ck_initiator, ck_responder) = + derive_session_chain_keys_v1(&session_id, &kem_ss, &dh_ss)?; + + self.session = Some(CoapSession::new(session_id, ck_initiator, ck_responder)); + + // Best-effort: wipe ephemeral secrets. + drop(PendingCoapSessionInit { + kem_sk: Zeroizing::new(kem_sk), + x25519_sk, + }); + + Ok(()) + } + + fn encrypt_payload( + session: &mut CoapSession, + binding: &CoapMsgBinding<'_>, + plaintext: &[u8], + ) -> Result> { + let (msg_num, ciphertext) = session.encrypt_v2(binding, plaintext)?; + encode_secure_payload_v2(binding.sender_id, &session.session_id, msg_num, &ciphertext) + } + + fn decrypt_payload( + session: &mut CoapSession, + expected_sender_id: &str, + binding: &CoapMsgBinding<'_>, + bytes: &[u8], + ) -> Result> { + let decoded = decode_secure_payload_v2(bytes)?; + if decoded.sender_id != expected_sender_id { + return Err(Error::ProtocolError("Unexpected sender_id".into())); + } + if decoded.session_id != session.session_id { + return Err(Error::ProtocolError("Session id mismatch".into())); + } + session.decrypt_v2(binding, decoded.msg_num, &decoded.ciphertext) + } + + fn send_secure_request( + &mut self, + method: RequestType, + server: SocketAddr, + path: &str, + payload: &[u8], + ) -> Result { + self.connect(server)?; + let socket = self.ensure_socket()?; + let session = self.session.as_mut().ok_or_else(|| { + Error::ClientError("Missing session after successful connect".into()) + })?; + + let mut request: CoapRequest<()> = CoapRequest::new(); + request.set_method(method); + request.set_path(path); + request.message.header.message_id = (OsRng.next_u32() & 0xFFFF) as u16; + let mut token = [0u8; 4]; + OsRng.fill_bytes(&mut token); + request.message.set_token(token.to_vec()); + + let code: u8 = request.message.header.code.into(); + let token_bytes = request.message.get_token().to_vec(); + + let binding = CoapMsgBinding { + sender_id: &self.client_id, + receiver_id: &self.peer_id, + code, + path, + token: &token_bytes, + }; + request.message.payload = Self::encrypt_payload(session, &binding, payload)?; + + let packet_bytes = request + .message + .to_bytes() + .map_err(|_| Error::ClientError("Packet serialization failed".into()))?; + + socket + .send_to(&packet_bytes, server) + .map_err(|e| Error::ClientError(e.to_string()))?; + + let mut buf = vec![0u8; 65535]; + let (amt, _src) = socket + .recv_from(&mut buf) + .map_err(|e| Error::ClientError(e.to_string()))?; + buf.truncate(amt); + + let packet = Packet::from_bytes(&buf) + .map_err(|_| Error::ClientError("Invalid packet".into()))?; + let mut response = CoapResponse { message: packet }; + + let resp_code: u8 = response.message.header.code.into(); + let resp_token = response.message.get_token().to_vec(); + + let resp_binding = CoapMsgBinding { + sender_id: &self.peer_id, + receiver_id: &self.client_id, + code: resp_code, + path, + token: &resp_token, + }; + let plaintext = Self::decrypt_payload( + session, + &self.peer_id, + &resp_binding, + &response.message.payload, + )?; + response.message.payload = plaintext; + Ok(response) + } + + /// Send an encrypted/authenticated CoAP `GET` request and return the decrypted response. + pub fn get(&mut self, server: SocketAddr, resource: &str) -> Result { + self.send_secure_request(RequestType::Get, server, resource, &[]) + } + + /// Send an encrypted/authenticated CoAP `POST` request and return the decrypted response. + pub fn post( + &mut self, + server: SocketAddr, + resource: &str, + payload: &[u8], + ) -> Result { + self.send_secure_request(RequestType::Post, server, resource, payload) + } + } + + /// Minimal in-process Secure CoAP session server for tests and demos. + pub struct SecureCoapSessionServer { + server_id: String, + block_handler: BlockHandler, + falcon: Falcon, + sig_sk: Zeroizing>, + sig_pk: Vec, + allowed_clients: HashMap>, + last_session_seq: HashMap, + session_resp_cache: HashMap, + sessions: HashMap, + } + + impl SecureCoapSessionServer { + /// Create a new in-process session server. + pub fn new(server_id: &str) -> Result { + let falcon = Falcon::new(); + let (sig_pk, sig_sk) = falcon.generate_keypair()?; + Ok(Self { + server_id: server_id.to_string(), + block_handler: BlockHandler::new(BlockHandlerConfig::default()), + falcon, + sig_sk: Zeroizing::new(sig_sk), + sig_pk, + allowed_clients: HashMap::new(), + last_session_seq: HashMap::new(), + session_resp_cache: HashMap::new(), + sessions: HashMap::new(), + }) + } + + /// Return this server's Falcon identity public key. + pub fn identity_sig_pk(&self) -> &[u8] { + &self.sig_pk + } + + /// Allow a client identity (pinned Falcon public key) to establish sessions. + pub fn allow_client(&mut self, client_id: &str, client_sig_pk: Vec) { + self.allowed_clients + .insert(client_id.to_string(), client_sig_pk); + } + + fn handle_session_init(&mut self, init: CoapSessionInit) -> Result> { + if init.responder_id != self.server_id { + return Err(Error::ProtocolError("Wrong responder_id".into())); + } + if init.session_seq == 0 { + return Err(Error::InvalidInput("session_seq=0".into())); + } + + let client_sig_pk = self + .allowed_clients + .get(&init.initiator_id) + .ok_or_else(|| Error::SignatureVerification("Unknown initiator_id".into()))?; + + let payload = session_init_payload_v1(&CoapSessionInitSigInput { + path: COAP_SESSION_INIT_PATH, + session_id: &init.session_id, + session_seq: init.session_seq, + initiator_id: &init.initiator_id, + responder_id: &init.responder_id, + kem_pk: &init.kem_pk, + x25519_pk: &init.x25519_pk, + ts: init.ts, + }); + if !self + .falcon + .verify(client_sig_pk, &payload, &init.signature)? + { + return Err(Error::SignatureVerification( + "Session init signature invalid".into(), + )); + } + + let last_seq = *self.last_session_seq.get(&init.initiator_id).unwrap_or(&0); + if init.session_seq < last_seq { + return Err(Error::ProtocolError("session_seq rollback".into())); + } + if init.session_seq == last_seq { + if let Some(cached) = self.session_resp_cache.get(&init.initiator_id) { + if cached.session_seq == init.session_seq + && cached.session_id == init.session_id + { + return Ok(cached.bytes.clone()); + } + } + return Err(Error::ProtocolError( + "Duplicate session_seq with different session_id".into(), + )); + } + + let responder_x_sk = X25519StaticSecret::random_from_rng(OsRng); + let responder_x_pk = X25519PublicKey::from(&responder_x_sk).to_bytes(); + let dh_ss = responder_x_sk + .diffie_hellman(&X25519PublicKey::from(init.x25519_pk)) + .to_bytes() + .to_vec(); + + let kyber = kyber_for_pk_len(init.kem_pk.len())?; + let (kem_ciphertext, kem_ss) = kyber.encapsulate(&init.kem_pk)?; + + let (ck_initiator, ck_responder) = + derive_session_chain_keys_v1(&init.session_id, &kem_ss, &dh_ss)?; + + // Replace any existing session for this peer deterministically. + self.sessions.insert( + init.initiator_id.clone(), + CoapSession::new(init.session_id, ck_responder, ck_initiator), + ); + + self.last_session_seq + .insert(init.initiator_id.clone(), init.session_seq); + + let ts = 0u64; + let resp_payload = session_resp_payload_v1(&CoapSessionRespSigInput { + path: COAP_SESSION_INIT_PATH, + session_id: &init.session_id, + session_seq: init.session_seq, + initiator_id: &init.initiator_id, + responder_id: &init.responder_id, + x25519_pk: &responder_x_pk, + kem_ciphertext: &kem_ciphertext, + ts, + }); + let signature = self.falcon.sign(&self.sig_sk, &resp_payload)?; + + let resp = CoapSessionResponse { + initiator_id: init.initiator_id.clone(), + responder_id: init.responder_id, + session_id: init.session_id, + session_seq: init.session_seq, + x25519_pk: responder_x_pk, + kem_ciphertext, + ts, + signature, + }; + let bytes = encode_session_response_v1(&resp)?; + self.session_resp_cache.insert( + init.initiator_id, + CachedCoapSessionResponse { + session_seq: resp.session_seq, + session_id: resp.session_id, + bytes: bytes.clone(), + }, + ); + Ok(bytes) + } + + fn handle_secure_request( + &mut self, + sender_id: &str, + code: u8, + path: &str, + token: &[u8], + decoded: CoapSecurePayloadV2, + ) -> Result> { + let session = self + .sessions + .get_mut(sender_id) + .ok_or_else(|| Error::ProtocolError("No active session for sender".into()))?; + + if decoded.sender_id != sender_id { + return Err(Error::ProtocolError("sender_id mismatch".into())); + } + if decoded.session_id != session.session_id { + return Err(Error::ProtocolError("session_id mismatch".into())); + } + + let binding = CoapMsgBinding { + sender_id, + receiver_id: &self.server_id, + code, + path, + token, + }; + let plaintext = session.decrypt_v2(&binding, decoded.msg_num, &decoded.ciphertext)?; + Ok(plaintext) + } + + fn encrypt_secure_response( + &mut self, + receiver_id: &str, + code: u8, + path: &str, + token: &[u8], + plaintext: &[u8], + ) -> Result> { + let session = self + .sessions + .get_mut(receiver_id) + .ok_or_else(|| Error::ProtocolError("No active session for receiver".into()))?; + let binding = CoapMsgBinding { + sender_id: &self.server_id, + receiver_id, + code, + path, + token, + }; + let (msg_num, ciphertext) = session.encrypt_v2(&binding, plaintext)?; + encode_secure_payload_v2(&self.server_id, &session.session_id, msg_num, &ciphertext) + } + + /// Handle an incoming CoAP request packet and (optionally) produce a response packet. + pub fn handle_packet(&mut self, packet: Packet, src: SocketAddr) -> Result> { + let mut request: CoapRequest = CoapRequest::from_packet(packet, src); + + if self + .block_handler + .intercept_request(&mut request) + .map_err(|e| Error::ClientError(e.to_string()))? + { + return Ok(request.response.map(|r| r.message)); + } + + let path = request.get_path(); + let code: u8 = request.message.header.code.into(); + let token = request.message.get_token().to_vec(); + + let response = request + .response + .as_mut() + .ok_or_else(|| Error::ClientError("Missing response template".into()))?; + + if path == COAP_SESSION_INIT_PATH { + let init = decode_session_init_v1(&request.message.payload)?; + let bytes = self.handle_session_init(init)?; + response.message.payload = bytes; + response.set_status(ResponseType::Content); + + let _ = self + .block_handler + .intercept_response(&mut request) + .map_err(|e| Error::ClientError(e.to_string()))?; + return Ok(request.response.map(|r| r.message)); + } + + let decoded = match decode_secure_payload_v2(&request.message.payload) { + Ok(v) => v, + Err(_) => { + response.set_status(ResponseType::BadRequest); + response.message.payload = b"secure payload required".to_vec(); + return Ok(request.response.map(|r| r.message)); + } + }; + let sender_id = decoded.sender_id.clone(); + + let plaintext = self.handle_secure_request(&sender_id, code, &path, &token, decoded)?; + + response.set_status(ResponseType::Content); + let resp_code: u8 = response.message.header.code.into(); + let encrypted = + self.encrypt_secure_response(&sender_id, resp_code, &path, &token, &plaintext)?; + response.message.payload = encrypted; + + let _ = self + .block_handler + .intercept_response(&mut request) + .map_err(|e| Error::ClientError(e.to_string()))?; + + Ok(request.response.map(|r| r.message)) + } + } + #[cfg(test)] mod tests { use super::*; @@ -252,18 +1578,37 @@ mod std_client { let server_socket = UdpSocket::bind("127.0.0.1:0").unwrap(); let server_addr = server_socket.local_addr().unwrap(); + let falcon = Falcon::new(); + let (server_pk, server_sk) = falcon.generate_keypair().unwrap(); + thread::spawn(move || { + let falcon = Falcon::new(); let mut buf = [0u8; 2048]; if let Ok((amt, src)) = server_socket.recv_from(&mut buf) { - server_socket.send_to(&buf[..amt], src).unwrap(); + let packet = Packet::from_bytes(&buf[..amt]).unwrap(); + let request: CoapRequest = CoapRequest::from_packet(packet, src); + + let mut response = request.response.unwrap(); + let message = b"OK"; + let signature = falcon.sign(&server_sk, message).unwrap(); + let sig_len = signature.len() as u16; + let mut signed = Vec::new(); + signed.extend_from_slice(message); + signed.extend_from_slice(&signature); + signed.extend_from_slice(&sig_len.to_be_bytes()); + response.message.payload = signed; + + let bytes = response.message.to_bytes().unwrap(); + server_socket.send_to(&bytes, src).unwrap(); } }); - let mut client = SecureCoapClient::new().unwrap(); + let mut client = SecureCoapClient::new().unwrap().with_peer_sig_pk(server_pk); let message = b"Hello, CoAP!"; let response = client.post(server_addr, "test", message).unwrap(); - client.verify_response(&response).unwrap(); + let payload = client.verify_response(&response).unwrap(); + assert_eq!(payload, b"OK"); } #[test] @@ -271,27 +1616,99 @@ mod std_client { let server_socket = UdpSocket::bind("127.0.0.1:0").unwrap(); let server_addr = server_socket.local_addr().unwrap(); + let falcon = Falcon::new(); + let (server_pk, server_sk) = falcon.generate_keypair().unwrap(); + thread::spawn(move || { + let falcon = Falcon::new(); let mut buf = [0u8; 2048]; if let Ok((amt, src)) = server_socket.recv_from(&mut buf) { - if amt > 0 { - buf[amt - 1] ^= 0xFF; - } - server_socket.send_to(&buf[..amt], src).unwrap(); + let packet = Packet::from_bytes(&buf[..amt]).unwrap(); + let request: CoapRequest = CoapRequest::from_packet(packet, src); + + let mut response = request.response.unwrap(); + let message = b"OK"; + let signature = falcon.sign(&server_sk, message).unwrap(); + let sig_len = signature.len() as u16; + let mut signed = Vec::new(); + signed.extend_from_slice(message); + signed.extend_from_slice(&signature); + signed.extend_from_slice(&sig_len.to_be_bytes()); + + // Tamper with payload after signing to ensure verification fails. + signed[0] ^= 0xFF; + response.message.payload = signed; + + let bytes = response.message.to_bytes().unwrap(); + server_socket.send_to(&bytes, src).unwrap(); } }); - let mut client = SecureCoapClient::new().unwrap(); + let mut client = SecureCoapClient::new().unwrap().with_peer_sig_pk(server_pk); let message = b"Hello, CoAP!"; let response = client.post(server_addr, "test", message).unwrap(); assert!(client.verify_response(&response).is_err()); } + + #[test] + fn coap_session_handshake_and_secure_echo() { + let server_socket = UdpSocket::bind("127.0.0.1:0").unwrap(); + server_socket + .set_read_timeout(Some(Duration::from_millis(200))) + .unwrap(); + let server_addr = server_socket.local_addr().unwrap(); + + let mut server = SecureCoapSessionServer::new("server").unwrap(); + let server_pk = server.identity_sig_pk().to_vec(); + + let mut client = SecureCoapSessionClient::new("client", "server", server_pk).unwrap(); + client = client.with_timeout(Duration::from_secs(5)); + server.allow_client("client", client.identity_sig_pk().to_vec()); + + thread::spawn(move || { + let mut server = server; + let mut buf = [0u8; 65535]; + let deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + if std::time::Instant::now() > deadline { + break; + } + match server_socket.recv_from(&mut buf) { + Ok((amt, src)) => { + let packet = Packet::from_bytes(&buf[..amt]).unwrap(); + if let Ok(Some(resp)) = server.handle_packet(packet, src) { + let should_stop = resp.payload == b"hello-secure"; + let bytes = resp.to_bytes().unwrap(); + let _ = server_socket.send_to(&bytes, src); + if should_stop { + break; + } + } + } + Err(e) + if e.kind() == std::io::ErrorKind::WouldBlock + || e.kind() == std::io::ErrorKind::TimedOut => + { + continue; + } + Err(_) => break, + } + } + }); + + let response = client + .post(server_addr, "test/resource", b"hello-secure") + .unwrap(); + assert_eq!(response.message.payload, b"hello-secure"); + } } } #[cfg(feature = "coap-std")] -pub use std_client::{AclRules, DtlsConfig, SecureCoapClient}; +pub use std_client::{ + AclRules, DtlsConfig, SecureCoapClient, SecureCoapSessionClient, SecureCoapSessionServer, +}; #[cfg(not(feature = "coap-std"))] mod nostd_core { diff --git a/src/mqtt_secure.rs b/src/mqtt_secure.rs index 5707060..7afed4b 100644 --- a/src/mqtt_secure.rs +++ b/src/mqtt_secure.rs @@ -6,8 +6,13 @@ use crate::security::audit::{AuditLog, AuditLogger, ChainedAuditLogger, Security use crate::security::hybrid; use crate::security::keystore::{KeyStore, PeerKeys}; use crate::security::metrics::SecurityMetrics; +use crate::security::monotonic::{seal_u64, unseal_u64}; +use crate::security::policy::FleetPolicyUpdate; use crate::security::provider::{SecurityProvider, SoftwareSecurityProvider}; -use crate::{Error, Falcon, Kyber, Result}; // Import Kyber and Falcon from root +use crate::security::revocation::RevocationUpdate; +use crate::security::time::SecureTimeFloor; +use crate::{Error, Falcon, Kyber, KyberSecurityLevel, Result}; // Import Kyber and Falcon from root +use hkdf::Hkdf; use log::{debug, error, info, trace, warn}; use sha2::{Digest, Sha256}; use std::sync::Arc; @@ -19,7 +24,7 @@ use std::string::{String, ToString}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::mpsc::{sync_channel, Receiver, TrySendError}; use std::thread; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use std::vec::Vec; use aes_gcm::{ @@ -28,7 +33,9 @@ use aes_gcm::{ Key, // Or wrappers Nonce, }; +use rand::rngs::OsRng; use rand::RngCore; +use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret as X25519StaticSecret}; use zeroize::{Zeroize, Zeroizing}; /// Encrypted identity file magic prefix. @@ -40,11 +47,165 @@ use zeroize::{Zeroize, Zeroizing}; /// discriminator for format upgrades and strict parsing. const IDENTITY_ENC_MAGIC: &[u8] = b"PQCENC1"; +/// Maximum peer/client identifier length accepted on-wire (MQTT topic suffix + message prefix). +/// +/// This is a hard DoS containment limit: peer IDs are used as hashmap keys and show up in +/// logs/metrics. Allowing unbounded IDs turns the broker into a cardinality amplifier. +const MAX_WIRE_ID_LEN: usize = 128; + +/// Default maximum accepted bytes for key announcements (`pqc/keys/` payload). +const DEFAULT_MAX_KEY_ANNOUNCEMENT_BYTES: usize = 64 * 1024; + +/// Default maximum accepted bytes for attestation messages (challenge/quote JSON payloads). +const DEFAULT_MAX_ATTESTATION_BYTES: usize = 32 * 1024; + +/// Default maximum accepted bytes for encrypted messages (wire packet). +const DEFAULT_MAX_ENCRYPTED_MESSAGE_BYTES: usize = 256 * 1024; + +/// Default maximum accepted bytes for revocation updates. +const DEFAULT_MAX_REVOCATION_BYTES: usize = 128 * 1024; + +/// Default maximum accepted bytes for fleet policy updates. +const DEFAULT_MAX_POLICY_BYTES: usize = 64 * 1024; + +/// Default maximum accepted bytes for MQTT session-init/response control messages. +const DEFAULT_MAX_SESSION_BYTES: usize = 64 * 1024; + +/// Default MQTT topic prefix for session initiation messages addressed to a peer. +const DEFAULT_SESSION_INIT_PREFIX: &str = "pqc/session/init/"; + +/// Default MQTT topic prefix for session responses addressed to a peer. +const DEFAULT_SESSION_RESP_PREFIX: &str = "pqc/session/resp/"; + +/// Default MQTT topic for fleet policy updates. +const DEFAULT_POLICY_TOPIC: &str = "pqc/policy/v1"; + +/// Default MQTT topic for requesting fleet policy sync (client -> CA service). +/// +/// The CA service is expected to answer by publishing a signed `FleetPolicyUpdate` on `DEFAULT_POLICY_TOPIC`. +const DEFAULT_POLICY_SYNC_TOPIC: &str = "pqc/policy/sync/v1"; + +/// Default MQTT topic for requesting revocation sync (client -> CA service). +/// +/// The CA service is expected to answer by publishing a signed `RevocationUpdate` on `revocation_topic`. +const DEFAULT_REVOCATION_SYNC_TOPIC: &str = "pqc/revocations/sync/v1"; + +/// Rate limit for outbound sync requests to avoid turning stale-state into a spam amplifier. +const DEFAULT_SYNC_REQUEST_COOLDOWN: Duration = Duration::from_secs(30); + +/// Default token bucket limits for expensive cryptographic verification work. +/// +/// These values are intentionally conservative to protect CPU under sustained adversarial load. +/// For safety/security-critical deployments, tune them based on device class and expected fleet traffic. +const DEFAULT_SIGVERIFY_BUDGET_CAPACITY: u32 = 40; +const DEFAULT_SIGVERIFY_BUDGET_REFILL_PER_SEC: u32 = 20; + +/// Default token bucket limits for expensive decryption/KEM work. +const DEFAULT_DECRYPT_BUDGET_CAPACITY: u32 = 20; +const DEFAULT_DECRYPT_BUDGET_REFILL_PER_SEC: u32 = 10; + +/// Global budget caps protect against sender-id cardinality attacks (many spoofed IDs). +const DEFAULT_GLOBAL_SIGVERIFY_BUDGET_CAPACITY: u32 = 200; +const DEFAULT_GLOBAL_SIGVERIFY_BUDGET_REFILL_PER_SEC: u32 = 100; +const DEFAULT_GLOBAL_DECRYPT_BUDGET_CAPACITY: u32 = 80; +const DEFAULT_GLOBAL_DECRYPT_BUDGET_REFILL_PER_SEC: u32 = 40; + +const DEFAULT_BUDGET_MAX_PEERS: usize = 10_000; + +/// Fixed-rate token bucket. +#[derive(Debug, Clone)] +struct TokenBucket { + tokens: u32, + last_refill: Instant, + capacity: u32, + refill_rate_per_sec: u32, +} + +impl TokenBucket { + fn new(capacity: u32, refill_rate_per_sec: u32) -> Self { + Self { + tokens: capacity, + last_refill: Instant::now(), + capacity, + refill_rate_per_sec, + } + } + + fn allow(&mut self) -> bool { + let now = Instant::now(); + let elapsed = now.duration_since(self.last_refill).as_secs() as u32; + if elapsed > 0 { + self.tokens = std::cmp::min( + self.capacity, + self.tokens + elapsed * self.refill_rate_per_sec, + ); + self.last_refill = now; + } + + if self.tokens > 0 { + self.tokens -= 1; + true + } else { + false + } + } +} + +/// Per-peer token buckets with a hard cap on tracked peers. +#[derive(Debug, Clone)] +struct TokenBucketMap { + peers: std::collections::HashMap, + capacity: u32, + refill_rate_per_sec: u32, + max_peers: usize, +} + +impl TokenBucketMap { + fn new(capacity: u32, refill_rate_per_sec: u32, max_peers: usize) -> Self { + Self { + peers: std::collections::HashMap::new(), + capacity, + refill_rate_per_sec, + max_peers, + } + } + + fn allow(&mut self, peer_id: &str) -> bool { + if self.peers.len() > self.max_peers { + // Nuclear option under cardinality attack: drop state to bound memory. + self.peers.clear(); + } + + let now = Instant::now(); + let (tokens, last_refill) = self + .peers + .entry(peer_id.to_string()) + .or_insert((self.capacity, now)); + + let elapsed = now.duration_since(*last_refill).as_secs() as u32; + if elapsed > 0 { + *tokens = std::cmp::min(self.capacity, *tokens + elapsed * self.refill_rate_per_sec); + *last_refill = now; + } + + if *tokens > 0 { + *tokens -= 1; + true + } else { + false + } + } +} + fn is_filesystem_safe_id(id: &str) -> bool { id.bytes() .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.')) } +fn is_valid_wire_peer_id(id: &str) -> bool { + !id.is_empty() && id.len() <= MAX_WIRE_ID_LEN && is_filesystem_safe_id(id) +} + fn storage_id_for(client_id: &str) -> String { // Preserve readable IDs when they are already filesystem-safe and bounded. // Otherwise, hash to avoid traversal/injection via separators/.. components. @@ -77,6 +238,16 @@ pub struct SecureMqttClient { storage_id: String, sequence_number: u64, strict_mode: bool, + /// If true, disallow v1 per-message hybrid encryption and require session/ratchet (v2). + require_sessions: bool, + /// If true, require rollback-resistant sealing/storage in the active fleet policy. + require_rollback_resistant_storage: bool, + /// Optional minimum revocation sequence required by fleet policy. + min_revocation_seq: Option, + /// Optional session rekey threshold (messages sent) from fleet policy. + session_rekey_after_msgs: Option, + /// Optional session rekey threshold (seconds since establishment) from fleet policy. + session_rekey_after_secs: Option, /// Pinned mesh CA public key used to verify OperationalCertificates. trust_anchor_ca_sig_pk: Option>, /// This device's OperationalCertificate (factory -> operational). @@ -105,6 +276,39 @@ pub struct SecureMqttClient { network_recv: Option>>, // Receive events from thread heartbeat: Arc, key_prefix: String, + + // Hard limits (DoS containment) + max_key_announcement_bytes: usize, + max_attestation_bytes: usize, + max_encrypted_message_bytes: usize, + max_revocation_bytes: usize, + revocation_topic: String, + max_policy_bytes: usize, + policy_topic: String, + policy_sync_topic: String, + fleet_policy: Option, + + // Asymmetric-cost DoS budgets (token buckets). + sig_verify_budget: TokenBucketMap, + decrypt_budget: TokenBucketMap, + global_sig_verify_budget: TokenBucket, + global_decrypt_budget: TokenBucket, + + // Secure time (best-effort monotonic floor) + secure_time: SecureTimeFloor, + + // Session + ratchet (forward secrecy / PCS building block) + max_session_bytes: usize, + session_init_prefix: String, + session_resp_prefix: String, + pending_sessions: std::collections::HashMap<[u8; 16], PendingSessionInit>, + sessions: std::collections::HashMap, + session_resp_cache: std::collections::HashMap, + + // Partition handling / catch-up + revocation_sync_topic: String, + last_policy_sync_request: Option, + last_revocation_sync_request: Option, } use crate::persistence::AtomicFileStore; @@ -136,6 +340,59 @@ struct OwnKeys { sequence_number: u64, } +#[derive(Serialize, Deserialize)] +struct SealedIdentityMeta { + version: u8, + /// Optional pinned CA public key (Falcon) used for provisioning-backed trust. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde(with = "crate::security::keystore::base64_serde_opt")] + trust_anchor_ca_sig_pk: Option>, + /// This device's OperationalCertificate (factory -> operational). + #[serde(default, skip_serializing_if = "Option::is_none")] + operational_cert: Option, + /// Outbound message sequence number (must be >= 1). + sequence_number: u64, +} + +impl SealedIdentityMeta { + const VERSION_V1: u8 = 1; +} + +fn identity_meta_label_for(storage_id: &str) -> String { + format!("pqc-iiot:identity-meta:v1:{}", storage_id) +} + +fn load_sealed_identity_meta( + provider: &Arc, + storage_id: &str, +) -> Result<(SealedIdentityMeta, bool)> { + let label = identity_meta_label_for(storage_id); + match provider.unseal_data(&label) { + Ok(blob) => { + let meta: SealedIdentityMeta = serde_json::from_slice(&blob).map_err(|e| { + Error::ClientError(format!("Invalid sealed identity meta ({}): {}", label, e)) + })?; + if meta.version != SealedIdentityMeta::VERSION_V1 { + return Err(Error::ProtocolError(format!( + "Unsupported identity meta version: {}", + meta.version + ))); + } + Ok((meta, false)) + } + Err(Error::IoError(e)) if e.kind() == std::io::ErrorKind::NotFound => Ok(( + SealedIdentityMeta { + version: SealedIdentityMeta::VERSION_V1, + trust_anchor_ca_sig_pk: None, + operational_cert: None, + sequence_number: 1, + }, + true, + )), + Err(e) => Err(e), + } +} + #[derive(Serialize, Deserialize)] struct AttestationChallenge { verifier_id: String, @@ -150,6 +407,465 @@ struct AttestationQuoteMessage { quote: crate::attestation::quote::AttestationQuote, } +#[derive(Serialize, Deserialize)] +struct SessionInitMessage { + version: u8, + initiator_id: String, + responder_id: String, + /// 16-byte session identifier (random, unique per handshake). + #[serde(with = "crate::security::keystore::base64_serde")] + session_id: Vec, + /// Monotonic per-peer sequence number (anti-replay / anti-downgrade for session init). + session_seq: u64, + /// Initiator ephemeral Kyber public key. + #[serde(with = "crate::security::keystore::base64_serde")] + kem_pk: Vec, + /// Initiator ephemeral X25519 public key (32 bytes). + #[serde(with = "crate::security::keystore::base64_serde")] + x25519_pk: Vec, + /// Informational timestamp (unix seconds) from the initiator. + ts: u64, + /// Detached Falcon signature by the initiator over `session_init_payload_v1`. + #[serde(with = "crate::security::keystore::base64_serde")] + signature: Vec, +} + +#[derive(Serialize, Deserialize)] +struct SessionResponseMessage { + version: u8, + initiator_id: String, + responder_id: String, + /// Session identifier from the initiator. + #[serde(with = "crate::security::keystore::base64_serde")] + session_id: Vec, + /// Session sequence echoed from the initiator. + session_seq: u64, + /// Responder ephemeral X25519 public key (32 bytes). + #[serde(with = "crate::security::keystore::base64_serde")] + x25519_pk: Vec, + /// Kyber encapsulation ciphertext to the initiator ephemeral KEM public key. + #[serde(with = "crate::security::keystore::base64_serde")] + kem_ciphertext: Vec, + /// Informational timestamp (unix seconds) from the responder. + ts: u64, + /// Detached Falcon signature by the responder over `session_resp_payload_v1`. + #[serde(with = "crate::security::keystore::base64_serde")] + signature: Vec, +} + +#[derive(Serialize, Deserialize)] +struct FleetPolicySyncRequest { + version: u8, + client_id: String, + current_seq: u64, +} + +impl FleetPolicySyncRequest { + const VERSION_V1: u8 = 1; +} + +#[derive(Serialize, Deserialize)] +struct RevocationSyncRequest { + version: u8, + client_id: String, + current_seq: u64, +} + +impl RevocationSyncRequest { + const VERSION_V1: u8 = 1; +} + +impl SessionInitMessage { + const VERSION_V1: u8 = 1; +} + +impl SessionResponseMessage { + const VERSION_V1: u8 = 1; +} + +fn vec_to_16(bytes: &[u8]) -> Result<[u8; 16]> { + if bytes.len() != 16 { + return Err(Error::InvalidInput(format!( + "Invalid 16-byte field length: {}", + bytes.len() + ))); + } + let mut out = [0u8; 16]; + out.copy_from_slice(bytes); + Ok(out) +} + +fn vec_to_32(bytes: &[u8]) -> Result<[u8; 32]> { + if bytes.len() != 32 { + return Err(Error::InvalidInput(format!( + "Invalid 32-byte field length: {}", + bytes.len() + ))); + } + let mut out = [0u8; 32]; + out.copy_from_slice(bytes); + Ok(out) +} + +struct SessionInitSigInput<'a> { + topic: &'a str, + session_id: &'a [u8; 16], + session_seq: u64, + initiator_id: &'a str, + responder_id: &'a str, + kem_pk: &'a [u8], + x25519_pk: &'a [u8; 32], + ts: u64, +} + +fn session_init_payload_v1(input: &SessionInitSigInput<'_>) -> Vec { + let mut buf = Vec::new(); + buf.extend_from_slice(b"pqc-iiot:mqtt-session:init:v1"); + buf.extend_from_slice(&(input.topic.len() as u16).to_be_bytes()); + buf.extend_from_slice(input.topic.as_bytes()); + buf.extend_from_slice(&(input.initiator_id.len() as u16).to_be_bytes()); + buf.extend_from_slice(input.initiator_id.as_bytes()); + buf.extend_from_slice(&(input.responder_id.len() as u16).to_be_bytes()); + buf.extend_from_slice(input.responder_id.as_bytes()); + buf.extend_from_slice(input.session_id); + buf.extend_from_slice(&input.session_seq.to_be_bytes()); + buf.extend_from_slice(&input.ts.to_be_bytes()); + buf.extend_from_slice(&(input.kem_pk.len() as u32).to_be_bytes()); + buf.extend_from_slice(input.kem_pk); + buf.extend_from_slice(input.x25519_pk); + buf +} + +struct SessionRespSigInput<'a> { + topic: &'a str, + session_id: &'a [u8; 16], + session_seq: u64, + initiator_id: &'a str, + responder_id: &'a str, + x25519_pk: &'a [u8; 32], + kem_ciphertext: &'a [u8], + ts: u64, +} + +fn session_resp_payload_v1(input: &SessionRespSigInput<'_>) -> Vec { + let mut buf = Vec::new(); + buf.extend_from_slice(b"pqc-iiot:mqtt-session:resp:v1"); + buf.extend_from_slice(&(input.topic.len() as u16).to_be_bytes()); + buf.extend_from_slice(input.topic.as_bytes()); + buf.extend_from_slice(&(input.initiator_id.len() as u16).to_be_bytes()); + buf.extend_from_slice(input.initiator_id.as_bytes()); + buf.extend_from_slice(&(input.responder_id.len() as u16).to_be_bytes()); + buf.extend_from_slice(input.responder_id.as_bytes()); + buf.extend_from_slice(input.session_id); + buf.extend_from_slice(&input.session_seq.to_be_bytes()); + buf.extend_from_slice(&input.ts.to_be_bytes()); + buf.extend_from_slice(input.x25519_pk); + buf.extend_from_slice(&(input.kem_ciphertext.len() as u32).to_be_bytes()); + buf.extend_from_slice(input.kem_ciphertext); + buf +} + +fn kyber_for_pk_len(len: usize) -> Result { + match len { + 800 => Ok(Kyber::new_with_level(KyberSecurityLevel::Kyber512)), + 1184 => Ok(Kyber::new_with_level(KyberSecurityLevel::Kyber768)), + 1568 => Ok(Kyber::new_with_level(KyberSecurityLevel::Kyber1024)), + _ => Err(Error::InvalidInput(format!( + "Invalid Kyber public key length: {}", + len + ))), + } +} + +fn kyber_for_sk_len(len: usize) -> Result { + match len { + 1632 => Ok(Kyber::new_with_level(KyberSecurityLevel::Kyber512)), + 2400 => Ok(Kyber::new_with_level(KyberSecurityLevel::Kyber768)), + 3168 => Ok(Kyber::new_with_level(KyberSecurityLevel::Kyber1024)), + _ => Err(Error::InvalidInput(format!( + "Invalid Kyber secret key length: {}", + len + ))), + } +} + +fn derive_session_chain_keys_v1(kem_ss: &[u8], dh_ss: &[u8]) -> Result<([u8; 32], [u8; 32])> { + if kem_ss.len() != 32 || dh_ss.len() != 32 { + return Err(Error::CryptoError(format!( + "Invalid session shared secret lengths: kem_ss={} dh_ss={}", + kem_ss.len(), + dh_ss.len() + ))); + } + let mut ikm = [0u8; 64]; + ikm[..32].copy_from_slice(kem_ss); + ikm[32..].copy_from_slice(dh_ss); + + let hk = Hkdf::::new(None, &ikm); + let mut ck_initiator = [0u8; 32]; + let mut ck_responder = [0u8; 32]; + hk.expand(b"pqc-iiot:mqtt-session:v1:ck-initiator", &mut ck_initiator) + .map_err(|_| Error::CryptoError("HKDF expand failed (ck-initiator)".into()))?; + hk.expand(b"pqc-iiot:mqtt-session:v1:ck-responder", &mut ck_responder) + .map_err(|_| Error::CryptoError("HKDF expand failed (ck-responder)".into()))?; + + ikm.zeroize(); + + Ok((ck_initiator, ck_responder)) +} + +const MQTT_SESSION_MAX_SKIPPED_KEYS: usize = 50; +const MQTT_SESSION_MAX_MESSAGES: u32 = 100_000; + +#[derive(Debug)] +struct MqttSession { + session_id: [u8; 16], + created_at: Instant, + send_chain_key: [u8; 32], + recv_chain_key: [u8; 32], + send_msg_num: u32, + recv_msg_num: u32, + skipped_message_keys: std::collections::HashMap, +} + +impl MqttSession { + fn new(session_id: [u8; 16], send_chain_key: [u8; 32], recv_chain_key: [u8; 32]) -> Self { + Self { + session_id, + created_at: Instant::now(), + send_chain_key, + recv_chain_key, + send_msg_num: 0, + recv_msg_num: 0, + skipped_message_keys: std::collections::HashMap::new(), + } + } + + fn kdf_ck(ck: &[u8; 32]) -> Result<([u8; 32], [u8; 32])> { + let hkdf = Hkdf::::from_prk(ck) + .map_err(|_| Error::CryptoError("HKDF PRK init failed".into()))?; + let mut mk = [0u8; 32]; + let mut next_ck = [0u8; 32]; + hkdf.expand(b"pqc-iiot:mqtt-session:v1:mk", &mut mk) + .map_err(|_| Error::CryptoError("HKDF expand failed (mk)".into()))?; + hkdf.expand(b"pqc-iiot:mqtt-session:v1:ck", &mut next_ck) + .map_err(|_| Error::CryptoError("HKDF expand failed (ck)".into()))?; + Ok((next_ck, mk)) + } + + fn aad_v2( + sender_id: &str, + receiver_id: &str, + topic: &str, + session_id: &[u8; 16], + msg_num: u32, + ) -> Vec { + let mut aad = Vec::new(); + aad.extend_from_slice(b"pqc-iiot:mqtt-msg:v2"); + aad.extend_from_slice(&(sender_id.len() as u16).to_be_bytes()); + aad.extend_from_slice(sender_id.as_bytes()); + aad.extend_from_slice(&(receiver_id.len() as u16).to_be_bytes()); + aad.extend_from_slice(receiver_id.as_bytes()); + aad.extend_from_slice(&(topic.len() as u16).to_be_bytes()); + aad.extend_from_slice(topic.as_bytes()); + aad.extend_from_slice(session_id); + aad.extend_from_slice(&msg_num.to_be_bytes()); + aad + } + + fn nonce_v2(session_id: &[u8; 16], msg_num: u32) -> [u8; 12] { + let mut nonce = [0u8; 12]; + nonce[..8].copy_from_slice(&session_id[..8]); + nonce[8..].copy_from_slice(&msg_num.to_be_bytes()); + nonce + } + + fn encrypt_v2( + &mut self, + sender_id: &str, + receiver_id: &str, + topic: &str, + plaintext: &[u8], + ) -> Result<(u32, Vec)> { + if self.send_msg_num >= MQTT_SESSION_MAX_MESSAGES { + return Err(Error::ProtocolError(format!( + "Session {} exhausted message budget (send)", + hex::encode(self.session_id) + ))); + } + + let (next_ck, mk) = Self::kdf_ck(&self.send_chain_key)?; + self.send_chain_key = next_ck; + let msg_num = self.send_msg_num; + self.send_msg_num = self.send_msg_num.saturating_add(1); + + let aad = Self::aad_v2(sender_id, receiver_id, topic, &self.session_id, msg_num); + let nonce_bytes = Self::nonce_v2(&self.session_id, msg_num); + + let cipher = Aes256Gcm::new(Key::::from_slice(&mk)); + let ciphertext = cipher + .encrypt( + Nonce::from_slice(&nonce_bytes), + Payload { + msg: plaintext, + aad: &aad, + }, + ) + .map_err(|_| Error::CryptoError("AES-GCM encryption failed".into()))?; + + Ok((msg_num, ciphertext)) + } + + fn decrypt_with_mk_v2( + &self, + sender_id: &str, + receiver_id: &str, + topic: &str, + msg_num: u32, + mk: &[u8; 32], + ciphertext: &[u8], + ) -> Result> { + let aad = Self::aad_v2(sender_id, receiver_id, topic, &self.session_id, msg_num); + let nonce_bytes = Self::nonce_v2(&self.session_id, msg_num); + let cipher = Aes256Gcm::new(Key::::from_slice(mk)); + cipher + .decrypt( + Nonce::from_slice(&nonce_bytes), + Payload { + msg: ciphertext, + aad: &aad, + }, + ) + .map_err(|_| Error::CryptoError("AES-GCM decryption failed".into())) + } + + fn decrypt_v2( + &mut self, + sender_id: &str, + receiver_id: &str, + topic: &str, + msg_num: u32, + ciphertext: &[u8], + ) -> Result> { + if let Some(mk) = self.skipped_message_keys.remove(&msg_num) { + return self.decrypt_with_mk_v2( + sender_id, + receiver_id, + topic, + msg_num, + &mk, + ciphertext, + ); + } + + if msg_num < self.recv_msg_num { + return Err(Error::CryptoError("Message too old / replay".into())); + } + + let delta = msg_num - self.recv_msg_num; + if delta > MQTT_SESSION_MAX_SKIPPED_KEYS as u32 { + return Err(Error::CryptoError( + "Message too far in the future (skip limit exceeded)".into(), + )); + } + + while self.recv_msg_num < msg_num { + let (next_ck, mk) = Self::kdf_ck(&self.recv_chain_key)?; + self.skipped_message_keys.insert(self.recv_msg_num, mk); + self.recv_chain_key = next_ck; + self.recv_msg_num = self.recv_msg_num.saturating_add(1); + } + + let (next_ck, mk) = Self::kdf_ck(&self.recv_chain_key)?; + self.recv_chain_key = next_ck; + self.recv_msg_num = self.recv_msg_num.saturating_add(1); + + self.decrypt_with_mk_v2(sender_id, receiver_id, topic, msg_num, &mk, ciphertext) + } +} + +impl Drop for MqttSession { + fn drop(&mut self) { + self.send_chain_key.zeroize(); + self.recv_chain_key.zeroize(); + for (_, key) in self.skipped_message_keys.iter_mut() { + key.zeroize(); + } + self.skipped_message_keys.clear(); + } +} + +/// Decryption grace window for the previous session after a rotation. +/// +/// This is an availability safeguard: MQTT can reorder/duplicate deliveries, so in-flight packets +/// encrypted under the previous session may arrive after the handshake completes. +const MQTT_PREVIOUS_SESSION_GRACE: Duration = Duration::from_secs(30); + +struct PeerSessions { + current: MqttSession, + previous: Option<(MqttSession, Instant)>, +} + +impl PeerSessions { + fn new(current: MqttSession) -> Self { + Self { + current, + previous: None, + } + } + + fn rotate_to(&mut self, new_session: MqttSession) { + let old = std::mem::replace(&mut self.current, new_session); + self.previous = Some((old, Instant::now())); + } + + fn prune(&mut self) { + if let Some((_, since)) = &self.previous { + if since.elapsed() > MQTT_PREVIOUS_SESSION_GRACE { + self.previous = None; + } + } + } + + fn current_mut(&mut self) -> &mut MqttSession { + self.prune(); + &mut self.current + } + + fn get_mut_by_session_id(&mut self, session_id: &[u8; 16]) -> Option<&mut MqttSession> { + self.prune(); + if &self.current.session_id == session_id { + return Some(&mut self.current); + } + if let Some((prev, _)) = self.previous.as_mut() { + if &prev.session_id == session_id { + return Some(prev); + } + } + None + } +} + +struct PendingSessionInit { + peer_id: String, + session_seq: u64, + kem_sk: Zeroizing>, + x25519_sk: X25519StaticSecret, +} + +struct CachedSessionResponse { + session_seq: u64, + session_id: [u8; 16], + bytes: Vec, +} + +impl Drop for PendingSessionInit { + fn drop(&mut self) { + self.kem_sk.zeroize(); + // x25519_dalek secrets are zeroized on drop. + } +} + /// Canonical payload used for signing/verifying key announcements. /// Excludes the detached signature field to avoid recursion. fn key_announcement_payload(peer_id: &str, keys: &PeerKeys) -> Vec { @@ -205,6 +921,66 @@ fn key_announcement_payload(peer_id: &str, keys: &PeerKeys) -> Vec { buf } +fn mqtt_encrypted_message_signature_digest( + sender_id: &str, + topic: &str, + encrypted_blob: &[u8], +) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(b"pqc-iiot:mqtt-msg:v1"); + hasher.update((sender_id.len() as u16).to_be_bytes()); + hasher.update(sender_id.as_bytes()); + hasher.update((topic.len() as u16).to_be_bytes()); + hasher.update(topic.as_bytes()); + hasher.update((encrypted_blob.len() as u32).to_be_bytes()); + hasher.update(encrypted_blob); + let digest = hasher.finalize(); + let mut out = [0u8; 32]; + out.copy_from_slice(&digest); + out +} + +/// Sliding replay window check. +/// +/// Returns `true` if `seq` is accepted and updates `(last_sequence, replay_window)` in-place. +/// Returns `false` if `seq` is a replay or is too old (outside the window). +fn replay_window_accept(last_sequence: &mut u64, replay_window: &mut u64, seq: u64) -> bool { + const WINDOW_BITS: u64 = 64; + + if seq == 0 { + return false; + } + + if *last_sequence == 0 { + *last_sequence = seq; + *replay_window = 1; + return true; + } + + if seq > *last_sequence { + let delta = seq - *last_sequence; + if delta >= WINDOW_BITS { + *replay_window = 1; + } else { + *replay_window <<= delta; + *replay_window |= 1; + } + *last_sequence = seq; + return true; + } + + let delta = *last_sequence - seq; + if delta >= WINDOW_BITS { + return false; + } + let mask = 1u64 << delta; + if (*replay_window & mask) != 0 { + return false; + } + *replay_window |= mask; + true +} + impl SecureMqttClient { /// Create a new Secure MQTT client. /// @@ -234,7 +1010,31 @@ impl SecureMqttClient { Self::init(broker, port, client_id, Some(key.to_vec())) } + /// Create a new Secure MQTT client using an externally provided `SecurityProvider` (TPM/HSM/TEE). + /// + /// In this mode, long-term secrets are assumed to be managed by the provider and are **not** + /// loaded from or written to the local identity file. Only non-secret metadata + /// (CA trust anchor, operational certificate, sequence number) is persisted via + /// `SecurityProvider::seal_data`. + pub fn new_with_provider( + broker: &str, + port: u16, + client_id: &str, + provider: Arc, + ) -> Result { + Self::init_with_provider(broker, port, client_id, provider) + } + fn init(broker: &str, port: u16, client_id: &str, key: Option>) -> Result { + // MQTT identities are used on-wire as topic suffixes and message prefixes. + // Enforce a strict, bounded charset to avoid topic injection and cardinality DoS. + if !is_valid_wire_peer_id(client_id) { + return Err(Error::InvalidInput(format!( + "Invalid client_id (must match [A-Za-z0-9_.-] and be <= {} bytes)", + MAX_WIRE_ID_LEN + ))); + } + // Run FIPS/Compliance Self-Tests on startup crate::compliance::run_self_tests()?; @@ -450,15 +1250,22 @@ impl SecureMqttClient { // Instantiate SoftwareSecurityProvider (exportable for identity persistence). // X25519 static secret must be pinned to avoid "self-bricking" decryption failures after restart. let mut x25519_sk_bytes = x25519_sk; - let provider = Arc::new(SoftwareSecurityProvider::new_exportable_with_x25519( - sk, - pk.clone(), - sig_sk, - sig_pk.clone(), - x25519_sk_bytes, - )); + let provider: Arc = + Arc::new(SoftwareSecurityProvider::new_exportable_with_x25519( + sk, + pk.clone(), + sig_sk, + sig_pk.clone(), + x25519_sk_bytes, + )); x25519_sk_bytes.zeroize(); + let audit_signer = provider.clone(); + let secure_time = SecureTimeFloor::load( + provider.clone(), + format!("pqc-iiot:time-floor:v1:{}", storage_id), + )?; + let mut client = SecureMqttClient { client: None, // eventloop: None, // Removed @@ -474,6 +1281,11 @@ impl SecureMqttClient { storage_id, // Secure by default: reject unknown peers unless explicitly opted out. strict_mode: true, + require_sessions: false, + require_rollback_resistant_storage: false, + min_revocation_seq: None, + session_rekey_after_msgs: None, + session_rekey_after_secs: None, trust_anchor_ca_sig_pk, operational_cert, attestation_required: false, @@ -484,7 +1296,7 @@ impl SecureMqttClient { data_dir: data_dir.clone(), // Clone here to avoid move encryption_key, // kyber, // Removed - audit_logger: Box::new(ChainedAuditLogger::new(&data_dir)), + audit_logger: Box::new(ChainedAuditLogger::new_signed(&data_dir, audit_signer)), metrics: Arc::new(SecurityMetrics::new()), persist_manager: crate::persistence::LazyPersistManager::new( Duration::from_secs(300), // Flush every 5 mins @@ -493,6 +1305,43 @@ impl SecureMqttClient { network_recv: None, heartbeat: Arc::new(AtomicU64::new(0)), key_prefix: "pqc/keys/".to_string(), + max_key_announcement_bytes: DEFAULT_MAX_KEY_ANNOUNCEMENT_BYTES, + max_attestation_bytes: DEFAULT_MAX_ATTESTATION_BYTES, + max_encrypted_message_bytes: DEFAULT_MAX_ENCRYPTED_MESSAGE_BYTES, + max_revocation_bytes: DEFAULT_MAX_REVOCATION_BYTES, + revocation_topic: "pqc/revocations/v1".to_string(), + max_policy_bytes: DEFAULT_MAX_POLICY_BYTES, + policy_topic: DEFAULT_POLICY_TOPIC.to_string(), + policy_sync_topic: DEFAULT_POLICY_SYNC_TOPIC.to_string(), + fleet_policy: None, + sig_verify_budget: TokenBucketMap::new( + DEFAULT_SIGVERIFY_BUDGET_CAPACITY, + DEFAULT_SIGVERIFY_BUDGET_REFILL_PER_SEC, + DEFAULT_BUDGET_MAX_PEERS, + ), + decrypt_budget: TokenBucketMap::new( + DEFAULT_DECRYPT_BUDGET_CAPACITY, + DEFAULT_DECRYPT_BUDGET_REFILL_PER_SEC, + DEFAULT_BUDGET_MAX_PEERS, + ), + global_sig_verify_budget: TokenBucket::new( + DEFAULT_GLOBAL_SIGVERIFY_BUDGET_CAPACITY, + DEFAULT_GLOBAL_SIGVERIFY_BUDGET_REFILL_PER_SEC, + ), + global_decrypt_budget: TokenBucket::new( + DEFAULT_GLOBAL_DECRYPT_BUDGET_CAPACITY, + DEFAULT_GLOBAL_DECRYPT_BUDGET_REFILL_PER_SEC, + ), + secure_time, + max_session_bytes: DEFAULT_MAX_SESSION_BYTES, + session_init_prefix: DEFAULT_SESSION_INIT_PREFIX.to_string(), + session_resp_prefix: DEFAULT_SESSION_RESP_PREFIX.to_string(), + pending_sessions: std::collections::HashMap::new(), + sessions: std::collections::HashMap::new(), + session_resp_cache: std::collections::HashMap::new(), + revocation_sync_topic: DEFAULT_REVOCATION_SYNC_TOPIC.to_string(), + last_policy_sync_request: None, + last_revocation_sync_request: None, }; // Migrate legacy keystore to the new storage path (best-effort; atomic write). @@ -505,25 +1354,195 @@ impl SecureMqttClient { client.save_identity()?; } - Ok(client) - } + // Initialize keystore anti-rollback binding after any legacy migration flush. + client.init_keystore_anti_rollback()?; - /// Save the current identity to disk. - pub fn save_identity(&self) -> Result<()> { - // Export keys from provider. - let exported = match self.provider.export_secret_keys() { - Some(keys) => keys, - None => { - return Err(Error::ClientError( - "Cannot save identity: Provider does not export keys".to_string(), - )) + if let Some(policy) = client.load_sealed_fleet_policy()? { + if let Some(ca_pk) = client.trust_anchor_ca_sig_pk.clone() { + if let Err(e) = policy.verify(&ca_pk, &client.policy_topic) { + warn!("Ignoring sealed fleet policy: {}", e); + } else { + client.apply_fleet_policy(policy); + } } - }; + } - let x25519_pk = self.provider.x25519_public_key(); + Ok(client) + } - let mut own_keys = OwnKeys { - public_key: self.public_key.clone(), + fn init_with_provider( + broker: &str, + port: u16, + client_id: &str, + provider: Arc, + ) -> Result { + if !is_valid_wire_peer_id(client_id) { + return Err(Error::InvalidInput(format!( + "Invalid client_id (must match [A-Za-z0-9_.-] and be <= {} bytes)", + MAX_WIRE_ID_LEN + ))); + } + + crate::compliance::run_self_tests()?; + + let storage_id = storage_id_for(client_id); + + let mut options = MqttOptions::new(client_id, broker, port); + options.set_keep_alive(Duration::from_secs(5)); + options.set_clean_session(true); + + let data_dir = std::path::Path::new("pqc-data"); + if !data_dir.exists() { + std::fs::create_dir_all(data_dir).map_err(Error::IoError)?; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Err(e) = + std::fs::set_permissions(data_dir, std::fs::Permissions::from_mode(0o700)) + { + warn!("failed to set permissions on {:?}: {}", data_dir, e); + } + } + let data_dir = data_dir.to_path_buf(); + + // Load keystore. + let keystore_path = data_dir.join(format!("keystore_{}.json", storage_id)); + let keystore_path_str = keystore_path.to_str().ok_or(Error::ClientError( + "Invalid Keystore Path (Non-UTF8)".into(), + ))?; + let keystore = KeyStore::load_from_file(keystore_path_str)?; + + // Load sealed identity metadata (best-effort; first run initializes defaults). + let (meta, meta_missing) = load_sealed_identity_meta(&provider, &storage_id)?; + let mut sequence_number = meta.sequence_number; + if sequence_number == 0 { + sequence_number = 1; + } + + let audit_signer = provider.clone(); + let secure_time = SecureTimeFloor::load( + provider.clone(), + format!("pqc-iiot:time-floor:v1:{}", storage_id), + )?; + + let mut client = SecureMqttClient { + client: None, + options, + keystore, + public_key: provider.kem_public_key().to_vec(), + sig_pk: provider.sig_public_key().to_vec(), + provider, + sequence_number, + client_id: client_id.to_string(), + storage_id: storage_id.clone(), + strict_mode: true, + require_sessions: false, + require_rollback_resistant_storage: false, + min_revocation_seq: None, + session_rekey_after_msgs: None, + session_rekey_after_secs: None, + trust_anchor_ca_sig_pk: meta.trust_anchor_ca_sig_pk, + operational_cert: meta.operational_cert, + attestation_required: false, + expected_pcr_digest: vec![0u8; 32], + pending_attestation: std::collections::HashMap::new(), + attest_challenge_prefix: "pqc/attest/challenge/".to_string(), + attest_quote_prefix: "pqc/attest/quote/".to_string(), + data_dir: data_dir.clone(), + encryption_key: None, + audit_logger: Box::new(ChainedAuditLogger::new_signed(&data_dir, audit_signer)), + metrics: Arc::new(SecurityMetrics::new()), + persist_manager: crate::persistence::LazyPersistManager::new( + Duration::from_secs(300), + 50, + ), + network_recv: None, + heartbeat: Arc::new(AtomicU64::new(0)), + key_prefix: "pqc/keys/".to_string(), + max_key_announcement_bytes: DEFAULT_MAX_KEY_ANNOUNCEMENT_BYTES, + max_attestation_bytes: DEFAULT_MAX_ATTESTATION_BYTES, + max_encrypted_message_bytes: DEFAULT_MAX_ENCRYPTED_MESSAGE_BYTES, + max_revocation_bytes: DEFAULT_MAX_REVOCATION_BYTES, + revocation_topic: "pqc/revocations/v1".to_string(), + max_policy_bytes: DEFAULT_MAX_POLICY_BYTES, + policy_topic: DEFAULT_POLICY_TOPIC.to_string(), + policy_sync_topic: DEFAULT_POLICY_SYNC_TOPIC.to_string(), + fleet_policy: None, + sig_verify_budget: TokenBucketMap::new( + DEFAULT_SIGVERIFY_BUDGET_CAPACITY, + DEFAULT_SIGVERIFY_BUDGET_REFILL_PER_SEC, + DEFAULT_BUDGET_MAX_PEERS, + ), + decrypt_budget: TokenBucketMap::new( + DEFAULT_DECRYPT_BUDGET_CAPACITY, + DEFAULT_DECRYPT_BUDGET_REFILL_PER_SEC, + DEFAULT_BUDGET_MAX_PEERS, + ), + global_sig_verify_budget: TokenBucket::new( + DEFAULT_GLOBAL_SIGVERIFY_BUDGET_CAPACITY, + DEFAULT_GLOBAL_SIGVERIFY_BUDGET_REFILL_PER_SEC, + ), + global_decrypt_budget: TokenBucket::new( + DEFAULT_GLOBAL_DECRYPT_BUDGET_CAPACITY, + DEFAULT_GLOBAL_DECRYPT_BUDGET_REFILL_PER_SEC, + ), + secure_time, + max_session_bytes: DEFAULT_MAX_SESSION_BYTES, + session_init_prefix: DEFAULT_SESSION_INIT_PREFIX.to_string(), + session_resp_prefix: DEFAULT_SESSION_RESP_PREFIX.to_string(), + pending_sessions: std::collections::HashMap::new(), + sessions: std::collections::HashMap::new(), + session_resp_cache: std::collections::HashMap::new(), + revocation_sync_topic: DEFAULT_REVOCATION_SYNC_TOPIC.to_string(), + last_policy_sync_request: None, + last_revocation_sync_request: None, + }; + + if !client.provider.is_rollback_resistant_storage() { + warn!( + "SecurityProvider '{}' does not provide rollback-resistant storage; secure time and anti-rollback checks are best-effort only", + client.provider.provider_kind() + ); + } + + // Anchor keystore anti-rollback counter. + client.init_keystore_anti_rollback()?; + + if let Some(policy) = client.load_sealed_fleet_policy()? { + if let Some(ca_pk) = client.trust_anchor_ca_sig_pk.clone() { + if let Err(e) = policy.verify(&ca_pk, &client.policy_topic) { + warn!("Ignoring sealed fleet policy: {}", e); + } else { + client.apply_fleet_policy(policy); + } + } + } + + // First run: persist initial metadata so subsequent boots have a stable anchor. + if meta_missing { + client.save_identity()?; + } + + Ok(client) + } + + /// Save the current identity to disk. + pub fn save_identity(&self) -> Result<()> { + // Non-exportable providers (TPM/HSM) persist only non-secret metadata behind seal/unseal. + if self.provider.export_secret_keys().is_none() { + return self.seal_identity_meta(); + } + + // Export keys from provider (software identity path). + let exported = self.provider.export_secret_keys().ok_or_else(|| { + Error::ClientError("Cannot save identity: Provider does not export keys".to_string()) + })?; + + let x25519_pk = self.provider.x25519_public_key(); + + let mut own_keys = OwnKeys { + public_key: self.public_key.clone(), secret_key: exported.kem_sk, sig_pk: self.sig_pk.clone(), sig_sk: exported.sig_sk, @@ -585,6 +1604,240 @@ impl SecureMqttClient { Ok(()) } + fn identity_meta_label(&self) -> String { + format!("pqc-iiot:identity-meta:v1:{}", self.storage_id) + } + + fn session_out_seq_label(&self, peer_id: &str) -> String { + format!( + "pqc-iiot:mqtt-session-outseq:v1:{}:{}", + self.storage_id, + storage_id_for(peer_id) + ) + } + + fn session_in_seq_label(&self, peer_id: &str) -> String { + format!( + "pqc-iiot:mqtt-session-inseq:v1:{}:{}", + self.storage_id, + storage_id_for(peer_id) + ) + } + + fn next_session_seq(&self, peer_id: &str) -> Result { + let label = self.session_out_seq_label(peer_id); + let current = unseal_u64(&self.provider, &label)?.unwrap_or(0); + let next = current.saturating_add(1).max(1); + seal_u64(&self.provider, &label, next)?; + Ok(next) + } + + fn last_inbound_session_seq(&self, peer_id: &str) -> Result { + let label = self.session_in_seq_label(peer_id); + Ok(unseal_u64(&self.provider, &label)?.unwrap_or(0)) + } + + fn persist_inbound_session_seq(&self, peer_id: &str, seq: u64) -> Result<()> { + let label = self.session_in_seq_label(peer_id); + seal_u64(&self.provider, &label, seq) + } + + fn fleet_policy_label(&self) -> String { + format!("pqc-iiot:fleet-policy:v1:{}", self.storage_id) + } + + fn load_sealed_fleet_policy(&self) -> Result> { + let label = self.fleet_policy_label(); + match self.provider.unseal_data(&label) { + Ok(blob) => { + let policy: FleetPolicyUpdate = serde_json::from_slice(&blob).map_err(|e| { + Error::ClientError(format!("Invalid sealed fleet policy ({}): {}", label, e)) + })?; + Ok(Some(policy)) + } + Err(Error::IoError(e)) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e), + } + } + + fn seal_fleet_policy(&self, policy: &FleetPolicyUpdate) -> Result<()> { + let label = self.fleet_policy_label(); + let blob = serde_json::to_vec(policy) + .map_err(|e| Error::ClientError(format!("Fleet policy serialization error: {}", e)))?; + self.provider.seal_data(&label, &blob) + } + + fn apply_fleet_policy(&mut self, policy: FleetPolicyUpdate) { + self.strict_mode = policy.strict_mode; + self.attestation_required = policy.attestation_required; + self.require_sessions = policy.require_sessions; + self.require_rollback_resistant_storage = policy.require_rollback_resistant_storage; + self.min_revocation_seq = policy.min_revocation_seq; + self.session_rekey_after_msgs = policy.session_rekey_after_msgs; + self.session_rekey_after_secs = policy.session_rekey_after_secs; + + if let Some(b) = &policy.sig_verify_budget { + let max_peers = self.sig_verify_budget.max_peers; + self.sig_verify_budget = + TokenBucketMap::new(b.per_peer_capacity, b.per_peer_refill_per_sec, max_peers); + self.global_sig_verify_budget = + TokenBucket::new(b.global_capacity, b.global_refill_per_sec); + } + if let Some(b) = &policy.decrypt_budget { + let max_peers = self.decrypt_budget.max_peers; + self.decrypt_budget = + TokenBucketMap::new(b.per_peer_capacity, b.per_peer_refill_per_sec, max_peers); + self.global_decrypt_budget = + TokenBucket::new(b.global_capacity, b.global_refill_per_sec); + } + + self.fleet_policy = Some(policy); + } + + fn ensure_storage_assurance(&self, op: &str) -> Result<()> { + if self.require_rollback_resistant_storage && !self.provider.is_rollback_resistant_storage() + { + return Err(Error::ClientError(format!( + "Fleet policy requires rollback-resistant storage; refusing {} (provider_kind={})", + op, + self.provider.provider_kind() + ))); + } + Ok(()) + } + + fn ensure_revocation_caught_up(&mut self, op: &str) -> Result<()> { + let min = match self.min_revocation_seq { + Some(v) => v, + None => return Ok(()), + }; + let have = self.keystore.revocation_seq(); + if have < min { + warn!( + "Revocation state behind policy requirement: have_seq={} < min_seq={} (op={})", + have, min, op + ); + let _ = self.maybe_request_revocation_sync(); + return Err(Error::ClientError(format!( + "Revocation state behind policy requirement (have_seq={} < min_seq={}); refusing {}", + have, min, op + ))); + } + Ok(()) + } + + fn maybe_request_fleet_policy_sync(&mut self) -> Result<()> { + let now = Instant::now(); + if let Some(last) = self.last_policy_sync_request { + if now.duration_since(last) < DEFAULT_SYNC_REQUEST_COOLDOWN { + return Ok(()); + } + } + self.last_policy_sync_request = Some(now); + + let current_seq = self.fleet_policy.as_ref().map(|p| p.seq).unwrap_or(0); + let req = FleetPolicySyncRequest { + version: FleetPolicySyncRequest::VERSION_V1, + client_id: self.client_id.clone(), + current_seq, + }; + let payload = serde_json::to_vec(&req) + .map_err(|e| Error::ClientError(format!("FleetPolicySyncRequest JSON error: {}", e)))?; + + if let Some(client) = &mut self.client { + if let Err(e) = + client.publish(&self.policy_sync_topic, QoS::AtLeastOnce, false, payload) + { + warn!( + "FleetPolicySyncRequest publish failed (topic={}): {}", + self.policy_sync_topic, e + ); + } + } + Ok(()) + } + + fn maybe_request_revocation_sync(&mut self) -> Result<()> { + let now = Instant::now(); + if let Some(last) = self.last_revocation_sync_request { + if now.duration_since(last) < DEFAULT_SYNC_REQUEST_COOLDOWN { + return Ok(()); + } + } + self.last_revocation_sync_request = Some(now); + + let current_seq = self.keystore.revocation_seq(); + let req = RevocationSyncRequest { + version: RevocationSyncRequest::VERSION_V1, + client_id: self.client_id.clone(), + current_seq, + }; + let payload = serde_json::to_vec(&req) + .map_err(|e| Error::ClientError(format!("RevocationSyncRequest JSON error: {}", e)))?; + + if let Some(client) = &mut self.client { + if let Err(e) = client.publish( + &self.revocation_sync_topic, + QoS::AtLeastOnce, + false, + payload, + ) { + warn!( + "RevocationSyncRequest publish failed (topic={}): {}", + self.revocation_sync_topic, e + ); + } + } + Ok(()) + } + + fn is_fleet_policy_stale(&mut self) -> Result { + let (issued_at, ttl_secs) = match &self.fleet_policy { + Some(p) => match p.ttl_secs { + Some(ttl) => (p.issued_at, ttl), + None => return Ok(false), + }, + None => return Ok(false), + }; + + let now = self.secure_time.now_unix_s()?; + Ok(now.saturating_sub(issued_at) > ttl_secs) + } + + fn ensure_fleet_policy_fresh(&mut self, op: &str) -> Result<()> { + self.ensure_storage_assurance(op)?; + self.ensure_revocation_caught_up(op)?; + if self.is_fleet_policy_stale()? { + let _ = self.maybe_request_fleet_policy_sync(); + return Err(Error::ClientError(format!( + "Fleet policy stale (ttl exceeded); refusing {}", + op + ))); + } + Ok(()) + } + + fn drop_if_fleet_policy_stale(&mut self, op: &str) -> Result { + if self.is_fleet_policy_stale()? { + let _ = self.maybe_request_fleet_policy_sync(); + warn!("Dropping {}: fleet policy stale (ttl exceeded)", op); + return Ok(true); + } + Ok(false) + } + + fn seal_identity_meta(&self) -> Result<()> { + let meta = SealedIdentityMeta { + version: SealedIdentityMeta::VERSION_V1, + trust_anchor_ca_sig_pk: self.trust_anchor_ca_sig_pk.clone(), + operational_cert: self.operational_cert.clone(), + sequence_number: self.sequence_number.max(1), + }; + let blob = serde_json::to_vec(&meta) + .map_err(|e| Error::ClientError(format!("Identity meta serialization error: {}", e)))?; + self.provider.seal_data(&self.identity_meta_label(), &blob) + } + fn keystore_path(&self) -> std::path::PathBuf { self.data_dir .join(format!("keystore_{}.json", self.storage_id)) @@ -595,7 +1848,16 @@ impl SecureMqttClient { let keystore_path_str = keystore_path.to_str().ok_or(Error::ClientError( "Invalid Keystore Path (Non-UTF8)".into(), ))?; + // Bump generation *before* persistence. If we crash after file write but before sealing, + // the next boot repairs a +1 mismatch (see init_keystore_anti_rollback). + let gen = self.keystore.bump_generation(); self.keystore.save_to_file(keystore_path_str)?; + + // Bind the persisted keystore generation to a sealed counter behind the provider. + // In TPM/HSM-backed providers, this becomes an anti-rollback primitive for replay windows. + let label = self.keystore_generation_label(); + seal_u64(&self.provider, &label, gen)?; + Ok(()) } @@ -607,6 +1869,53 @@ impl SecureMqttClient { Ok(()) } + fn keystore_generation_label(&self) -> String { + // Tie the counter to storage_id to avoid using client_id as a filesystem fragment and to + // keep identities stable across display-name changes. + format!("pqc-iiot:keystore-gen:v1:{}", self.storage_id) + } + + fn init_keystore_anti_rollback(&mut self) -> Result<()> { + let label = self.keystore_generation_label(); + let file_gen = self.keystore.generation(); + let sealed_gen = unseal_u64(&self.provider, &label)?; + + match sealed_gen { + None => { + // First run (or legacy upgrade): anchor the counter to the current on-disk generation. + seal_u64(&self.provider, &label, file_gen)?; + } + Some(sealed) => { + if file_gen < sealed { + return Err(Error::ClientError(format!( + "Keystore rollback detected: file_gen={} < sealed_gen={}", + file_gen, sealed + ))); + } + if file_gen > sealed { + // Accept a +1 mismatch as a crash window repair; anything larger is suspicious. + if file_gen == sealed.saturating_add(1) { + seal_u64(&self.provider, &label, file_gen)?; + } else { + return Err(Error::ClientError(format!( + "Keystore generation mismatch: file_gen={} sealed_gen={}", + file_gen, sealed + ))); + } + } + } + } + Ok(()) + } + + fn allow_sig_verify(&mut self, peer_id: &str) -> bool { + self.global_sig_verify_budget.allow() && self.sig_verify_budget.allow(peer_id) + } + + fn allow_decrypt(&mut self, peer_id: &str) -> bool { + self.global_decrypt_budget.allow() && self.decrypt_budget.allow(peer_id) + } + // ... builders ... /// Set the keep-alive interval. pub fn with_keep_alive(mut self, duration: Duration) -> Self { @@ -654,6 +1963,160 @@ impl SecureMqttClient { self } + /// Set the maximum accepted bytes for key announcements (`pqc/keys/`). + /// + /// This is a DoS containment boundary: payloads larger than this are dropped before parsing. + pub fn with_max_key_announcement_bytes(mut self, max_bytes: usize) -> Self { + self.max_key_announcement_bytes = max_bytes; + self + } + + /// Set the maximum accepted bytes for attestation messages (challenge/quote). + pub fn with_max_attestation_bytes(mut self, max_bytes: usize) -> Self { + self.max_attestation_bytes = max_bytes; + self + } + + /// Set the maximum accepted bytes for encrypted messages. + pub fn with_max_encrypted_message_bytes(mut self, max_bytes: usize) -> Self { + self.max_encrypted_message_bytes = max_bytes; + self + } + + /// Set the maximum accepted bytes for revocation updates. + pub fn with_max_revocation_bytes(mut self, max_bytes: usize) -> Self { + self.max_revocation_bytes = max_bytes; + self + } + + /// Set the maximum accepted bytes for fleet policy updates. + pub fn with_max_policy_bytes(mut self, max_bytes: usize) -> Self { + self.max_policy_bytes = max_bytes; + self + } + + /// Set the maximum accepted bytes for MQTT session control messages (init/resp). + /// + /// These messages carry ephemeral public keys and signatures and should remain bounded. + pub fn with_max_session_bytes(mut self, max_bytes: usize) -> Self { + self.max_session_bytes = max_bytes; + self + } + + /// Configure signature verification DoS budgets. + /// + /// This caps **expensive** cryptographic work (Falcon signature verification, certificate verification). + /// Budgets are enforced per-peer *and* globally to prevent sender-id cardinality attacks. + pub fn with_sig_verify_budget( + mut self, + per_peer_capacity: u32, + per_peer_refill_per_sec: u32, + global_capacity: u32, + global_refill_per_sec: u32, + ) -> Self { + let max_peers = self.sig_verify_budget.max_peers; + self.sig_verify_budget = + TokenBucketMap::new(per_peer_capacity, per_peer_refill_per_sec, max_peers); + self.global_sig_verify_budget = TokenBucket::new(global_capacity, global_refill_per_sec); + self + } + + /// Configure decryption/KEM DoS budgets. + /// + /// This caps decapsulation + AEAD decrypt work, which is typically the highest-cost path. + pub fn with_decrypt_budget( + mut self, + per_peer_capacity: u32, + per_peer_refill_per_sec: u32, + global_capacity: u32, + global_refill_per_sec: u32, + ) -> Self { + let max_peers = self.decrypt_budget.max_peers; + self.decrypt_budget = + TokenBucketMap::new(per_peer_capacity, per_peer_refill_per_sec, max_peers); + self.global_decrypt_budget = TokenBucket::new(global_capacity, global_refill_per_sec); + self + } + + /// Configure the maximum number of tracked peers in DoS budget maps. + /// + /// This is a hard memory bound under sender-id cardinality attacks. + pub fn with_budget_max_peers(mut self, max_peers: usize) -> Self { + self.sig_verify_budget = TokenBucketMap::new( + self.sig_verify_budget.capacity, + self.sig_verify_budget.refill_rate_per_sec, + max_peers, + ); + self.decrypt_budget = TokenBucketMap::new( + self.decrypt_budget.capacity, + self.decrypt_budget.refill_rate_per_sec, + max_peers, + ); + self + } + + /// Set the revocation topic (default "pqc/revocations/v1"). + /// + /// This topic is expected to carry CA-signed revocation updates (CRL-like) and should be + /// retained by the broker so reconnecting devices can fetch the latest policy. + pub fn with_revocation_topic(mut self, topic: &str) -> Self { + self.revocation_topic = topic.to_string(); + self + } + + /// Set the fleet policy topic (default "pqc/policy/v1"). + /// + /// This topic is expected to carry CA-signed FleetPolicyUpdate messages and should be retained + /// by the broker so reconnecting devices can fetch the latest policy. + pub fn with_policy_topic(mut self, topic: &str) -> Self { + self.policy_topic = topic.to_string(); + self + } + + /// Set the fleet policy sync request topic (default "pqc/policy/sync/v1"). + /// + /// This topic is used by devices to request that the control plane republishes the latest signed + /// `FleetPolicyUpdate` on `policy_topic`. It is a best-effort catch-up mechanism for long + /// partitions; it is not a delivery guarantee. + pub fn with_policy_sync_topic(mut self, topic: &str) -> Self { + self.policy_sync_topic = topic.to_string(); + self + } + + /// Set the revocation sync request topic (default "pqc/revocations/sync/v1"). + /// + /// This topic is used by devices to request that the control plane republishes the latest signed + /// `RevocationUpdate` on `revocation_topic`. It is a best-effort catch-up mechanism for long + /// partitions; it is not a delivery guarantee. + pub fn with_revocation_sync_topic(mut self, topic: &str) -> Self { + self.revocation_sync_topic = topic.to_string(); + self + } + + /// Set the session init topic prefix (default `pqc/session/init/`). + /// + /// Session init messages are addressed to the **responder** under: + /// `{session_init_prefix}{responder_id}`. + pub fn with_session_init_prefix(mut self, prefix: &str) -> Self { + self.session_init_prefix = prefix.to_string(); + if !self.session_init_prefix.ends_with('/') { + self.session_init_prefix.push('/'); + } + self + } + + /// Set the session response topic prefix (default `pqc/session/resp/`). + /// + /// Session responses are addressed to the **initiator** under: + /// `{session_resp_prefix}{initiator_id}`. + pub fn with_session_resp_prefix(mut self, prefix: &str) -> Self { + self.session_resp_prefix = prefix.to_string(); + if !self.session_resp_prefix.ends_with('/') { + self.session_resp_prefix.push('/'); + } + self + } + /// Pin the mesh CA public key used to verify OperationalCertificates. /// /// This is the trust anchor that eliminates TOFU for `pqc/keys/*` announcements. @@ -836,10 +2299,30 @@ impl SecureMqttClient { .subscribe("e_topic, QoS::AtLeastOnce) .map_err(|e| Error::MqttError(e.to_string()))?; - // Provisioned identity is the default trust model: eliminate TOFU. - // Nonce-based attestation is challenge-driven (handled out-of-band), so bootstrap does not emit a quote. - let cert = match &self.operational_cert { - Some(c) => Some(c.clone()), + // Subscribe to session control topics directed to this client. + let session_init_topic = format!("{}{}", self.session_init_prefix, self.client_id); + client + .subscribe(&session_init_topic, QoS::AtLeastOnce) + .map_err(|e| Error::MqttError(e.to_string()))?; + let session_resp_topic = format!("{}{}", self.session_resp_prefix, self.client_id); + client + .subscribe(&session_resp_topic, QoS::AtLeastOnce) + .map_err(|e| Error::MqttError(e.to_string()))?; + + // Subscribe to fleet revocations (CA-signed CRL-like updates). + client + .subscribe(&self.revocation_topic, QoS::AtLeastOnce) + .map_err(|e| Error::MqttError(e.to_string()))?; + + // Subscribe to fleet policy updates (CA-signed, retained). + client + .subscribe(&self.policy_topic, QoS::AtLeastOnce) + .map_err(|e| Error::MqttError(e.to_string()))?; + + // Provisioned identity is the default trust model: eliminate TOFU. + // Nonce-based attestation is challenge-driven (handled out-of-band), so bootstrap does not emit a quote. + let cert = match &self.operational_cert { + Some(c) => Some(c.clone()), None => { if self.strict_mode { return Err(Error::ClientError( @@ -888,6 +2371,7 @@ impl SecureMqttClient { key_id, operational_cert: cert, last_sequence: 0, + replay_window: 0, is_trusted: true, // Self is trusted quote: None, key_signature: None, @@ -912,6 +2396,10 @@ impl SecureMqttClient { warn!("keystore flush failed: {}", e); } } + + // Best-effort catch-up for long partitions: request the latest policy/revocations from the control plane. + let _ = self.maybe_request_revocation_sync(); + let _ = self.maybe_request_fleet_policy_sync(); Ok(()) } @@ -924,6 +2412,19 @@ impl SecureMqttClient { target_client_id: &str, ) -> Result<()> { self.ensure_connected()?; + self.ensure_fleet_policy_fresh("publish_encrypted")?; + + // Prefer forward-secure session encryption when a session is established. + // This is opt-in by calling `initiate_session()`; v1 remains as the fallback. + if self.sessions.contains_key(target_client_id) { + return self.publish_encrypted_session(topic, payload, target_client_id); + } + if self.require_sessions { + return Err(Error::ClientError(format!( + "Fleet policy requires sessions; no active session for {}. Call initiate_session() first", + target_client_id + ))); + } // 1. Get Target Keys let target_keys = self @@ -961,8 +2462,11 @@ impl SecureMqttClient { &attached_payload, )?; - // 4. Sign the encrypted blob - let signature = self.provider.sign(&encrypted_blob)?; + // 4. Sign the encrypted blob with explicit domain separation and topic binding. + // This prevents cross-protocol confusion and topic re-routing attacks. + let digest = + mqtt_encrypted_message_signature_digest(&self.client_id, topic, &encrypted_blob); + let signature = self.provider.sign(&digest)?; let sig_len = signature.len() as u16; // 5. Construct Packet: [ SenderID Len(2) ] [ SenderID ] [ Encrypted Blob ] [ Signature Len(2) ] [ Signature ] @@ -1040,6 +2544,198 @@ impl SecureMqttClient { } } + /// Check if a forward-secure session (ratchet) is established for this peer. + pub fn has_session(&self, peer_id: &str) -> bool { + self.sessions.contains_key(peer_id) + } + + /// Initiate an ephemeral authenticated session with a trusted peer. + /// + /// This is a building block for forward secrecy and post-compromise recovery: + /// - it uses ephemeral Kyber + ephemeral X25519, authenticated by long-term Falcon identities. + /// - once established, payloads can be protected via a symmetric ratchet without per-message KEM/signature costs. + pub fn initiate_session(&mut self, peer_id: &str) -> Result<()> { + self.ensure_connected()?; + self.ensure_fleet_policy_fresh("initiate_session")?; + + if !is_valid_wire_peer_id(peer_id) { + return Err(Error::InvalidInput("Invalid peer_id for session".into())); + } + + let keys = self.keystore.get(peer_id).ok_or_else(|| { + Error::ClientError(format!( + "Cannot initiate session: unknown peer (no keystore entry): {}", + peer_id + )) + })?; + + if !keys.is_trusted { + return Err(Error::ClientError(format!( + "Cannot initiate session: peer not trusted/ready: {}", + peer_id + ))); + } + + if let Some(key_id) = keys.key_id.as_deref() { + if self.keystore.is_key_id_revoked(peer_id, key_id) { + return Err(Error::ClientError(format!( + "Cannot initiate session: peer key_id is revoked: {}", + peer_id + ))); + } + } + + // Generate session_id (16 bytes). + let mut session_id = [0u8; 16]; + OsRng.fill_bytes(&mut session_id); + + // Ephemeral X25519 key pair. + let x25519_sk = X25519StaticSecret::random_from_rng(OsRng); + let x25519_pk = X25519PublicKey::from(&x25519_sk).to_bytes(); + + // Ephemeral Kyber key pair (match our configured Kyber level). + let kyber = kyber_for_pk_len(self.public_key.len())?; + let (kem_pk, kem_sk) = kyber.generate_keypair()?; + + let topic = format!("{}{}", self.session_init_prefix, peer_id); + let ts = self.secure_time.now_unix_s()?; + let session_seq = self.next_session_seq(peer_id)?; + let payload = session_init_payload_v1(&SessionInitSigInput { + topic: topic.as_str(), + session_id: &session_id, + session_seq, + initiator_id: &self.client_id, + responder_id: peer_id, + kem_pk: &kem_pk, + x25519_pk: &x25519_pk, + ts, + }); + let signature = self.provider.sign(&payload)?; + + let msg = SessionInitMessage { + version: SessionInitMessage::VERSION_V1, + initiator_id: self.client_id.clone(), + responder_id: peer_id.to_string(), + session_id: session_id.to_vec(), + session_seq, + kem_pk, + x25519_pk: x25519_pk.to_vec(), + ts, + signature, + }; + + let bytes = serde_json::to_vec(&msg) + .map_err(|e| Error::ClientError(format!("SessionInit JSON error: {}", e)))?; + + self.pending_sessions.insert( + session_id, + PendingSessionInit { + peer_id: peer_id.to_string(), + session_seq, + kem_sk: Zeroizing::new(kem_sk), + x25519_sk, + }, + ); + + if let Some(client) = &mut self.client { + client + .publish(topic, QoS::AtLeastOnce, false, bytes) + .map_err(|e| Error::MqttError(e.to_string()))?; + } + + Ok(()) + } + + /// Publish an encrypted message using the forward-secure session ratchet (v2). + /// + /// Requires a session to be established via `initiate_session()` and a corresponding response. + pub fn publish_encrypted_session( + &mut self, + topic: &str, + payload: &[u8], + target_peer_id: &str, + ) -> Result<()> { + self.ensure_connected()?; + self.ensure_fleet_policy_fresh("publish_encrypted_session")?; + + // Enforce periodic re-handshake thresholds from fleet policy (PCS building block). + let needs_rekey = match self.sessions.get(target_peer_id) { + Some(peer_sessions) => { + let msgs = self.session_rekey_after_msgs; + let secs = self.session_rekey_after_secs; + let mut required = false; + if let Some(max_msgs) = msgs { + if peer_sessions.current.send_msg_num >= max_msgs { + required = true; + } + } + if let Some(max_secs) = secs { + if peer_sessions.current.created_at.elapsed() >= Duration::from_secs(max_secs) { + required = true; + } + } + required + } + None => { + return Err(Error::ClientError(format!( + "No active session for {}; call initiate_session() and wait for response", + target_peer_id + ))) + } + }; + if needs_rekey { + if !self + .pending_sessions + .values() + .any(|p| p.peer_id == target_peer_id) + { + // Best-effort: initiate a fresh session; caller retries once established. + self.initiate_session(target_peer_id)?; + } + return Err(Error::ClientError(format!( + "Session requires rekey; initiated session handshake for {}; retry later", + target_peer_id + ))); + } + + let peer_sessions = self.sessions.get_mut(target_peer_id).ok_or_else(|| { + Error::ClientError(format!( + "No active session for {}; call initiate_session() and wait for response", + target_peer_id + )) + })?; + + let session = peer_sessions.current_mut(); + let (msg_num, ciphertext) = + session.encrypt_v2(&self.client_id, target_peer_id, topic, payload)?; + + // Packet: [sender_id_len:u16][sender_id][v=2][session_id:16][msg_num:u32][ct_len:u32][ct] + let sender_id_bytes = self.client_id.as_bytes(); + let sender_id_len = sender_id_bytes.len() as u16; + + if ciphertext.len() > u32::MAX as usize { + return Err(Error::InvalidInput("Ciphertext too large".into())); + } + let ct_len = ciphertext.len() as u32; + + let mut packet = + Vec::with_capacity(2 + sender_id_bytes.len() + 1 + 16 + 4 + 4 + ciphertext.len()); + packet.extend_from_slice(&sender_id_len.to_be_bytes()); + packet.extend_from_slice(sender_id_bytes); + packet.push(2); + packet.extend_from_slice(&session.session_id); + packet.extend_from_slice(&msg_num.to_be_bytes()); + packet.extend_from_slice(&ct_len.to_be_bytes()); + packet.extend_from_slice(&ciphertext); + + if let Some(client) = &mut self.client { + client + .publish(topic, QoS::AtLeastOnce, false, packet) + .map_err(|e| Error::MqttError(e.to_string()))?; + } + Ok(()) + } + /// Manually add a trusted peer with their Identity Key (Falcon). pub fn add_trusted_peer(&mut self, client_id: &str, sig_pk: Vec) -> Result<()> { // Create a placeholder PeerKeys with just the identity key (sig_pk) @@ -1060,6 +2756,7 @@ impl SecureMqttClient { key_id: None, operational_cert: None, last_sequence: 0, + replay_window: 0, is_trusted: true, quote: None, key_signature: None, @@ -1137,6 +2834,22 @@ impl SecureMqttClient { // 1. Check Key Exchange if let Some(sender_id) = topic.strip_prefix(&self.key_prefix) { if sender_id != self.client_id { + if !is_valid_wire_peer_id(sender_id) { + warn!( + "Dropping key announcement with invalid peer id: {:?}", + sender_id + ); + return Ok(None); + } + if payload.len() > self.max_key_announcement_bytes { + warn!( + "Dropping key announcement from {}: payload too large ({} bytes > {})", + sender_id, + payload.len(), + self.max_key_announcement_bytes + ); + return Ok(None); + } let keys: PeerKeys = serde_json::from_slice(&payload) .map_err(|e| Error::ClientError(format!("Invalid keys: {}", e)))?; self.handle_key_exchange(sender_id, keys)?; @@ -1147,6 +2860,14 @@ impl SecureMqttClient { // 1.1 Attestation challenge (directed to this subject) if let Some(target_id) = topic.strip_prefix(&self.attest_challenge_prefix) { if target_id == self.client_id { + if payload.len() > self.max_attestation_bytes { + warn!( + "Dropping attestation challenge: payload too large ({} bytes > {})", + payload.len(), + self.max_attestation_bytes + ); + return Ok(None); + } let challenge: AttestationChallenge = serde_json::from_slice(&payload) .map_err(|e| { Error::ClientError(format!("Invalid attestation challenge: {}", e)) @@ -1159,6 +2880,14 @@ impl SecureMqttClient { // 1.2 Attestation quote (directed to this verifier) if let Some(verifier_id) = topic.strip_prefix(&self.attest_quote_prefix) { if verifier_id == self.client_id { + if payload.len() > self.max_attestation_bytes { + warn!( + "Dropping attestation quote msg: payload too large ({} bytes > {})", + payload.len(), + self.max_attestation_bytes + ); + return Ok(None); + } let msg: AttestationQuoteMessage = serde_json::from_slice(&payload).map_err(|e| { Error::ClientError(format!("Invalid attestation quote msg: {}", e)) @@ -1168,28 +2897,172 @@ impl SecureMqttClient { return Ok(None); } + // 1.3 Revocation updates (CA-signed, CRL-like). + if topic == self.revocation_topic { + if payload.len() > self.max_revocation_bytes { + warn!( + "Dropping revocation update: payload too large ({} bytes > {})", + payload.len(), + self.max_revocation_bytes + ); + return Ok(None); + } + let update: RevocationUpdate = serde_json::from_slice(&payload).map_err(|e| { + Error::ClientError(format!("Invalid revocation update JSON: {}", e)) + })?; + self.handle_revocation_update(&topic, update)?; + return Ok(None); + } + + // 1.3.1 Fleet policy updates (CA-signed, retained). + if topic == self.policy_topic { + if payload.len() > self.max_policy_bytes { + warn!( + "Dropping fleet policy update: payload too large ({} bytes > {})", + payload.len(), + self.max_policy_bytes + ); + return Ok(None); + } + let update: FleetPolicyUpdate = serde_json::from_slice(&payload).map_err(|e| { + Error::ClientError(format!("Invalid fleet policy update JSON: {}", e)) + })?; + self.handle_fleet_policy_update(&topic, update)?; + return Ok(None); + } + + // 1.4 Session control: init (directed to this responder). + if let Some(target_id) = topic.strip_prefix(&self.session_init_prefix) { + if target_id == self.client_id { + if payload.len() > self.max_session_bytes { + warn!( + "Dropping session init: payload too large ({} bytes > {})", + payload.len(), + self.max_session_bytes + ); + return Ok(None); + } + let msg: SessionInitMessage = + serde_json::from_slice(&payload).map_err(|e| { + Error::ClientError(format!("Invalid session init JSON: {}", e)) + })?; + self.handle_session_init(&topic, msg)?; + } + return Ok(None); + } + + // 1.5 Session control: response (directed to this initiator). + if let Some(target_id) = topic.strip_prefix(&self.session_resp_prefix) { + if target_id == self.client_id { + if payload.len() > self.max_session_bytes { + warn!( + "Dropping session response: payload too large ({} bytes > {})", + payload.len(), + self.max_session_bytes + ); + return Ok(None); + } + let msg: SessionResponseMessage = + serde_json::from_slice(&payload).map_err(|e| { + Error::ClientError(format!("Invalid session response JSON: {}", e)) + })?; + self.handle_session_response(&topic, msg)?; + } + return Ok(None); + } + // 2. Check Encrypted Packet (SenderID prefixed) + if payload.len() > self.max_encrypted_message_bytes { + warn!( + "Dropping encrypted message: payload too large ({} bytes > {})", + payload.len(), + self.max_encrypted_message_bytes + ); + return Ok(None); + } + + // If policy TTL is in effect and is stale, fail closed for decrypt/verify work. + if self.drop_if_fleet_policy_stale("mqtt encrypted message")? { + return Ok(None); + } + if let Err(e) = self.ensure_storage_assurance("mqtt encrypted message") { + warn!("Dropping encrypted message: {}", e); + return Ok(None); + } + if let Err(e) = self.ensure_revocation_caught_up("mqtt encrypted message") { + warn!("Dropping encrypted message: {}", e); + return Ok(None); + } if payload.len() > 2 { let (len_bytes, _) = payload.split_at(2); let id_len = u16::from_be_bytes([len_bytes[0], len_bytes[1]]) as usize; // Heuristic check - if id_len > 0 && id_len < 256 && payload.len() > 2 + id_len + 4 { + if id_len > 0 && id_len <= MAX_WIRE_ID_LEN && payload.len() > 2 + id_len + 4 { let (id_bytes, rest) = payload[2..].split_at(id_len); if let Ok(sender_id) = std::str::from_utf8(id_bytes) { + if !is_valid_wire_peer_id(sender_id) { + warn!( + "Dropping encrypted message with invalid sender_id: {:?}", + sender_id + ); + return Ok(None); + } trace!("mqtt rx extracted sender_id={}", sender_id); - // Look for signature at end + // Session/ratchet encrypted packet (v2): [2][session_id:16][msg_num:u32][ct_len:u32][ct] + // No per-message signature; authenticity is provided by the established session keys. + if !rest.is_empty() && rest[0] == 2 { + if let Some(plaintext) = + self.try_decrypt_session_packet_v2(&topic, sender_id, rest)? + { + return Ok(Some((topic, plaintext))); + } + return Ok(None); + } + + // Policy enforcement: when sessions are required, drop v1 hybrid encrypted packets. + if self.require_sessions { + warn!( + "Dropping v1 encrypted message from {}: fleet policy requires sessions", + sender_id + ); + return Ok(None); + } + + // v1: Look for signature at end if rest.len() > 2 { let (blob_and_sig, sig_len_bytes) = rest.split_at(rest.len() - 2); let sig_len = u16::from_be_bytes([sig_len_bytes[0], sig_len_bytes[1]]) as usize; + // Falcon signatures are small; reject absurd lengths early. + if sig_len == 0 || sig_len > 2048 { + warn!( + "Dropping encrypted message from {}: invalid signature length {}", + sender_id, sig_len + ); + return Ok(None); + } + if blob_and_sig.len() > sig_len { let (encrypted_blob, signature) = blob_and_sig.split_at(blob_and_sig.len() - sig_len); if let Some(keys) = self.keystore.get(sender_id) { - if !keys.is_trusted { + let sig_pk = keys.sig_pk.clone(); + let key_id = keys.key_id.clone(); + let is_trusted = keys.is_trusted; + + if let Some(key_id) = key_id.as_deref() { + if self.keystore.is_key_id_revoked(sender_id, key_id) { + warn!( + "Dropping encrypted message from {}: key_id revoked", + sender_id + ); + return Ok(None); + } + } + if !is_trusted { warn!( "Dropping encrypted message from untrusted peer: {}", sender_id @@ -1197,22 +3070,44 @@ impl SecureMqttClient { return Ok(None); } - let is_valid = match verify_falcon_auto( - &keys.sig_pk, + // Asymmetric-cost DoS budget: signature verification is expensive. + if !self.allow_sig_verify(sender_id) { + warn!( + "Dropping encrypted message from {}: rate limited (sig verify budget)", + sender_id + ); + self.metrics.inc_rate_limit_drop(); + return Ok(None); + } + + let digest = mqtt_encrypted_message_signature_digest( + sender_id, + &topic, encrypted_blob, - signature, - ) { - Ok(v) => v, - Err(e) => { + ); + let is_valid = + match verify_falcon_auto(&sig_pk, &digest, signature) { + Ok(v) => v, + Err(e) => { + warn!( + "Signature verification error for {}: {}", + sender_id, e + ); + false + } + }; + + if is_valid { + // Asymmetric-cost DoS budget: KEM + AEAD decrypt work is expensive. + if !self.allow_decrypt(sender_id) { warn!( - "Signature verification error for {}: {}", - sender_id, e + "Dropping encrypted message from {}: rate limited (decrypt budget)", + sender_id ); - false + self.metrics.inc_rate_limit_drop(); + return Ok(None); } - }; - if is_valid { match self.provider.decrypt(encrypted_blob) { Ok(decrypted) => { // Extract Sequence Number (First 8 bytes). @@ -1224,26 +3119,35 @@ impl SecureMqttClient { seq_arr.copy_from_slice(seq_bytes); let seq = u64::from_be_bytes(seq_arr); - if seq > keys.last_sequence { - // Update KeyStore with new sequence - if let Some(keys_mut) = - self.keystore.get_mut(sender_id) - { - keys_mut.last_sequence = seq; + // Update per-peer replay window (bounded OOO support). + let mut accepted = false; + if let Some(keys_mut) = + self.keystore.get_mut(sender_id) + { + accepted = replay_window_accept( + &mut keys_mut.last_sequence, + &mut keys_mut.replay_window, + seq, + ); + if accepted { self.persist_manager.mark_dirty(); } + } + if accepted { + // Flush lazily (bounded by persist_manager policy). + let _ = self.maybe_flush_keystore(); return Ok(Some(( topic, actual_payload.to_vec(), ))); - } else { - warn!( - "Replay detected: Seq {} <= Last {}", - seq, keys.last_sequence - ); - self.metrics.inc_replay_attack(); } + + warn!( + "Replay detected from {}: seq={}", + sender_id, seq + ); + self.metrics.inc_replay_attack(); } else { warn!("Decrypted payload too short"); } @@ -1285,6 +3189,545 @@ impl SecureMqttClient { Ok(None) } + fn handle_session_init(&mut self, topic: &str, msg: SessionInitMessage) -> Result<()> { + if self.drop_if_fleet_policy_stale("mqtt session init")? { + return Ok(()); + } + if let Err(e) = self.ensure_storage_assurance("mqtt session init") { + warn!("Dropping session init on {}: {}", topic, e); + return Ok(()); + } + if let Err(e) = self.ensure_revocation_caught_up("mqtt session init") { + warn!("Dropping session init on {}: {}", topic, e); + return Ok(()); + } + + if msg.version != SessionInitMessage::VERSION_V1 { + warn!( + "Ignoring session init on {}: unsupported version {}", + topic, msg.version + ); + return Ok(()); + } + + if !is_valid_wire_peer_id(&msg.initiator_id) || !is_valid_wire_peer_id(&msg.responder_id) { + warn!("Ignoring session init: invalid peer ids"); + return Ok(()); + } + + if msg.responder_id != self.client_id { + warn!( + "Ignoring session init: responder_id mismatch {} != {}", + msg.responder_id, self.client_id + ); + return Ok(()); + } + + if msg.session_seq == 0 { + warn!( + "Ignoring session init from {}: invalid session_seq=0", + msg.initiator_id + ); + return Ok(()); + } + + let session_id = match vec_to_16(&msg.session_id) { + Ok(v) => v, + Err(e) => { + warn!("Ignoring session init from {}: {}", msg.initiator_id, e); + return Ok(()); + } + }; + + let initiator_x_pk = match vec_to_32(&msg.x25519_pk) { + Ok(v) => v, + Err(e) => { + warn!("Ignoring session init from {}: {}", msg.initiator_id, e); + return Ok(()); + } + }; + + // Only accept sessions from trusted peers. + let (peer_sig_pk, peer_key_id, peer_is_trusted) = match self.keystore.get(&msg.initiator_id) + { + Some(k) => (k.sig_pk.clone(), k.key_id.clone(), k.is_trusted), + None => { + warn!( + "Ignoring session init from {}: unknown peer (no keystore entry)", + msg.initiator_id + ); + return Ok(()); + } + }; + + if !peer_is_trusted { + warn!( + "Ignoring session init from {}: peer not trusted/ready", + msg.initiator_id + ); + return Ok(()); + } + + if let Some(key_id) = peer_key_id.as_deref() { + if self.keystore.is_key_id_revoked(&msg.initiator_id, key_id) { + warn!( + "Ignoring session init from {}: key_id revoked", + msg.initiator_id + ); + return Ok(()); + } + } + + let last_seq = self.last_inbound_session_seq(&msg.initiator_id)?; + if msg.session_seq < last_seq { + warn!( + "Ignoring session init from {}: session_seq rollback ({} < {})", + msg.initiator_id, msg.session_seq, last_seq + ); + self.metrics.inc_replay_attack(); + return Ok(()); + } + if msg.session_seq == last_seq { + // Idempotent retransmit: if we have a cached response for this (peer, seq, session_id), + // resend it without redoing expensive signature/KEM work. + if let Some(cached) = self.session_resp_cache.get(&msg.initiator_id) { + if cached.session_seq == msg.session_seq && cached.session_id == session_id { + let resp_topic = format!("{}{}", self.session_resp_prefix, msg.initiator_id); + if let Some(client) = &mut self.client { + client + .publish(resp_topic, QoS::AtLeastOnce, false, cached.bytes.clone()) + .map_err(|e| Error::MqttError(e.to_string()))?; + } + } + } + return Ok(()); + } + + if !self.allow_sig_verify(&msg.initiator_id) { + warn!( + "Dropping session init from {}: rate limited (sig verify budget)", + msg.initiator_id + ); + self.metrics.inc_rate_limit_drop(); + return Ok(()); + } + + let payload = session_init_payload_v1(&SessionInitSigInput { + topic, + session_id: &session_id, + session_seq: msg.session_seq, + initiator_id: &msg.initiator_id, + responder_id: &msg.responder_id, + kem_pk: &msg.kem_pk, + x25519_pk: &initiator_x_pk, + ts: msg.ts, + }); + + let sig_ok = match verify_falcon_auto(&peer_sig_pk, &payload, &msg.signature) { + Ok(v) => v, + Err(e) => { + warn!( + "Ignoring session init from {}: signature verify error: {}", + msg.initiator_id, e + ); + false + } + }; + if !sig_ok { + warn!( + "Ignoring session init from {}: invalid signature", + msg.initiator_id + ); + return Ok(()); + } + + // Asymmetric-cost DoS budget: session establishment performs KEM work. + if !self.allow_decrypt(&msg.initiator_id) { + warn!( + "Dropping session init from {}: rate limited (decrypt budget)", + msg.initiator_id + ); + self.metrics.inc_rate_limit_drop(); + return Ok(()); + } + + // Responder ephemeral X25519 key pair. + let responder_x_sk = X25519StaticSecret::random_from_rng(OsRng); + let responder_x_pk = X25519PublicKey::from(&responder_x_sk).to_bytes(); + + let peer_pub = X25519PublicKey::from(initiator_x_pk); + let dh_ss = responder_x_sk.diffie_hellman(&peer_pub).to_bytes(); + + let kyber = match kyber_for_pk_len(msg.kem_pk.len()) { + Ok(k) => k, + Err(e) => { + warn!("Ignoring session init from {}: {}", msg.initiator_id, e); + return Ok(()); + } + }; + let (kem_ct, kem_ss) = match kyber.encapsulate(&msg.kem_pk) { + Ok(v) => v, + Err(e) => { + warn!( + "Ignoring session init from {}: kyber encapsulate failed: {}", + msg.initiator_id, e + ); + return Ok(()); + } + }; + + let (ck_initiator, ck_responder) = match derive_session_chain_keys_v1(&kem_ss, &dh_ss) { + Ok(v) => v, + Err(e) => { + warn!( + "Ignoring session init from {}: session key derivation failed: {}", + msg.initiator_id, e + ); + return Ok(()); + } + }; + + // Responder uses ck_responder for sending, ck_initiator for receiving. + let session = MqttSession::new(session_id, ck_responder, ck_initiator); + + if let Some(existing) = self.sessions.get_mut(&msg.initiator_id) { + existing.rotate_to(session); + } else { + self.sessions + .insert(msg.initiator_id.clone(), PeerSessions::new(session)); + self.metrics.inc_active_sessions(); + } + + // Send response to the initiator. + let initiator_id = msg.initiator_id; + let resp_topic = format!("{}{}", self.session_resp_prefix, initiator_id); + let ts = self.secure_time.now_unix_s()?; + let payload = session_resp_payload_v1(&SessionRespSigInput { + topic: resp_topic.as_str(), + session_id: &session_id, + session_seq: msg.session_seq, + initiator_id: &initiator_id, + responder_id: &self.client_id, + x25519_pk: &responder_x_pk, + kem_ciphertext: &kem_ct, + ts, + }); + let signature = self.provider.sign(&payload)?; + let resp = SessionResponseMessage { + version: SessionResponseMessage::VERSION_V1, + initiator_id: initiator_id.clone(), + responder_id: self.client_id.clone(), + session_id: session_id.to_vec(), + session_seq: msg.session_seq, + x25519_pk: responder_x_pk.to_vec(), + kem_ciphertext: kem_ct, + ts, + signature, + }; + let bytes = serde_json::to_vec(&resp) + .map_err(|e| Error::ClientError(format!("SessionResponse JSON error: {}", e)))?; + + self.persist_inbound_session_seq(&initiator_id, msg.session_seq)?; + self.session_resp_cache.insert( + initiator_id.clone(), + CachedSessionResponse { + session_seq: msg.session_seq, + session_id, + bytes: bytes.clone(), + }, + ); + + if let Some(client) = &mut self.client { + client + .publish(resp_topic, QoS::AtLeastOnce, false, bytes) + .map_err(|e| Error::MqttError(e.to_string()))?; + } + Ok(()) + } + + fn handle_session_response(&mut self, topic: &str, msg: SessionResponseMessage) -> Result<()> { + if self.drop_if_fleet_policy_stale("mqtt session response")? { + return Ok(()); + } + if let Err(e) = self.ensure_storage_assurance("mqtt session response") { + warn!("Dropping session response on {}: {}", topic, e); + return Ok(()); + } + if let Err(e) = self.ensure_revocation_caught_up("mqtt session response") { + warn!("Dropping session response on {}: {}", topic, e); + return Ok(()); + } + + if msg.version != SessionResponseMessage::VERSION_V1 { + warn!( + "Ignoring session response on {}: unsupported version {}", + topic, msg.version + ); + return Ok(()); + } + + if msg.initiator_id != self.client_id { + warn!( + "Ignoring session response: initiator_id mismatch {} != {}", + msg.initiator_id, self.client_id + ); + return Ok(()); + } + + if msg.session_seq == 0 { + warn!( + "Ignoring session response from {}: invalid session_seq=0", + msg.responder_id + ); + return Ok(()); + } + + if !is_valid_wire_peer_id(&msg.responder_id) { + warn!("Ignoring session response: invalid responder_id"); + return Ok(()); + } + + let session_id = match vec_to_16(&msg.session_id) { + Ok(v) => v, + Err(e) => { + warn!("Ignoring session response from {}: {}", msg.responder_id, e); + return Ok(()); + } + }; + + let responder_x_pk = match vec_to_32(&msg.x25519_pk) { + Ok(v) => v, + Err(e) => { + warn!("Ignoring session response from {}: {}", msg.responder_id, e); + return Ok(()); + } + }; + + let pending = match self.pending_sessions.remove(&session_id) { + Some(p) => p, + None => { + warn!( + "Ignoring session response from {}: unknown session_id {}", + msg.responder_id, + hex::encode(session_id) + ); + return Ok(()); + } + }; + + if pending.peer_id != msg.responder_id { + warn!( + "Ignoring session response: peer mismatch pending={} responder_id={}", + pending.peer_id, msg.responder_id + ); + self.pending_sessions.insert(session_id, pending); + return Ok(()); + } + + if msg.session_seq != pending.session_seq { + warn!( + "Ignoring session response from {}: session_seq mismatch pending={} msg={}", + msg.responder_id, pending.session_seq, msg.session_seq + ); + self.pending_sessions.insert(session_id, pending); + return Ok(()); + } + + let (peer_sig_pk, peer_key_id, peer_is_trusted) = match self.keystore.get(&msg.responder_id) + { + Some(k) => (k.sig_pk.clone(), k.key_id.clone(), k.is_trusted), + None => { + warn!( + "Ignoring session response from {}: unknown peer (no keystore entry)", + msg.responder_id + ); + self.pending_sessions.insert(session_id, pending); + return Ok(()); + } + }; + if !peer_is_trusted { + warn!( + "Ignoring session response from {}: peer not trusted/ready", + msg.responder_id + ); + self.pending_sessions.insert(session_id, pending); + return Ok(()); + } + if let Some(key_id) = peer_key_id.as_deref() { + if self.keystore.is_key_id_revoked(&msg.responder_id, key_id) { + warn!( + "Ignoring session response from {}: key_id revoked", + msg.responder_id + ); + self.pending_sessions.insert(session_id, pending); + return Ok(()); + } + } + + if !self.allow_sig_verify(&msg.responder_id) { + warn!( + "Dropping session response from {}: rate limited (sig verify budget)", + msg.responder_id + ); + self.metrics.inc_rate_limit_drop(); + self.pending_sessions.insert(session_id, pending); + return Ok(()); + } + + let payload = session_resp_payload_v1(&SessionRespSigInput { + topic, + session_id: &session_id, + session_seq: msg.session_seq, + initiator_id: &msg.initiator_id, + responder_id: &msg.responder_id, + x25519_pk: &responder_x_pk, + kem_ciphertext: &msg.kem_ciphertext, + ts: msg.ts, + }); + let sig_ok = match verify_falcon_auto(&peer_sig_pk, &payload, &msg.signature) { + Ok(v) => v, + Err(e) => { + warn!( + "Ignoring session response from {}: signature verify error: {}", + msg.responder_id, e + ); + false + } + }; + if !sig_ok { + warn!( + "Ignoring session response from {}: invalid signature", + msg.responder_id + ); + self.pending_sessions.insert(session_id, pending); + return Ok(()); + } + + // KEM decapsulation is expensive: enforce decrypt budget here as well. + if !self.allow_decrypt(&msg.responder_id) { + warn!( + "Dropping session response from {}: rate limited (decrypt budget)", + msg.responder_id + ); + self.metrics.inc_rate_limit_drop(); + self.pending_sessions.insert(session_id, pending); + return Ok(()); + } + + let peer_pub = X25519PublicKey::from(responder_x_pk); + let dh_ss = pending.x25519_sk.diffie_hellman(&peer_pub).to_bytes(); + + let kyber = match kyber_for_sk_len(pending.kem_sk.len()) { + Ok(k) => k, + Err(e) => { + warn!("Ignoring session response from {}: {}", msg.responder_id, e); + self.pending_sessions.insert(session_id, pending); + return Ok(()); + } + }; + let kem_ss = match kyber.decapsulate(pending.kem_sk.as_slice(), &msg.kem_ciphertext) { + Ok(v) => v, + Err(e) => { + warn!( + "Ignoring session response from {}: kyber decapsulate failed: {}", + msg.responder_id, e + ); + self.pending_sessions.insert(session_id, pending); + return Ok(()); + } + }; + + let (ck_initiator, ck_responder) = match derive_session_chain_keys_v1(&kem_ss, &dh_ss) { + Ok(v) => v, + Err(e) => { + warn!( + "Ignoring session response from {}: session key derivation failed: {}", + msg.responder_id, e + ); + self.pending_sessions.insert(session_id, pending); + return Ok(()); + } + }; + + // Initiator uses ck_initiator for sending, ck_responder for receiving. + let session = MqttSession::new(session_id, ck_initiator, ck_responder); + if let Some(existing) = self.sessions.get_mut(&msg.responder_id) { + existing.rotate_to(session); + } else { + self.sessions + .insert(msg.responder_id.clone(), PeerSessions::new(session)); + self.metrics.inc_active_sessions(); + } + + Ok(()) + } + + fn try_decrypt_session_packet_v2( + &mut self, + topic: &str, + sender_id: &str, + rest: &[u8], + ) -> Result>> { + // [2][session_id:16][msg_num:u32][ct_len:u32][ct] + const HEADER_LEN: usize = 1 + 16 + 4 + 4; + if rest.len() < HEADER_LEN { + warn!( + "Dropping session packet from {}: too short ({} bytes)", + sender_id, + rest.len() + ); + return Ok(None); + } + + let mut session_id = [0u8; 16]; + session_id.copy_from_slice(&rest[1..17]); + let msg_num = u32::from_be_bytes([rest[17], rest[18], rest[19], rest[20]]); + let ct_len = u32::from_be_bytes([rest[21], rest[22], rest[23], rest[24]]) as usize; + + if rest.len() != HEADER_LEN + ct_len { + warn!( + "Dropping session packet from {}: length mismatch ct_len={} total={}", + sender_id, + ct_len, + rest.len() + ); + return Ok(None); + } + + let ciphertext = &rest[HEADER_LEN..]; + + let peer_sessions = match self.sessions.get_mut(sender_id) { + Some(s) => s, + None => { + warn!( + "Dropping session packet from {}: no active session", + sender_id + ); + return Ok(None); + } + }; + + let session = match peer_sessions.get_mut_by_session_id(&session_id) { + Some(s) => s, + None => { + warn!( + "Dropping session packet from {}: session_id mismatch", + sender_id + ); + return Ok(None); + } + }; + + match session.decrypt_v2(sender_id, &self.client_id, topic, msg_num, ciphertext) { + Ok(pt) => Ok(Some(pt)), + Err(e) => { + warn!("Session decrypt failed for {}: {}", sender_id, e); + self.metrics.inc_decryption_failure(); + Ok(None) + } + } + } + fn send_attestation_challenge(&mut self, peer_id: &str) -> Result<()> { if self.pending_attestation.contains_key(peer_id) { return Ok(()); @@ -1316,6 +3759,10 @@ impl SecureMqttClient { } fn handle_attestation_challenge(&mut self, challenge: AttestationChallenge) -> Result<()> { + if self.drop_if_fleet_policy_stale("attestation challenge")? { + return Ok(()); + } + // Generate quote bound to challenger nonce. let quote = self .provider @@ -1338,6 +3785,18 @@ impl SecureMqttClient { } fn handle_attestation_quote(&mut self, msg: AttestationQuoteMessage) -> Result<()> { + if self.drop_if_fleet_policy_stale("attestation quote")? { + return Ok(()); + } + if let Err(e) = self.ensure_storage_assurance("attestation quote") { + warn!("Dropping attestation quote: {}", e); + return Ok(()); + } + if let Err(e) = self.ensure_revocation_caught_up("attestation quote") { + warn!("Dropping attestation quote: {}", e); + return Ok(()); + } + let expected_nonce = match self.pending_attestation.get(&msg.subject_id) { Some(n) => n.clone(), None => { @@ -1369,6 +3828,15 @@ impl SecureMqttClient { return Ok(()); } + // Attestation verification is expensive; apply per-peer + global budgets. + if !self.allow_sig_verify(&msg.subject_id) { + warn!( + "Attestation quote for {} dropped: rate limited (sig verify budget)", + msg.subject_id + ); + self.metrics.inc_rate_limit_drop(); + return Ok(()); + } if let Err(e) = msg.quote.verify(&expected_nonce, &self.expected_pcr_digest) { warn!("Attestation quote rejected for {}: {}", msg.subject_id, e); return Ok(()); @@ -1387,12 +3855,125 @@ impl SecureMqttClient { Ok(()) } + fn handle_revocation_update(&mut self, topic: &str, update: RevocationUpdate) -> Result<()> { + let ca_pk = match &self.trust_anchor_ca_sig_pk { + Some(pk) => pk, + None => { + warn!( + "Ignoring revocation update on {}: missing trust_anchor_ca_sig_pk", + topic + ); + return Ok(()); + } + }; + + // Revocation verification is expensive and broker-controlled. Apply a global budget. + if !self.global_sig_verify_budget.allow() { + warn!( + "Dropping revocation update on {}: rate limited (global sig verify budget)", + topic + ); + self.metrics.inc_rate_limit_drop(); + return Ok(()); + } + + if let Err(e) = update.verify(ca_pk, topic) { + warn!("Revocation update rejected: {}", e); + return Ok(()); + } + + let current_seq = self.keystore.revocation_seq(); + if update.seq <= current_seq { + debug!( + "Ignoring revocation update: seq={} <= current_seq={}", + update.seq, current_seq + ); + return Ok(()); + } + + for entry in &update.entries { + self.keystore + .revoke_key_id(entry.device_id.as_str(), entry.key_id.as_slice()); + if let Some(peer) = self.keystore.get_mut(entry.device_id.as_str()) { + if peer.key_id.as_deref() == Some(entry.key_id.as_slice()) { + peer.is_trusted = false; + } + } + } + + self.keystore.set_revocation_seq(update.seq); + + // Persist immediately: revocations are emergency policy updates and must survive restarts. + self.flush_keystore()?; + self.persist_manager.notify_flushed(); + + Ok(()) + } + + fn handle_fleet_policy_update(&mut self, topic: &str, update: FleetPolicyUpdate) -> Result<()> { + let ca_pk = match &self.trust_anchor_ca_sig_pk { + Some(pk) => pk, + None => { + warn!( + "Ignoring fleet policy update on {}: missing trust_anchor_ca_sig_pk", + topic + ); + return Ok(()); + } + }; + + // Policy verification is expensive and broker-controlled. Apply a global budget. + if !self.global_sig_verify_budget.allow() { + warn!( + "Dropping fleet policy update on {}: rate limited (global sig verify budget)", + topic + ); + self.metrics.inc_rate_limit_drop(); + return Ok(()); + } + + if let Err(e) = update.verify(ca_pk, topic) { + warn!("Fleet policy update rejected: {}", e); + return Ok(()); + } + + let current_seq = self.fleet_policy.as_ref().map(|p| p.seq).unwrap_or(0); + if update.seq <= current_seq { + debug!( + "Ignoring fleet policy update: seq={} <= current_seq={}", + update.seq, current_seq + ); + return Ok(()); + } + + // Persist before applying so a crash after apply doesn't revert to an older policy. + self.seal_fleet_policy(&update)?; + self.apply_fleet_policy(update); + + Ok(()) + } + /// Handle Key Exchange messages (Identity Verification) fn handle_key_exchange(&mut self, sender_id: &str, mut keys: PeerKeys) -> Result<()> { + if self.drop_if_fleet_policy_stale("mqtt key announcement")? { + return Ok(()); + } + if let Err(e) = self.ensure_storage_assurance("mqtt key announcement") { + warn!("Dropping key announcement from {}: {}", sender_id, e); + return Ok(()); + } + if let Err(e) = self.ensure_revocation_caught_up("mqtt key announcement") { + warn!("Dropping key announcement from {}: {}", sender_id, e); + return Ok(()); + } + // Trust is local policy; never accept remote claims via key announcements. keys.is_trusted = false; // Attestation artifacts must only be accepted via the explicit attestation flow. keys.quote = None; + // Replay state is local-only. Never accept remote-provided sequencing/window state. + keys.last_sequence = 0; + keys.replay_window = 0; // Detached signature is mandatory (older clients are rejected). let signature = match &keys.key_signature { @@ -1411,7 +3992,7 @@ impl SecureMqttClient { // - strict_mode: require provisioning-backed OperationalCertificate (no TOFU) // - non-strict: apply TOFU semantics but pin `sig_pk` after first contact (prevents broker key rewrites) if let Some(cert) = &keys.operational_cert { - let ca_pk = match &self.trust_anchor_ca_sig_pk { + let ca_pk = match self.trust_anchor_ca_sig_pk.clone() { Some(pk) => pk, None => { error!( @@ -1424,11 +4005,16 @@ impl SecureMqttClient { }; // Validate cert now (time window + signature + internal consistency). - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - if let Err(e) = cert.verify(ca_pk, Some(now)) { + let now = self.secure_time.now_unix_s()?; + if !self.allow_sig_verify(sender_id) { + warn!( + "Key exchange from {} dropped: rate limited (cert verify budget)", + sender_id + ); + self.metrics.inc_rate_limit_drop(); + return Ok(()); + } + if let Err(e) = cert.verify(&ca_pk, Some(now)) { warn!( "Key exchange from {} rejected: invalid operational_cert: {}", sender_id, e @@ -1477,6 +4063,14 @@ impl SecureMqttClient { // Announcement signature check (proof-of-possession of the certified signing key). let payload = key_announcement_payload(sender_id, &keys); + if !self.allow_sig_verify(sender_id) { + warn!( + "Key exchange from {} dropped: rate limited (announcement verify budget)", + sender_id + ); + self.metrics.inc_rate_limit_drop(); + return Ok(()); + } let is_valid = verify_falcon_auto(&cert.sig_pk, &payload, signature)?; if !is_valid { warn!( @@ -1509,13 +4103,20 @@ impl SecureMqttClient { } } + if !self.allow_sig_verify(sender_id) { + warn!( + "Key exchange from {} dropped: rate limited (announcement verify budget)", + sender_id + ); + self.metrics.inc_rate_limit_drop(); + return Ok(()); + } let verify_pk = match self.keystore.get(sender_id) { - Some(existing) if !existing.sig_pk.is_empty() => existing.sig_pk.as_slice(), - _ => keys.sig_pk.as_slice(), + Some(existing) if !existing.sig_pk.is_empty() => existing.sig_pk.clone(), + _ => keys.sig_pk.clone(), }; - let payload = key_announcement_payload(sender_id, &keys); - let is_valid = verify_falcon_auto(verify_pk, &payload, signature)?; + let is_valid = verify_falcon_auto(&verify_pk, &payload, signature)?; if !is_valid { warn!( "Key exchange from {} rejected: invalid signature", @@ -1553,9 +4154,11 @@ impl SecureMqttClient { // Preserve replay window. keys.last_sequence = existing.last_sequence; + keys.replay_window = existing.replay_window; } else { // New epoch => new session; reset replay window. keys.last_sequence = 0; + keys.replay_window = 0; } } diff --git a/src/security/audit.rs b/src/security/audit.rs index bf6abc5..dce19f8 100644 --- a/src/security/audit.rs +++ b/src/security/audit.rs @@ -158,8 +158,11 @@ use sha2::{Digest, Sha256}; use std::fs::OpenOptions; use std::io::Write; use std::path::PathBuf; +use std::sync::Arc; use std::sync::Mutex; +use crate::security::provider::SecurityProvider; + /// Cryptographically Chained Log Entry (Tamper-Evident). /// Forms a hash chain where `hash_n = SHA256(hash_{n-1} + entry_n)`. #[derive(Debug, Serialize, Deserialize)] // Added Deserialize @@ -170,6 +173,10 @@ pub struct ChainedLogEntry { pub entry: AuditLog, /// The hash of this entry (including prev_hash). pub hash: String, // SHA256(prev_hash + json(entry)) + /// Optional device signature over this entry's chain hash (for tamper evidence without trusting filesystem). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde(with = "crate::security::keystore::base64_serde_opt")] + pub signature: Option>, } /// Audit Logger that enforces specific ordering and integrity via Hash Chaining. @@ -177,21 +184,34 @@ pub struct ChainedLogEntry { pub struct ChainedAuditLogger { file_path: PathBuf, last_hash: Mutex, + signer: Option>, } impl ChainedAuditLogger { /// Initialize the Chained Logger, recovering the last hash from disk if available. pub fn new(data_dir: &std::path::Path) -> Self { + Self::new_inner(data_dir, None) + } + + /// Initialize the Chained Logger with a signer. + /// + /// When a signer is provided (TPM/HSM-backed in production), each entry is signed, making + /// offline log rewriting detectable without trusting filesystem permissions. + pub fn new_signed(data_dir: &std::path::Path, signer: Arc) -> Self { + Self::new_inner(data_dir, Some(signer)) + } + + fn new_inner(data_dir: &std::path::Path, signer: Option>) -> Self { let file_path = data_dir.join("audit.log"); - // Recover last hash from file if exists, else generic genesis hash - // Efficiently read from end of file + // Recover last hash from file if exists, else generic genesis hash. + // Efficiently read from end of file. let last_hash = if file_path.exists() { use std::io::{Read, Seek, SeekFrom}; if let Ok(mut file) = std::fs::File::open(&file_path) { if let Ok(len) = file.metadata().map(|m| m.len()) { if len > 0 { - // Seek to end - 1024 bytes (heuristic for last line) or less + // Seek to end - 4096 bytes (heuristic for last line) or less let seek_back = std::cmp::min(len, 4096); if file.seek(SeekFrom::End(-(seek_back as i64))).is_ok() { let mut buffer = std::vec::Vec::new(); @@ -231,6 +251,7 @@ impl ChainedAuditLogger { Self { file_path, last_hash: Mutex::new(last_hash), + signer, } } } @@ -261,13 +282,33 @@ impl AuditLogger for ChainedAuditLogger { let mut hasher = Sha256::new(); hasher.update(last_hash_guard.as_bytes()); hasher.update(entry_json.as_bytes()); - let new_hash = hex::encode(hasher.finalize()); + let chain_hash_bytes: [u8; 32] = hasher.finalize().into(); + let new_hash = hex::encode(chain_hash_bytes); + + // Optional signature for tamper evidence without trusting filesystem permissions. + // This is only meaningful when the signer is backed by non-exportable keys (TPM/HSM). + let signature = if let Some(signer) = &self.signer { + let mut sig_hasher = Sha256::new(); + sig_hasher.update(b"pqc-iiot:audit-sig:v1"); + sig_hasher.update(chain_hash_bytes); + let sig_digest = sig_hasher.finalize(); + match signer.sign(sig_digest.as_slice()) { + Ok(sig) => Some(sig), + Err(e) => { + error!("CRITICAL AUDIT FAILURE: audit entry signing failed: {}", e); + None + } + } + } else { + None + }; // 3. Create Chained Entry let chained = ChainedLogEntry { prev_hash: last_hash_guard.clone(), entry, hash: new_hash.clone(), + signature, }; // 4. Atomic Write to Disk (Append) diff --git a/src/security/keystore.rs b/src/security/keystore.rs index 14f48dc..24f3b67 100644 --- a/src/security/keystore.rs +++ b/src/security/keystore.rs @@ -35,6 +35,12 @@ pub struct PeerKeys { /// Last seen sequence number for replay protection #[serde(default)] // Default to 0 for compatibility pub last_sequence: u64, + /// Sliding replay window bitmap relative to `last_sequence`. + /// + /// Bit `i` corresponds to sequence `last_sequence - i` for `i in 0..64`. + /// This enables bounded out-of-order delivery while still rejecting replays. + #[serde(default)] + pub replay_window: u64, /// Whether this peer is trusted (Identity Verified) #[serde(default)] // Default to false pub is_trusted: bool, @@ -52,10 +58,20 @@ pub struct PeerKeys { pub struct KeyStore { // Map client_id -> PeerKeys keys: std::collections::HashMap, + /// Monotonic generation counter used for rollback detection. + /// + /// This value is persisted inside the keystore file and is expected to match a sealed counter + /// stored behind `SecurityProvider::seal_data`. In deployments with a TPM/HSM-backed provider, + /// this becomes a hard anti-rollback primitive for replay state and revocation state. + #[serde(default)] + generation: u64, /// Local revocation list: peer_id -> key_ids (opaque 16-byte identifiers). /// Used to prevent rollback to compromised identities and to support emergency revocation. #[serde(default)] revoked: std::collections::HashMap>>, + /// Monotonic revocation sequence applied to this keystore (anti-rollback/replay for CRL updates). + #[serde(default)] + revocation_seq: u64, } impl Default for KeyStore { @@ -69,10 +85,28 @@ impl KeyStore { pub fn new() -> Self { Self { keys: std::collections::HashMap::new(), + generation: 0, revoked: std::collections::HashMap::new(), + revocation_seq: 0, } } + /// Current persisted generation counter. + pub fn generation(&self) -> u64 { + self.generation + } + + /// Set the persisted generation counter. + pub fn set_generation(&mut self, generation: u64) { + self.generation = generation; + } + + /// Bump the persisted generation counter, saturating on overflow. + pub fn bump_generation(&mut self) -> u64 { + self.generation = self.generation.saturating_add(1); + self.generation + } + /// Insert a peer into the store. pub fn insert(&mut self, client_id: &str, keys: PeerKeys) { self.keys.insert(client_id.to_string(), keys); @@ -109,6 +143,16 @@ impl KeyStore { .unwrap_or(false) } + /// Current monotonic revocation sequence applied to this keystore. + pub fn revocation_seq(&self) -> u64 { + self.revocation_seq + } + + /// Set the revocation sequence applied to this keystore. + pub fn set_revocation_seq(&mut self, seq: u64) { + self.revocation_seq = seq; + } + /// Save the keystore to a file (JSON) #[cfg(feature = "std")] pub fn save_to_file(&self, path: &str) -> crate::Result<()> { diff --git a/src/security/metrics.rs b/src/security/metrics.rs index cbdc83c..87ba4de 100644 --- a/src/security/metrics.rs +++ b/src/security/metrics.rs @@ -81,6 +81,11 @@ impl SecurityMetrics { self.replay_attacks_detected.fetch_add(1, Ordering::Relaxed); } + /// Increment rate-limit drops. + pub fn inc_rate_limit_drop(&self) { + self.rate_limit_drops.fetch_add(1, Ordering::Relaxed); + } + /// Increment MQTT RX queue drops (bounded channel full). pub fn inc_mqtt_rx_queue_drop(&self) { self.mqtt_rx_queue_drops.fetch_add(1, Ordering::Relaxed); diff --git a/src/security/mod.rs b/src/security/mod.rs index 628fd02..2edfc52 100644 --- a/src/security/mod.rs +++ b/src/security/mod.rs @@ -8,8 +8,18 @@ pub mod hybrid; pub mod keystore; /// Anomaly Detection Counters and Metrics. pub mod metrics; +/// Sealed monotonic counters and helpers. +#[cfg(feature = "std")] +pub mod monotonic; +/// Signed fleet policy updates (CA-distributed). +pub mod policy; /// Abstraction for security providers (Hardware/Software) pub mod provider; +/// Signed revocation updates (CA-distributed). +pub mod revocation; +/// Secure time / monotonic floor helpers (best-effort without TPM/HSM). +#[cfg(feature = "std")] +pub mod time; /// TPM 2.0 implementation (Software-backed for Linux/Gateway) #[cfg(feature = "std")] pub mod tpm; diff --git a/src/security/monotonic.rs b/src/security/monotonic.rs new file mode 100644 index 0000000..26e85d0 --- /dev/null +++ b/src/security/monotonic.rs @@ -0,0 +1,93 @@ +use crate::security::provider::SecurityProvider; +use crate::{Error, Result}; +use std::sync::Arc; + +/// Unseal a `u64` value from the `SecurityProvider` under `label`. +/// +/// Returns `Ok(None)` when the sealed blob does not exist. +pub fn unseal_u64(provider: &Arc, label: &str) -> Result> { + match provider.unseal_data(label) { + Ok(blob) => { + if blob.len() != 8 { + return Err(Error::CryptoError(format!( + "Invalid sealed u64 length for {}: {}", + label, + blob.len() + ))); + } + let mut buf = [0u8; 8]; + buf.copy_from_slice(&blob); + Ok(Some(u64::from_be_bytes(buf))) + } + Err(Error::IoError(e)) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e), + } +} + +/// Seal a `u64` value behind the `SecurityProvider` under `label`. +pub fn seal_u64(provider: &Arc, label: &str, value: u64) -> Result<()> { + provider.seal_data(label, &value.to_be_bytes()) +} + +/// A monotonic u64 counter persisted behind `SecurityProvider::seal_data`. +/// +/// Security notes: +/// - This counter is only rollback-resistant when `SecurityProvider::is_rollback_resistant_storage() == true`. +/// Otherwise, an attacker with filesystem write access can restore an older sealed blob. +/// - Persist on every update. For embedded flash, callers should tune usage (batching counters at a higher layer). +pub struct SealedMonotonicU64 { + provider: Arc, + label: String, + value: u64, +} + +impl SealedMonotonicU64 { + /// Load a counter from sealed storage or initialize it to `initial` and seal it immediately. + pub fn load( + provider: Arc, + label: impl Into, + initial: u64, + ) -> Result { + let label = label.into(); + let persisted = unseal_u64(&provider, &label)?; + let value = persisted.unwrap_or(initial); + if persisted.is_none() { + seal_u64(&provider, &label, value)?; + } + Ok(Self { + provider, + label, + value, + }) + } + + /// Return the current value (last persisted). + pub fn current(&self) -> u64 { + self.value + } + + /// Persist a new value if it strictly advances the counter. + /// + /// Returns `Ok(true)` when the counter advanced, `Ok(false)` otherwise. + pub fn advance_to(&mut self, candidate: u64) -> Result { + if candidate > self.value { + self.value = candidate; + seal_u64(&self.provider, &self.label, self.value)?; + return Ok(true); + } + Ok(false) + } + + /// Increment the counter by 1, persist, and return the new value. + pub fn increment(&mut self) -> Result { + let next = self.value.saturating_add(1).max(1); + self.value = next; + seal_u64(&self.provider, &self.label, self.value)?; + Ok(self.value) + } + + /// Return the backing seal label (observability/debug only). + pub fn label(&self) -> &str { + &self.label + } +} diff --git a/src/security/policy.rs b/src/security/policy.rs new file mode 100644 index 0000000..87ff243 --- /dev/null +++ b/src/security/policy.rs @@ -0,0 +1,284 @@ +use alloc::vec::Vec; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::crypto::traits::PqcSignature; +use crate::{Error, Falcon, FalconSecurityLevel, Result}; + +/// Per-peer crypto budget parameters (token bucket). +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct BudgetParams { + /// Per-peer token bucket capacity. + pub per_peer_capacity: u32, + /// Per-peer token bucket refill rate (tokens per second). + pub per_peer_refill_per_sec: u32, + /// Global token bucket capacity (shared across all peers). + pub global_capacity: u32, + /// Global token bucket refill rate (tokens per second). + pub global_refill_per_sec: u32, +} + +/// Fleet-wide security policy update, signed by the mesh CA. +/// +/// Threat model: +/// - Delivered over an attacker-controlled broker/network; must be verified before applying. +/// - Consumers must enforce `seq` monotonicity to prevent rollback/replay. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct FleetPolicyUpdate { + /// Schema version for forward compatibility. + pub version: u8, + /// Monotonic sequence number for anti-rollback and replay protection. + pub seq: u64, + /// Issuance timestamp (unix seconds). Informational unless the verifier has a trusted time source. + pub issued_at: u64, + /// If true, require that the `SecurityProvider` backend is rollback-resistant. + /// + /// This is a hard safety gate for critical fleets: without rollback-resistant sealing (TPM NV, + /// HSM, TEE monotonic storage, or a remote append-only service), time windows and replay-state + /// persistence can be rolled back under filesystem compromise. + #[serde(default)] + pub require_rollback_resistant_storage: bool, + /// If true, reject key announcements without an OperationalCertificate (no TOFU). + pub strict_mode: bool, + /// If true, peers are marked trusted only after a verifier-driven attestation roundtrip. + pub attestation_required: bool, + /// If true, disallow v1 per-message KEM/signature encryption and require session/ratchet (v2) before sending. + pub require_sessions: bool, + /// Optional minimum revocation sequence that must be applied before accepting trust transitions. + /// + /// If set, clients should fail closed until they have processed revocation updates up to this `seq`. + /// This is a partition-resilience mechanism: it prevents accepting fresh identities while missing + /// emergency revocations. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub min_revocation_seq: Option, + /// Optional crypto DoS budget overrides. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sig_verify_budget: Option, + /// Optional decryption/KEM DoS budget overrides. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub decrypt_budget: Option, + /// Optional policy TTL in seconds. If set and secure time is available, new handshakes should fail closed when stale. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ttl_secs: Option, + /// Optional session rekey threshold (messages sent) to enforce periodic re-handshake. + /// + /// This is not a DH ratchet, but it is a practical PCS building block for fleets that can + /// tolerate handshake churn. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_rekey_after_msgs: Option, + /// Optional session rekey threshold (seconds since session establishment). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_rekey_after_secs: Option, + /// Detached Falcon signature by the CA over `payload_v1`. + #[serde(with = "crate::security::keystore::base64_serde")] + pub signature: Vec, +} + +impl FleetPolicyUpdate { + /// Current fleet policy schema version. + pub const VERSION_V1: u8 = 1; + /// Extended fleet policy schema version (adds storage/time/PCS gates). + pub const VERSION_V2: u8 = 2; + + fn payload(&self, topic: &str) -> Result> { + match self.version { + Self::VERSION_V1 => { + let mut buf = Vec::new(); + buf.extend_from_slice(b"pqc-iiot:fleet-policy:v1"); + buf.extend_from_slice(&(topic.len() as u16).to_be_bytes()); + buf.extend_from_slice(topic.as_bytes()); + buf.push(self.version); + buf.extend_from_slice(&self.seq.to_be_bytes()); + buf.extend_from_slice(&self.issued_at.to_be_bytes()); + buf.push(self.strict_mode as u8); + buf.push(self.attestation_required as u8); + buf.push(self.require_sessions as u8); + + match &self.sig_verify_budget { + Some(b) => { + buf.push(1); + buf.extend_from_slice(&b.per_peer_capacity.to_be_bytes()); + buf.extend_from_slice(&b.per_peer_refill_per_sec.to_be_bytes()); + buf.extend_from_slice(&b.global_capacity.to_be_bytes()); + buf.extend_from_slice(&b.global_refill_per_sec.to_be_bytes()); + } + None => buf.push(0), + } + match &self.decrypt_budget { + Some(b) => { + buf.push(1); + buf.extend_from_slice(&b.per_peer_capacity.to_be_bytes()); + buf.extend_from_slice(&b.per_peer_refill_per_sec.to_be_bytes()); + buf.extend_from_slice(&b.global_capacity.to_be_bytes()); + buf.extend_from_slice(&b.global_refill_per_sec.to_be_bytes()); + } + None => buf.push(0), + } + + match self.ttl_secs { + Some(ttl) => { + buf.push(1); + buf.extend_from_slice(&ttl.to_be_bytes()); + } + None => buf.push(0), + } + Ok(buf) + } + Self::VERSION_V2 => { + let mut buf = Vec::new(); + buf.extend_from_slice(b"pqc-iiot:fleet-policy:v2"); + buf.extend_from_slice(&(topic.len() as u16).to_be_bytes()); + buf.extend_from_slice(topic.as_bytes()); + buf.push(self.version); + buf.extend_from_slice(&self.seq.to_be_bytes()); + buf.extend_from_slice(&self.issued_at.to_be_bytes()); + buf.push(self.require_rollback_resistant_storage as u8); + buf.push(self.strict_mode as u8); + buf.push(self.attestation_required as u8); + buf.push(self.require_sessions as u8); + + match self.min_revocation_seq { + Some(seq) => { + buf.push(1); + buf.extend_from_slice(&seq.to_be_bytes()); + } + None => buf.push(0), + } + + match &self.sig_verify_budget { + Some(b) => { + buf.push(1); + buf.extend_from_slice(&b.per_peer_capacity.to_be_bytes()); + buf.extend_from_slice(&b.per_peer_refill_per_sec.to_be_bytes()); + buf.extend_from_slice(&b.global_capacity.to_be_bytes()); + buf.extend_from_slice(&b.global_refill_per_sec.to_be_bytes()); + } + None => buf.push(0), + } + match &self.decrypt_budget { + Some(b) => { + buf.push(1); + buf.extend_from_slice(&b.per_peer_capacity.to_be_bytes()); + buf.extend_from_slice(&b.per_peer_refill_per_sec.to_be_bytes()); + buf.extend_from_slice(&b.global_capacity.to_be_bytes()); + buf.extend_from_slice(&b.global_refill_per_sec.to_be_bytes()); + } + None => buf.push(0), + } + + match self.ttl_secs { + Some(ttl) => { + buf.push(1); + buf.extend_from_slice(&ttl.to_be_bytes()); + } + None => buf.push(0), + } + + match self.session_rekey_after_msgs { + Some(n) => { + buf.push(1); + buf.extend_from_slice(&n.to_be_bytes()); + } + None => buf.push(0), + } + + match self.session_rekey_after_secs { + Some(n) => { + buf.push(1); + buf.extend_from_slice(&n.to_be_bytes()); + } + None => buf.push(0), + } + + Ok(buf) + } + v => Err(Error::ProtocolError(format!( + "Unsupported fleet policy version: {}", + v + ))), + } + } + + /// Sign this update with the CA secret key for a specific topic scope. + pub fn sign(&mut self, ca_sig_sk: &[u8], topic: &str) -> Result<()> { + if self.version == Self::VERSION_V1 + && (self.require_rollback_resistant_storage + || self.min_revocation_seq.is_some() + || self.session_rekey_after_msgs.is_some() + || self.session_rekey_after_secs.is_some()) + { + return Err(Error::ProtocolError( + "FleetPolicyUpdate v1 must not set v2-only fields".into(), + )); + } + let payload = self.payload(topic)?; + let signature = falcon_sign_auto(ca_sig_sk, &payload)?; + self.signature = signature; + Ok(()) + } + + /// Verify this update against the pinned CA public key and topic scope. + pub fn verify(&self, ca_sig_pk: &[u8], topic: &str) -> Result<()> { + let payload = self.payload(topic)?; + let ok = falcon_verify_auto(ca_sig_pk, &payload, &self.signature)?; + if !ok { + return Err(Error::SignatureVerification( + "FleetPolicyUpdate signature invalid".into(), + )); + } + if self.version == Self::VERSION_V1 + && (self.require_rollback_resistant_storage + || self.min_revocation_seq.is_some() + || self.session_rekey_after_msgs.is_some() + || self.session_rekey_after_secs.is_some()) + { + return Err(Error::ProtocolError( + "FleetPolicyUpdate v1 must not set v2-only fields".into(), + )); + } + Ok(()) + } + + /// Stable 128-bit identifier for observability/deduplication. + pub fn stable_id(&self) -> [u8; 16] { + let mut hasher = Sha256::new(); + hasher.update(b"pqc-iiot:fleet-policy-id:v1"); + hasher.update(self.version.to_be_bytes()); + hasher.update(self.seq.to_be_bytes()); + hasher.update(self.issued_at.to_be_bytes()); + let digest = hasher.finalize(); + let mut out = [0u8; 16]; + out.copy_from_slice(&digest[..16]); + out + } +} + +fn falcon_verify_auto(pk: &[u8], msg: &[u8], sig: &[u8]) -> Result { + let level = match pk.len() { + 897 => FalconSecurityLevel::Falcon512, + 1793 => FalconSecurityLevel::Falcon1024, + _ => { + return Err(Error::InvalidInput(format!( + "Invalid Falcon public key length: {}", + pk.len() + ))) + } + }; + let falcon = Falcon::new_with_level(level); + falcon.verify(pk, msg, sig) +} + +fn falcon_sign_auto(sk: &[u8], msg: &[u8]) -> Result> { + let level = match sk.len() { + 1281 => FalconSecurityLevel::Falcon512, + 2305 => FalconSecurityLevel::Falcon1024, + _ => { + return Err(Error::InvalidInput(format!( + "Invalid Falcon secret key length: {}", + sk.len() + ))) + } + }; + let falcon = Falcon::new_with_level(level); + falcon.sign(sk, msg) +} diff --git a/src/security/provider.rs b/src/security/provider.rs index c0f3c0c..09c54d4 100644 --- a/src/security/provider.rs +++ b/src/security/provider.rs @@ -10,12 +10,28 @@ use pqcrypto_falcon::falcon512::{ use pqcrypto_traits::sign::{DetachedSignature, SecretKey as _}; use rand_core::{OsRng, RngCore}; use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; -fn sealed_blob_path(label: &str) -> std::path::PathBuf { +fn ensure_pqc_data_dir() -> Result<()> { + let dir = Path::new("pqc-data"); + if !dir.exists() { + std::fs::create_dir_all(dir).map_err(Error::IoError)?; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Err(e) = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)) { + return Err(Error::IoError(e)); + } + } + Ok(()) +} + +fn sealed_blob_path(label: &str) -> PathBuf { // Never use `label` as a path fragment directly: it may carry path separators and trigger // traversal/overwrite. Hash it into a stable, filesystem-safe name. let digest = Sha256::digest(label.as_bytes()); - std::path::PathBuf::from(format!("pqc_sealed_{}.bin", hex::encode(digest))) + Path::new("pqc-data").join(format!("sealed_{}.bin", hex::encode(digest))) } /// Trait for abstracting cryptographic operations. @@ -52,6 +68,35 @@ pub trait SecurityProvider: Send + Sync { /// Returns None for hardware providers (TPM/HSM). fn export_secret_keys(&self) -> Option; + /// Human-readable provider kind for observability. + /// + /// This is intended for logging/telemetry, not for security decisions. + fn provider_kind(&self) -> &'static str { + "unknown" + } + + /// Whether the provider's sealing backend is rollback-resistant. + /// + /// Security meaning: + /// - `true`: `seal_data/unseal_data` are backed by a primitive that an attacker with filesystem + /// write access cannot roll back (e.g., TPM NV, TEE monotonic counter + sealed storage, WORM + /// remote append-only service). + /// - `false`: persistence is best-effort and can be rolled back by restoring old blobs. + /// + /// This flag is used to make threat-model assumptions explicit. It does not imply secrecy + /// (confidentiality) by itself. + fn is_rollback_resistant_storage(&self) -> bool { + false + } + + /// Whether long-term identity secrets are non-exportable. + /// + /// For software providers, secret keys may still be exfiltrated by a host compromise; this + /// function only indicates whether the provider is willing to export them via the API. + fn is_identity_non_exportable(&self) -> bool { + self.export_secret_keys().is_none() + } + /// Seal data to persistent storage (e.g. encrypted with Root Key if available). fn seal_data(&self, label: &str, data: &[u8]) -> Result<()>; @@ -223,7 +268,12 @@ impl SecurityProvider for SoftwareSecurityProvider { } } + fn provider_kind(&self) -> &'static str { + "software" + } + fn seal_data(&self, label: &str, data: &[u8]) -> Result<()> { + ensure_pqc_data_dir()?; let path = sealed_blob_path(label); let key = self.sealing_key(label); let cipher = Aes256Gcm::new(&key); @@ -246,12 +296,20 @@ impl SecurityProvider for SoftwareSecurityProvider { fn unseal_data(&self, label: &str) -> Result> { const MAX_SEALED_BYTES: usize = 1024 * 1024; // 1 MiB anti-OOM guardrail + // If the directory does not exist, behave as if the blob is missing. + if !Path::new("pqc-data").exists() { + return Err(crate::Error::IoError(std::io::Error::new( + std::io::ErrorKind::NotFound, + "pqc-data missing", + ))); + } + let path = sealed_blob_path(label); let blob = match crate::persistence::AtomicFileStore::read_with_limit(&path, MAX_SEALED_BYTES) { Ok(b) => b, Err(crate::Error::IoError(e)) if e.kind() == std::io::ErrorKind::NotFound => { - return Err(crate::Error::CryptoError("Sealed data not found".into())); + return Err(crate::Error::IoError(e)); } Err(e) => return Err(e), }; diff --git a/src/security/revocation.rs b/src/security/revocation.rs new file mode 100644 index 0000000..a652905 --- /dev/null +++ b/src/security/revocation.rs @@ -0,0 +1,136 @@ +use alloc::string::String; +use alloc::vec::Vec; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::crypto::traits::PqcSignature; +use crate::{Error, Falcon, FalconSecurityLevel, Result}; + +/// A single revocation entry targeting a specific certified `key_id` for a device. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct RevocationEntry { + /// Device identifier (must match the OperationalCertificate `device_id` / MQTT peer_id). + pub device_id: String, + /// Revoked key identifier (OperationalCertificate `key_id`, 16 bytes). + #[serde(with = "crate::security::keystore::base64_serde")] + pub key_id: Vec, +} + +/// A CA-signed revocation update message (CRL-like). +/// +/// Security model: +/// - The update is authenticated by a detached Falcon signature from the mesh CA. +/// - Consumers must enforce `seq` monotonicity to prevent rollback/replay. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct RevocationUpdate { + /// Schema version for forward compatibility. + pub version: u8, + /// Monotonic sequence number for anti-rollback and replay protection. + pub seq: u64, + /// Issuance timestamp (unix seconds). Informational unless the verifier has a trusted time source. + pub issued_at: u64, + /// Revocation entries. + pub entries: Vec, + /// Detached Falcon signature by the CA over `payload_v1`. + #[serde(with = "crate::security::keystore::base64_serde")] + pub signature: Vec, +} + +impl RevocationUpdate { + /// Current revocation update schema version. + pub const VERSION_V1: u8 = 1; + + fn payload_v1(&self, topic: &str) -> Result> { + if self.version != Self::VERSION_V1 { + return Err(Error::ProtocolError(format!( + "Unsupported revocation version: {}", + self.version + ))); + } + + let mut buf = Vec::new(); + buf.extend_from_slice(b"pqc-iiot:revocation:v1"); + buf.extend_from_slice(&(topic.len() as u16).to_be_bytes()); + buf.extend_from_slice(topic.as_bytes()); + buf.push(self.version); + buf.extend_from_slice(&self.seq.to_be_bytes()); + buf.extend_from_slice(&self.issued_at.to_be_bytes()); + buf.extend_from_slice(&(self.entries.len() as u16).to_be_bytes()); + for entry in &self.entries { + if entry.key_id.len() != 16 { + return Err(Error::InvalidInput(format!( + "Invalid revocation key_id length: {}", + entry.key_id.len() + ))); + } + buf.extend_from_slice(&(entry.device_id.len() as u16).to_be_bytes()); + buf.extend_from_slice(entry.device_id.as_bytes()); + buf.extend_from_slice(&(entry.key_id.len() as u16).to_be_bytes()); + buf.extend_from_slice(&entry.key_id); + } + Ok(buf) + } + + /// Sign this update with the CA secret key for a specific MQTT topic scope. + pub fn sign(&mut self, ca_sig_sk: &[u8], topic: &str) -> Result<()> { + let payload = self.payload_v1(topic)?; + let signature = falcon_sign_auto(ca_sig_sk, &payload)?; + self.signature = signature; + Ok(()) + } + + /// Verify this update against the pinned CA public key and topic scope. + pub fn verify(&self, ca_sig_pk: &[u8], topic: &str) -> Result<()> { + let payload = self.payload_v1(topic)?; + let ok = falcon_verify_auto(ca_sig_pk, &payload, &self.signature)?; + if !ok { + return Err(Error::SignatureVerification( + "RevocationUpdate signature invalid".into(), + )); + } + Ok(()) + } + + /// A stable 128-bit identifier for observability/deduplication. + pub fn stable_id(&self) -> [u8; 16] { + let mut hasher = Sha256::new(); + hasher.update(b"pqc-iiot:revocation-id:v1"); + hasher.update(self.version.to_be_bytes()); + hasher.update(self.seq.to_be_bytes()); + hasher.update(self.issued_at.to_be_bytes()); + let digest = hasher.finalize(); + let mut out = [0u8; 16]; + out.copy_from_slice(&digest[..16]); + out + } +} + +fn falcon_verify_auto(pk: &[u8], msg: &[u8], sig: &[u8]) -> Result { + let level = match pk.len() { + 897 => FalconSecurityLevel::Falcon512, + 1793 => FalconSecurityLevel::Falcon1024, + _ => { + return Err(Error::InvalidInput(format!( + "Invalid Falcon public key length: {}", + pk.len() + ))) + } + }; + let falcon = Falcon::new_with_level(level); + falcon.verify(pk, msg, sig) +} + +fn falcon_sign_auto(sk: &[u8], msg: &[u8]) -> Result> { + let level = match sk.len() { + 1281 => FalconSecurityLevel::Falcon512, + 2305 => FalconSecurityLevel::Falcon1024, + _ => { + return Err(Error::InvalidInput(format!( + "Invalid Falcon secret key length: {}", + sk.len() + ))) + } + }; + let falcon = Falcon::new_with_level(level); + falcon.sign(sk, msg) +} diff --git a/src/security/time.rs b/src/security/time.rs new file mode 100644 index 0000000..559f7d7 --- /dev/null +++ b/src/security/time.rs @@ -0,0 +1,117 @@ +use crate::security::monotonic::{seal_u64, unseal_u64}; +use crate::security::provider::SecurityProvider; +use crate::Result; +use log::{info, warn}; +use std::sync::Arc; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +/// Security assurance level for `SecureTimeFloor`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TimeAssurance { + /// Best-effort monotonic floor; can be rolled back if the sealed backend is rollbackable. + BestEffort, + /// Backed by rollback-resistant sealing (TPM NV, HSM, TEE monotonic storage, WORM service). + RollbackResistant, +} + +fn system_unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +/// Best-effort secure time floor. +/// +/// This is *not* a secure time source by itself; it enforces a monotonic floor (non-decreasing) +/// across restarts when `SecurityProvider::seal_data/unseal_data` is backed by non-rollback storage +/// (TPM NV, HSM, TEE monotonic counter, WORM remote service, etc). +/// +/// Threat model notes: +/// - Without a real anti-rollback root (hardware monotonic counter), an attacker with filesystem +/// write access can roll this floor back by restoring an older sealed blob. +/// - A forward time jump (setting clock to the future) is always a DoS vector; we only log it. +pub struct SecureTimeFloor { + provider: Arc, + label: String, + floor_unix_s: u64, + last_persist: Instant, + persist_interval: Duration, + max_backward_skew_s: u64, +} + +impl SecureTimeFloor { + /// Load a persisted monotonic unix-second floor from the provider. + /// + /// On the first run (no sealed value), this initializes the floor to the current system time + /// and seals it immediately. + pub fn load(provider: Arc, label: impl Into) -> Result { + let label = label.into(); + let persisted = unseal_u64(&provider, &label)?; + let floor = persisted.unwrap_or_else(system_unix_seconds); + + // If this is the first run (no persisted floor), anchor immediately. + if persisted.is_none() { + seal_u64(&provider, &label, floor)?; + info!("secure time floor initialized: {}={}", label, floor); + } + + Ok(Self { + provider, + label, + floor_unix_s: floor, + last_persist: Instant::now(), + // Avoid flash wear: persist at most once per minute unless caller forces. + persist_interval: Duration::from_secs(60), + // Allow small backward skew without treating it as an attack (NTP adjustments, RTC jitter). + max_backward_skew_s: 5, + }) + } + + /// Returns a unix timestamp in seconds, clamped to a monotonic floor. + /// + /// On backward jumps greater than `max_backward_skew_s`, this clamps to the previous floor and + /// logs a warning. On forward movement, the floor is advanced and persisted periodically. + pub fn now_unix_s(&mut self) -> Result { + let now = system_unix_seconds(); + + if now + self.max_backward_skew_s < self.floor_unix_s { + warn!( + "secure time rollback detected: now={} < floor={} (label={})", + now, self.floor_unix_s, self.label + ); + return Ok(self.floor_unix_s); + } + + if now > self.floor_unix_s { + self.floor_unix_s = now; + if self.last_persist.elapsed() >= self.persist_interval { + seal_u64(&self.provider, &self.label, self.floor_unix_s)?; + self.last_persist = Instant::now(); + } + } + + Ok(now) + } + + /// Returns the assurance level of the monotonic floor based on the provider backend. + pub fn assurance(&self) -> TimeAssurance { + if self.provider.is_rollback_resistant_storage() { + TimeAssurance::RollbackResistant + } else { + TimeAssurance::BestEffort + } + } + + /// Force persistence of the current floor. + pub fn flush(&mut self) -> Result<()> { + seal_u64(&self.provider, &self.label, self.floor_unix_s)?; + self.last_persist = Instant::now(); + Ok(()) + } + + /// Return the current monotonic floor value (unix seconds). + pub fn floor_unix_s(&self) -> u64 { + self.floor_unix_s + } +} diff --git a/src/security/tpm.rs b/src/security/tpm.rs index a70ca47..020795a 100644 --- a/src/security/tpm.rs +++ b/src/security/tpm.rs @@ -360,7 +360,7 @@ impl SoftwareTpm { match crate::persistence::AtomicFileStore::read_with_limit(&path, MAX_SESSION_BYTES) { Ok(d) => d, Err(Error::IoError(e)) if e.kind() == std::io::ErrorKind::NotFound => { - return Err(Error::CryptoError("Session Not Found".into())); + return Err(Error::IoError(e)); } Err(e) => return Err(e), }; @@ -422,6 +422,10 @@ impl SecurityProvider for SoftwareTpm { None } + fn provider_kind(&self) -> &'static str { + "software-tpm" + } + fn seal_data(&self, label: &str, data: &[u8]) -> Result<()> { self.seal_session(label, data) } diff --git a/tests/common/mod.rs b/tests/common/mod.rs index e5262c7..eda0084 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -40,6 +40,7 @@ pub fn start_mqtt_broker(port: u16) { } // Simple CoAP server mock +#[allow(dead_code)] pub fn start_coap_server(port: u16) { thread::spawn(move || { let socket = UdpSocket::bind(format!("127.0.0.1:{}", port)).unwrap(); diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index d857734..85054f0 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -4,6 +4,7 @@ use pqc_iiot::provisioning::{FactoryIdentity, OperationalCa}; use pqc_iiot::Falcon; use pqc_iiot::{coap_secure::SecureCoapClient, mqtt_secure::SecureMqttClient}; use rumqttc::{Client as RumqttClient, MqttOptions, QoS}; +use sha2::{Digest, Sha256}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tokio::runtime::Runtime; mod common; @@ -412,6 +413,174 @@ fn test_strict_mode() -> Result<(), Box> { Ok(()) } +#[test] +fn test_distributed_revocation_blocks_peer() -> Result<(), Box> { + let port = 19856; + common::start_mqtt_broker(port); + + let suffix: u32 = rand::random(); + let key_prefix = format!("pqc/revoke_keys_{}/", suffix); + let revocation_topic = format!("pqc/revocations/test_{}", suffix); + let topic = format!("secure/revoke_chat_{}", suffix); + + let falcon = Falcon::new(); + let (ca_pk, ca_sk) = falcon.generate_keypair().expect("ca keygen"); + let ca_sk_for_revocation = ca_sk.clone(); + let mut ca = OperationalCa::new(ca_pk.clone(), ca_sk); + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let bob_id = format!("bob_revoke_{}", suffix); + let alice_id = format!("alice_revoke_{}", suffix); + + // Bob (receiver) + let (bob_factory_pk, bob_factory_sk) = falcon.generate_keypair().expect("factory keygen"); + let bob_factory = FactoryIdentity::new(bob_factory_pk, bob_factory_sk); + let mut bob = SecureMqttClient::new("localhost", port, &bob_id)? + .with_key_prefix(&key_prefix) + .with_revocation_topic(&revocation_topic) + .with_strict_mode(true); + ca.allow_device(&bob_id, bob_factory.pubkey.clone()); + let bob_join = bob_factory.create_join_request( + &bob_id, + &bob.get_kem_public_key(), + &bob.get_identity_key(), + &bob.get_x25519_public_key(), + )?; + let bob_cert = ca.issue_operational_cert(&bob_join, now, 3600)?; + bob = bob + .with_trust_anchor_ca_sig_pk(ca_pk.clone()) + .with_operational_cert(bob_cert); + bob.bootstrap()?; + bob.subscribe(&topic)?; + + // Alice (sender) + let (alice_factory_pk, alice_factory_sk) = falcon.generate_keypair().expect("factory keygen"); + let alice_factory = FactoryIdentity::new(alice_factory_pk, alice_factory_sk); + let mut alice = SecureMqttClient::new("localhost", port, &alice_id)? + .with_key_prefix(&key_prefix) + .with_revocation_topic(&revocation_topic) + .with_strict_mode(true); + ca.allow_device(&alice_id, alice_factory.pubkey.clone()); + let alice_join = alice_factory.create_join_request( + &alice_id, + &alice.get_kem_public_key(), + &alice.get_identity_key(), + &alice.get_x25519_public_key(), + )?; + let alice_cert = ca.issue_operational_cert(&alice_join, now, 3600)?; + alice = alice + .with_trust_anchor_ca_sig_pk(ca_pk.clone()) + .with_operational_cert(alice_cert.clone()); + alice.bootstrap()?; + + // Drive both until they're mutually ready. + let start = Instant::now(); + while start.elapsed() < Duration::from_secs(5) { + bob.poll(|_, _| {})?; + alice.poll(|_, _| {})?; + if bob.is_peer_ready(&alice_id) && alice.is_peer_ready(&bob_id) { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + assert!( + bob.is_peer_ready(&alice_id), + "Bob should accept Alice before revocation" + ); + assert!( + alice.is_peer_ready(&bob_id), + "Alice should learn Bob keys before sending" + ); + + // Sanity: message is delivered before revocation. + alice.publish_encrypted(&topic, b"BEFORE_REVOKE", &bob_id)?; + let start = Instant::now(); + let mut got_before = false; + while start.elapsed() < Duration::from_secs(2) { + bob.poll(|t, p| { + if t == topic && p == b"BEFORE_REVOKE" { + got_before = true; + } + })?; + if got_before { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + assert!(got_before, "Expected message delivery before revocation"); + + // Publish a CA-signed revocation update for Alice's certified key_id. + let mut update = pqc_iiot::security::revocation::RevocationUpdate { + version: pqc_iiot::security::revocation::RevocationUpdate::VERSION_V1, + seq: 1, + issued_at: now, + entries: vec![pqc_iiot::security::revocation::RevocationEntry { + device_id: alice_id.clone(), + key_id: alice_cert.key_id.clone(), + }], + signature: Vec::new(), + }; + update.sign(&ca_sk_for_revocation, &revocation_topic)?; + let payload = serde_json::to_vec(&update)?; + + let mut opts = MqttOptions::new(format!("revoke_pub_{}", suffix), "localhost", port); + opts.set_clean_session(true); + let (mut pub_client, mut pub_conn) = RumqttClient::new(opts, 10); + let pub_handle = std::thread::spawn(move || { + for notification in pub_conn.iter() { + if notification.is_err() { + break; + } + } + }); + pub_client.publish(revocation_topic.clone(), QoS::AtLeastOnce, true, payload)?; + pub_client.disconnect()?; + drop(pub_client); + pub_handle + .join() + .expect("revocation publisher thread panicked"); + + // Wait until Bob applies the revocation (peer readiness drops). + let start = Instant::now(); + while start.elapsed() < Duration::from_secs(3) { + bob.poll(|_, _| {})?; + if !bob.is_peer_ready(&alice_id) { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + assert!( + !bob.is_peer_ready(&alice_id), + "Bob must mark Alice not-ready after revocation" + ); + + // After revocation, encrypted messages must be dropped. + alice.publish_encrypted(&topic, b"AFTER_REVOKE", &bob_id)?; + let start = Instant::now(); + let mut got_after = false; + while start.elapsed() < Duration::from_millis(600) { + bob.poll(|t, p| { + if t == topic && p == b"AFTER_REVOKE" { + got_after = true; + } + })?; + if got_after { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + assert!( + !got_after, + "Bob must drop messages from revoked key_id even if signature/decrypt would succeed" + ); + + Ok(()) +} + #[test] fn test_attestation_gates_trust() -> Result<(), Box> { let port = 29837; @@ -649,8 +818,19 @@ fn test_encrypted_message_rejects_invalid_signature_even_if_parses( )?; let (_mallory_pk, mallory_sk) = falcon.generate_keypair().expect("mallory keygen"); + let digest = { + let mut hasher = Sha256::new(); + hasher.update(b"pqc-iiot:mqtt-msg:v1"); + hasher.update((alice_id.len() as u16).to_be_bytes()); + hasher.update(alice_id.as_bytes()); + hasher.update((topic.len() as u16).to_be_bytes()); + hasher.update(topic.as_bytes()); + hasher.update((encrypted_blob.len() as u32).to_be_bytes()); + hasher.update(&encrypted_blob); + hasher.finalize() + }; let forged_signature = falcon - .sign(&mallory_sk, &encrypted_blob) + .sign(&mallory_sk, digest.as_slice()) .expect("mallory sign"); let sender_id_bytes = alice_id.as_bytes(); @@ -1095,7 +1275,8 @@ fn test_encryption_at_rest() -> Result<(), Box> { // let _ = env_logger::builder().is_test(true).try_init(); // Use a unique client ID for this test to avoid conflict with other tests - let client_id = "test_encrypted_client"; + let suffix: u32 = rand::random(); + let client_id = format!("test_encrypted_client_{}", suffix); let key = [0x42u8; 32]; // 32-byte key let wrong_key = [0x00u8; 32]; @@ -1105,12 +1286,26 @@ fn test_encryption_at_rest() -> Result<(), Box> { if identity_path.exists() { std::fs::remove_file(&identity_path)?; } + let keystore_path = data_dir.join(format!("keystore_{}.json", client_id)); + if keystore_path.exists() { + std::fs::remove_file(&keystore_path)?; + } + for label in [ + format!("pqc-iiot:time-floor:v1:{}", client_id), + format!("pqc-iiot:keystore-gen:v1:{}", client_id), + ] { + let digest = Sha256::digest(label.as_bytes()); + let sealed_path = data_dir.join(format!("sealed_{}.bin", hex::encode(digest))); + if sealed_path.exists() { + std::fs::remove_file(sealed_path)?; + } + } // 1. Create and Save (Encrypted) { // Broker is not needed for this test, just file ops, but new() connects options. // We can pass dummy broker as we won't call bootstrap/connect - let client = SecureMqttClient::new_encrypted("localhost", 1883, client_id, &key)?; + let client = SecureMqttClient::new_encrypted("localhost", 1883, &client_id, &key)?; client.save_identity()?; // Should save encrypted } @@ -1123,20 +1318,20 @@ fn test_encryption_at_rest() -> Result<(), Box> { // 3. Load with Correct Key { - let client_result = SecureMqttClient::new_encrypted("localhost", 1883, client_id, &key); + let client_result = SecureMqttClient::new_encrypted("localhost", 1883, &client_id, &key); assert!(client_result.is_ok(), "Should load with correct key"); } // 4. Load with Wrong Key { let client_result = - SecureMqttClient::new_encrypted("localhost", 1883, client_id, &wrong_key); + SecureMqttClient::new_encrypted("localhost", 1883, &client_id, &wrong_key); assert!(client_result.is_err(), "Should fail with wrong key"); } // 5. Load with No Key (expecting JSON) { - let client_result = SecureMqttClient::new("localhost", 1883, client_id); + let client_result = SecureMqttClient::new("localhost", 1883, &client_id); assert!( client_result.is_err(), "Should fail with no key (parsing encrypted as JSON)" diff --git a/tests/mqtt_invariants.rs b/tests/mqtt_invariants.rs new file mode 100644 index 0000000..92ad2ea --- /dev/null +++ b/tests/mqtt_invariants.rs @@ -0,0 +1,759 @@ +use pqc_iiot::crypto::traits::PqcSignature; +use pqc_iiot::mqtt_secure::SecureMqttClient; +use pqc_iiot::Falcon; +use rumqttc::{Client as RumqttClient, Event, MqttOptions, Packet, QoS}; +use sha2::{Digest, Sha256}; +use std::time::{Duration, Instant}; + +mod common; + +fn mqtt_msg_digest(sender_id: &str, topic: &str, encrypted_blob: &[u8]) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(b"pqc-iiot:mqtt-msg:v1"); + hasher.update((sender_id.len() as u16).to_be_bytes()); + hasher.update(sender_id.as_bytes()); + hasher.update((topic.len() as u16).to_be_bytes()); + hasher.update(topic.as_bytes()); + hasher.update((encrypted_blob.len() as u32).to_be_bytes()); + hasher.update(encrypted_blob); + let digest = hasher.finalize(); + let mut out = [0u8; 32]; + out.copy_from_slice(&digest); + out +} + +fn publish_raw(topic: &str, port: u16, payload: Vec) -> Result<(), Box> { + let mut opts = MqttOptions::new("raw_pub", "localhost", port); + opts.set_clean_session(true); + let (mut pub_client, mut pub_conn) = RumqttClient::new(opts, 10); + let pub_handle = std::thread::spawn(move || { + for notification in pub_conn.iter() { + if notification.is_err() { + break; + } + } + }); + + pub_client.publish(topic, QoS::AtLeastOnce, false, payload)?; + pub_client.disconnect()?; + drop(pub_client); + pub_handle.join().expect("publisher thread panicked"); + Ok(()) +} + +fn wait_for_key_exchange( + alice: &mut SecureMqttClient, + bob: &mut SecureMqttClient, + alice_id: &str, + bob_id: &str, +) -> Result<(), Box> { + let start = Instant::now(); + while start.elapsed() < Duration::from_secs(5) { + alice.poll(|_, _| {})?; + bob.poll(|_, _| {})?; + if alice.has_peer(bob_id) && bob.has_peer(alice_id) { + return Ok(()); + } + std::thread::sleep(Duration::from_millis(20)); + } + Err(format!( + "key exchange did not converge: alice_has_bob={} bob_has_alice={}", + alice.has_peer(bob_id), + bob.has_peer(alice_id) + ) + .into()) +} + +#[test] +fn mqtt_session_ratchet_establishes_and_binds_topic_and_rejects_replay( +) -> Result<(), Box> { + let port = 29840; + common::start_mqtt_broker(port); + let suffix: u32 = rand::random(); + + let key_prefix = format!("pqc/session_keys_{}/", suffix); + let topic_good = format!("secure/session_good_{}", suffix); + let topic_bad = format!("secure/session_bad_{}", suffix); + + let alice_id = format!("alice_sess_{}", suffix); + let bob_id = format!("bob_sess_{}", suffix); + + let mut alice = SecureMqttClient::new("localhost", port, &alice_id)? + .with_strict_mode(false) + .with_key_prefix(&key_prefix); + let mut bob = SecureMqttClient::new("localhost", port, &bob_id)? + .with_strict_mode(false) + .with_key_prefix(&key_prefix); + + alice.bootstrap()?; + bob.bootstrap()?; + + bob.subscribe(&topic_good)?; + bob.subscribe(&topic_bad)?; + + // Wait for key exchange (TOFU) so both sides consider each other trusted. + let start = Instant::now(); + while start.elapsed() < Duration::from_secs(5) { + alice.poll(|_, _| {})?; + bob.poll(|_, _| {})?; + if alice.has_peer(&bob_id) && bob.has_peer(&alice_id) { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + + assert!(alice.has_peer(&bob_id), "Alice never learned Bob's keys"); + assert!(bob.has_peer(&alice_id), "Bob never learned Alice's keys"); + + // Initiate a forward-secure session from Alice -> Bob. + alice.initiate_session(&bob_id)?; + + let start = Instant::now(); + while start.elapsed() < Duration::from_secs(5) { + alice.poll(|_, _| {})?; + bob.poll(|_, _| {})?; + if alice.has_session(&bob_id) && bob.has_session(&alice_id) { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + assert!(alice.has_session(&bob_id), "Alice session not established"); + assert!(bob.has_session(&alice_id), "Bob session not established"); + + // Sniff the raw encrypted packet on topic_good, then replay it on: + // - topic_bad (must fail topic binding) + // - topic_good (must fail replay protection) + let (tx, rx) = std::sync::mpsc::channel::>(); + let (ready_tx, ready_rx) = std::sync::mpsc::channel::<()>(); + let topic_good_sniff = topic_good.clone(); + let handle = std::thread::spawn(move || { + let mut opts = MqttOptions::new("sniffer", "localhost", port); + opts.set_clean_session(true); + let (mut sniff_client, mut sniff_conn) = RumqttClient::new(opts, 10); + sniff_client + .subscribe(&topic_good_sniff, QoS::AtLeastOnce) + .expect("sniffer subscribe"); + + let mut ready_sent = false; + for notification in sniff_conn.iter() { + if let Ok(Event::Incoming(Packet::SubAck(_))) = notification { + if !ready_sent { + let _ = ready_tx.send(()); + ready_sent = true; + } + continue; + } + if let Ok(Event::Incoming(Packet::Publish(p))) = notification { + if !ready_sent { + let _ = ready_tx.send(()); + } + let _ = tx.send(p.payload.to_vec()); + break; + } + } + let _ = sniff_client.disconnect(); + }); + + ready_rx + .recv_timeout(Duration::from_secs(1)) + .expect("sniffer not ready"); + + alice.publish_encrypted(&topic_good, b"SESSION_OK", &bob_id)?; + + let raw_packet = rx + .recv_timeout(Duration::from_secs(3)) + .expect("did not sniff encrypted packet"); + handle.join().expect("sniffer thread panicked"); + + // Replay on wrong topic. + publish_raw(&topic_bad, port, raw_packet.clone())?; + // Replay on correct topic (duplicate). + publish_raw(&topic_good, port, raw_packet)?; + + let start = Instant::now(); + let mut got_good = 0u32; + let mut got_bad = 0u32; + while start.elapsed() < Duration::from_secs(3) { + bob.poll(|t, p| { + if t == topic_good && p == b"SESSION_OK" { + got_good += 1; + } + if t == topic_bad && p == b"SESSION_OK" { + got_bad += 1; + } + })?; + if got_good >= 1 && got_bad > 0 { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + + assert_eq!( + got_good, 1, + "Expected exactly one accepted plaintext on topic_good" + ); + assert_eq!( + got_bad, 0, + "Expected no acceptance on topic_bad (topic binding)" + ); + + Ok(()) +} + +#[test] +fn mqtt_fleet_policy_require_sessions_enforced_and_updates_apply_monotonically( +) -> Result<(), Box> { + let port = 29842; + common::start_mqtt_broker(port); + let suffix: u32 = rand::random(); + + let key_prefix = format!("pqc/policy_keys_{}/", suffix); + let topic = format!("secure/policy_enforced_{}", suffix); + + let alice_id = format!("alice_policy_{}", suffix); + let bob_id = format!("bob_policy_{}", suffix); + + // Mesh CA used to sign fleet policy updates. + let falcon = Falcon::new(); + let (ca_pk, ca_sk) = falcon.generate_keypair().expect("ca keygen"); + + let mut alice = SecureMqttClient::new("localhost", port, &alice_id)? + .with_strict_mode(false) + .with_key_prefix(&key_prefix) + .with_trust_anchor_ca_sig_pk(ca_pk.clone()); + let mut bob = SecureMqttClient::new("localhost", port, &bob_id)? + .with_strict_mode(false) + .with_key_prefix(&key_prefix) + .with_trust_anchor_ca_sig_pk(ca_pk); + + alice.bootstrap()?; + bob.bootstrap()?; + bob.subscribe(&topic)?; + + // Wait for key exchange (TOFU) so v1 encryption would work when allowed. + let start = Instant::now(); + while start.elapsed() < Duration::from_secs(5) { + alice.poll(|_, _| {})?; + bob.poll(|_, _| {})?; + if alice.has_peer(&bob_id) && bob.has_peer(&alice_id) { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + assert!(alice.has_peer(&bob_id)); + assert!(bob.has_peer(&alice_id)); + + // Apply policy seq=1: require sessions (disallow v1 fallback). + let mut policy_1 = pqc_iiot::security::policy::FleetPolicyUpdate { + version: pqc_iiot::security::policy::FleetPolicyUpdate::VERSION_V1, + seq: 1, + issued_at: 1, + require_rollback_resistant_storage: false, + strict_mode: false, + attestation_required: false, + require_sessions: true, + min_revocation_seq: None, + sig_verify_budget: None, + decrypt_budget: None, + ttl_secs: None, + session_rekey_after_msgs: None, + session_rekey_after_secs: None, + signature: Vec::new(), + }; + policy_1.sign(&ca_sk, "pqc/policy/v1")?; + publish_raw( + "pqc/policy/v1", + port, + serde_json::to_vec(&policy_1).expect("policy_1 json"), + )?; + + let start = Instant::now(); + let mut enforced = false; + while start.elapsed() < Duration::from_secs(3) { + alice.poll(|_, _| {})?; + bob.poll(|_, _| {})?; + if alice + .publish_encrypted(&topic, b"NO_SESSION", &bob_id) + .is_err() + { + enforced = true; + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + assert!(enforced, "expected require_sessions to block v1 publish"); + + // Apply policy seq=2: allow v1 fallback again. + let mut policy_2 = pqc_iiot::security::policy::FleetPolicyUpdate { + version: pqc_iiot::security::policy::FleetPolicyUpdate::VERSION_V1, + seq: 2, + issued_at: 2, + require_rollback_resistant_storage: false, + strict_mode: false, + attestation_required: false, + require_sessions: false, + min_revocation_seq: None, + sig_verify_budget: None, + decrypt_budget: None, + ttl_secs: None, + session_rekey_after_msgs: None, + session_rekey_after_secs: None, + signature: Vec::new(), + }; + policy_2.sign(&ca_sk, "pqc/policy/v1")?; + publish_raw( + "pqc/policy/v1", + port, + serde_json::to_vec(&policy_2).expect("policy_2 json"), + )?; + + // Now v1 publish should succeed without establishing a session. + let start = Instant::now(); + let mut got = false; + while start.elapsed() < Duration::from_secs(5) { + alice.poll(|_, _| {})?; + bob.poll(|t, p| { + if t == topic && p == b"OK_V1" { + got = true; + } + })?; + if got { + break; + } + if alice.publish_encrypted(&topic, b"OK_V1", &bob_id).is_ok() { + // Give the receiver time to process. + } + std::thread::sleep(Duration::from_millis(20)); + } + assert!(got, "expected v1 publish to succeed after policy seq=2"); + + Ok(()) +} + +#[test] +fn mqtt_policy_v2_fails_closed_without_rollback_resistant_storage( +) -> Result<(), Box> { + let port = 29844; + common::start_mqtt_broker(port); + let suffix: u32 = rand::random(); + + let key_prefix = format!("pqc/policy2_keys_{}/", suffix); + let topic = format!("secure/policy2_storage_gate_{}", suffix); + + let alice_id = format!("alice_policy2_{}", suffix); + let bob_id = format!("bob_policy2_{}", suffix); + + let falcon = Falcon::new(); + let (ca_pk, ca_sk) = falcon.generate_keypair().expect("ca keygen"); + + let mut alice = SecureMqttClient::new("localhost", port, &alice_id)? + .with_strict_mode(false) + .with_key_prefix(&key_prefix) + .with_trust_anchor_ca_sig_pk(ca_pk.clone()); + let mut bob = SecureMqttClient::new("localhost", port, &bob_id)? + .with_strict_mode(false) + .with_key_prefix(&key_prefix) + .with_trust_anchor_ca_sig_pk(ca_pk); + + alice.bootstrap()?; + bob.bootstrap()?; + bob.subscribe(&topic)?; + + wait_for_key_exchange(&mut alice, &mut bob, &alice_id, &bob_id)?; + + // Apply policy seq=1: require rollback-resistant storage (software provider must fail closed). + let mut policy = pqc_iiot::security::policy::FleetPolicyUpdate { + version: pqc_iiot::security::policy::FleetPolicyUpdate::VERSION_V2, + seq: 1, + issued_at: 1, + require_rollback_resistant_storage: true, + strict_mode: false, + attestation_required: false, + require_sessions: false, + min_revocation_seq: None, + sig_verify_budget: None, + decrypt_budget: None, + ttl_secs: None, + session_rekey_after_msgs: None, + session_rekey_after_secs: None, + signature: Vec::new(), + }; + policy.sign(&ca_sk, "pqc/policy/v1")?; + publish_raw( + "pqc/policy/v1", + port, + serde_json::to_vec(&policy).expect("policy json"), + )?; + + // Wait for policy to apply: publish must fail with a storage gate error once applied. + let start = Instant::now(); + while start.elapsed() < Duration::from_secs(3) { + alice.poll(|_, _| {})?; + bob.poll(|_, _| {})?; + if let Err(e) = alice.publish_encrypted(&topic, b"X", &bob_id) { + if format!("{e:?}").contains("rollback-resistant storage") { + break; + } + } + std::thread::sleep(Duration::from_millis(20)); + } + + let err = alice + .publish_encrypted(&topic, b"BLOCKED", &bob_id) + .expect_err("expected fail-closed without rollback-resistant storage"); + let msg = format!("{err:?}"); + assert!( + msg.contains("rollback-resistant storage"), + "unexpected error: {msg}" + ); + + Ok(()) +} + +#[test] +fn mqtt_policy_v2_fails_closed_when_revocation_seq_behind() -> Result<(), Box> +{ + let port = 29846; + common::start_mqtt_broker(port); + let suffix: u32 = rand::random(); + + let key_prefix = format!("pqc/policy2_rev_keys_{}/", suffix); + let topic = format!("secure/policy2_rev_gate_{}", suffix); + + let alice_id = format!("alice_policy2_rev_{}", suffix); + let bob_id = format!("bob_policy2_rev_{}", suffix); + + let falcon = Falcon::new(); + let (ca_pk, ca_sk) = falcon.generate_keypair().expect("ca keygen"); + + let mut alice = SecureMqttClient::new("localhost", port, &alice_id)? + .with_strict_mode(false) + .with_key_prefix(&key_prefix) + .with_trust_anchor_ca_sig_pk(ca_pk.clone()); + let mut bob = SecureMqttClient::new("localhost", port, &bob_id)? + .with_strict_mode(false) + .with_key_prefix(&key_prefix) + .with_trust_anchor_ca_sig_pk(ca_pk); + + alice.bootstrap()?; + bob.bootstrap()?; + bob.subscribe(&topic)?; + + wait_for_key_exchange(&mut alice, &mut bob, &alice_id, &bob_id)?; + + // Apply policy seq=1: require revocation catch-up. + let mut policy = pqc_iiot::security::policy::FleetPolicyUpdate { + version: pqc_iiot::security::policy::FleetPolicyUpdate::VERSION_V2, + seq: 1, + issued_at: 1, + require_rollback_resistant_storage: false, + strict_mode: false, + attestation_required: false, + require_sessions: false, + min_revocation_seq: Some(10), + sig_verify_budget: None, + decrypt_budget: None, + ttl_secs: None, + session_rekey_after_msgs: None, + session_rekey_after_secs: None, + signature: Vec::new(), + }; + policy.sign(&ca_sk, "pqc/policy/v1")?; + publish_raw( + "pqc/policy/v1", + port, + serde_json::to_vec(&policy).expect("policy json"), + )?; + + // Wait for policy to apply: publish must fail with revocation gating once applied. + let start = Instant::now(); + while start.elapsed() < Duration::from_secs(3) { + alice.poll(|_, _| {})?; + bob.poll(|_, _| {})?; + if let Err(e) = alice.publish_encrypted(&topic, b"X", &bob_id) { + if format!("{e:?}").contains("Revocation state behind") { + break; + } + } + std::thread::sleep(Duration::from_millis(20)); + } + + let err = alice + .publish_encrypted(&topic, b"BLOCKED", &bob_id) + .expect_err("expected fail-closed when revocation seq behind"); + let msg = format!("{err:?}"); + assert!( + msg.contains("Revocation state behind"), + "unexpected error: {msg}" + ); + + Ok(()) +} + +#[test] +fn mqtt_policy_v2_ttl_stale_blocks_new_handshakes() -> Result<(), Box> { + let port = 29848; + common::start_mqtt_broker(port); + let suffix: u32 = rand::random(); + + let key_prefix = format!("pqc/policy2_ttl_keys_{}/", suffix); + let topic = format!("secure/policy2_ttl_gate_{}", suffix); + + let alice_id = format!("alice_policy2_ttl_{}", suffix); + let bob_id = format!("bob_policy2_ttl_{}", suffix); + + let falcon = Falcon::new(); + let (ca_pk, ca_sk) = falcon.generate_keypair().expect("ca keygen"); + + let mut alice = SecureMqttClient::new("localhost", port, &alice_id)? + .with_strict_mode(false) + .with_key_prefix(&key_prefix) + .with_trust_anchor_ca_sig_pk(ca_pk.clone()); + let mut bob = SecureMqttClient::new("localhost", port, &bob_id)? + .with_strict_mode(false) + .with_key_prefix(&key_prefix) + .with_trust_anchor_ca_sig_pk(ca_pk); + + alice.bootstrap()?; + bob.bootstrap()?; + bob.subscribe(&topic)?; + + wait_for_key_exchange(&mut alice, &mut bob, &alice_id, &bob_id)?; + + // Apply policy seq=1: TTL in the past -> always stale (secure time uses system unix time). + let mut policy = pqc_iiot::security::policy::FleetPolicyUpdate { + version: pqc_iiot::security::policy::FleetPolicyUpdate::VERSION_V2, + seq: 1, + issued_at: 0, + require_rollback_resistant_storage: false, + strict_mode: false, + attestation_required: false, + require_sessions: false, + min_revocation_seq: None, + sig_verify_budget: None, + decrypt_budget: None, + ttl_secs: Some(1), + session_rekey_after_msgs: None, + session_rekey_after_secs: None, + signature: Vec::new(), + }; + policy.sign(&ca_sk, "pqc/policy/v1")?; + publish_raw( + "pqc/policy/v1", + port, + serde_json::to_vec(&policy).expect("policy json"), + )?; + + // Wait for policy to apply: publish must fail with a stale policy error once applied. + let start = Instant::now(); + while start.elapsed() < Duration::from_secs(3) { + alice.poll(|_, _| {})?; + bob.poll(|_, _| {})?; + if let Err(e) = alice.publish_encrypted(&topic, b"X", &bob_id) { + if format!("{e:?}").contains("Fleet policy stale") { + break; + } + } + std::thread::sleep(Duration::from_millis(20)); + } + + let err = alice + .publish_encrypted(&topic, b"BLOCKED", &bob_id) + .expect_err("expected fail-closed when policy TTL is stale"); + let msg = format!("{err:?}"); + assert!( + msg.contains("Fleet policy stale"), + "unexpected error: {msg}" + ); + + Ok(()) +} + +#[test] +fn mqtt_replay_window_accepts_out_of_order_within_window() -> Result<(), Box> +{ + let port = 29838; + common::start_mqtt_broker(port); + let suffix: u32 = rand::random(); + + let topic = format!("secure/window_{}", suffix); + let victim_id = format!("victim_window_{}", suffix); + let alice_id = format!("alice_window_{}", suffix); + + let mut victim = SecureMqttClient::new("localhost", port, &victim_id)?; + victim.subscribe(&topic)?; + + let falcon = Falcon::new(); + let (alice_pk, alice_sk) = falcon.generate_keypair().expect("alice keygen"); + victim.add_trusted_peer(&alice_id, alice_pk)?; + + let victim_kem_pk = victim.get_kem_public_key(); + let victim_x25519_pk = victim.get_x25519_public_key(); + + // Craft two messages to the victim: + // - send seq=3 first + // - then send seq=2 (out-of-order but within the 64-bit window => must be accepted) + let make_packet = |seq: u64, plaintext: &[u8]| -> Result, Box> { + let mut attached_payload = Vec::with_capacity(8 + plaintext.len()); + attached_payload.extend_from_slice(&seq.to_be_bytes()); + attached_payload.extend_from_slice(plaintext); + + let encrypted_blob = + pqc_iiot::hybrid::encrypt(&victim_kem_pk, &victim_x25519_pk, &attached_payload)?; + + let digest = mqtt_msg_digest(&alice_id, &topic, &encrypted_blob); + let signature = falcon.sign(&alice_sk, &digest)?; + let sig_len = signature.len() as u16; + + let sender_id_bytes = alice_id.as_bytes(); + let sender_id_len = sender_id_bytes.len() as u16; + + let mut message = Vec::new(); + message.extend_from_slice(&sender_id_len.to_be_bytes()); + message.extend_from_slice(sender_id_bytes); + message.extend_from_slice(&encrypted_blob); + message.extend_from_slice(&signature); + message.extend_from_slice(&sig_len.to_be_bytes()); + Ok(message) + }; + + publish_raw(&topic, port, make_packet(3, b"M3")?)?; + publish_raw(&topic, port, make_packet(2, b"M2")?)?; + + let start = Instant::now(); + let mut got_m3 = false; + let mut got_m2 = false; + while start.elapsed() < Duration::from_secs(3) { + victim.poll(|t, p| { + if t == topic && p == b"M3" { + got_m3 = true; + } + if t == topic && p == b"M2" { + got_m2 = true; + } + })?; + if got_m3 && got_m2 { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + + assert!(got_m3, "Expected to receive seq=3"); + assert!( + got_m2, + "Expected to accept out-of-order seq=2 within replay window" + ); + + // Duplicate seq=3 must be rejected. + publish_raw(&topic, port, make_packet(3, b"M3_DUP")?)?; + + let start = Instant::now(); + let mut got_dup = false; + while start.elapsed() < Duration::from_millis(300) { + victim.poll(|t, p| { + if t == topic && p == b"M3_DUP" { + got_dup = true; + } + })?; + if got_dup { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + assert!(!got_dup, "Duplicate seq=3 must be rejected as replay"); + + Ok(()) +} + +#[test] +fn mqtt_signature_binds_topic() -> Result<(), Box> { + let port = 29839; + common::start_mqtt_broker(port); + let suffix: u32 = rand::random(); + + let topic_good = format!("secure/topic_good_{}", suffix); + let topic_bad = format!("secure/topic_bad_{}", suffix); + let victim_id = format!("victim_topic_{}", suffix); + let alice_id = format!("alice_topic_{}", suffix); + + let mut victim = SecureMqttClient::new("localhost", port, &victim_id)?; + victim.subscribe(&topic_good)?; + + let falcon = Falcon::new(); + let (alice_pk, alice_sk) = falcon.generate_keypair().expect("alice keygen"); + victim.add_trusted_peer(&alice_id, alice_pk)?; + + let victim_kem_pk = victim.get_kem_public_key(); + let victim_x25519_pk = victim.get_x25519_public_key(); + + let mut attached_payload = Vec::with_capacity(8 + b"HELLO".len()); + attached_payload.extend_from_slice(&1u64.to_be_bytes()); + attached_payload.extend_from_slice(b"HELLO"); + + let encrypted_blob = + pqc_iiot::hybrid::encrypt(&victim_kem_pk, &victim_x25519_pk, &attached_payload)?; + + // Sign for the wrong topic, then publish on the right topic => must be rejected. + let digest_bad = mqtt_msg_digest(&alice_id, &topic_bad, &encrypted_blob); + let signature_bad = falcon.sign(&alice_sk, &digest_bad)?; + + let sender_id_bytes = alice_id.as_bytes(); + let sender_id_len = sender_id_bytes.len() as u16; + let sig_len_bad = signature_bad.len() as u16; + let mut message_bad = Vec::new(); + message_bad.extend_from_slice(&sender_id_len.to_be_bytes()); + message_bad.extend_from_slice(sender_id_bytes); + message_bad.extend_from_slice(&encrypted_blob); + message_bad.extend_from_slice(&signature_bad); + message_bad.extend_from_slice(&sig_len_bad.to_be_bytes()); + + publish_raw(&topic_good, port, message_bad)?; + + let start = Instant::now(); + let mut received = false; + while start.elapsed() < Duration::from_millis(500) { + victim.poll(|t, p| { + if t == topic_good && p == b"HELLO" { + received = true; + } + })?; + if received { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + assert!( + !received, + "Message signed for a different topic must be rejected" + ); + + // Now publish a correctly signed packet to demonstrate acceptance. + let digest_good = mqtt_msg_digest(&alice_id, &topic_good, &encrypted_blob); + let signature_good = falcon.sign(&alice_sk, &digest_good)?; + let sig_len_good = signature_good.len() as u16; + let mut message_good = Vec::new(); + message_good.extend_from_slice(&sender_id_len.to_be_bytes()); + message_good.extend_from_slice(sender_id_bytes); + message_good.extend_from_slice(&encrypted_blob); + message_good.extend_from_slice(&signature_good); + message_good.extend_from_slice(&sig_len_good.to_be_bytes()); + + publish_raw(&topic_good, port, message_good)?; + + let start = Instant::now(); + let mut received = false; + while start.elapsed() < Duration::from_secs(2) { + victim.poll(|t, p| { + if t == topic_good && p == b"HELLO" { + received = true; + } + })?; + if received { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + assert!(received, "Correctly signed message must be accepted"); + + Ok(()) +}