Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,7 @@ wiremock = "0.6"
yew = { version = "0.23", features = ["csr"] }
yew-router = "0.20"
zbus-secret-service-keyring-store = { version = "1.0.0", features = ["rt-async-io-crypto-rust"] }
zeroize = "1"
zip = { version = "8.6.0", default-features = false, features = ["deflate"] }

[profile.release]
Expand Down
22 changes: 21 additions & 1 deletion core/binary_protocol/src/consensus/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ pub enum Command2 {
DoViewChange = 11,
StartView = 12,
Eviction = 13,

// Replica-to-replica auth handshake (server-ng consensus plane).
ReplicaHello = 14,
ReplicaChallenge = 15,
ReplicaFinish = 16,
}

// SAFETY: Command2 is #[repr(u8)] with no padding bytes.
Expand All @@ -50,7 +55,7 @@ unsafe impl CheckedBitPattern for Command2 {
type Bits = u8;

fn is_valid_bit_pattern(bits: &u8) -> bool {
*bits <= 13
*bits <= Self::ReplicaFinish as u8
}
}

Expand All @@ -68,4 +73,19 @@ mod tests {
let result = bytemuck::checked::try_from_bytes::<GenericHeader>(&buf);
assert!(result.is_err());
}

#[test]
fn replica_auth_commands_are_valid_bit_patterns() {
// Locks the is_valid_bit_pattern bump: 14/15/16 parse, 17 still rejects.
for command in 14u8..=16 {
let mut buf: AVec<u8, ConstAlign<16>> = AVec::new(16);
buf.resize(256, 0);
buf[60] = command;
assert!(bytemuck::checked::try_from_bytes::<GenericHeader>(&buf).is_ok());
}
let mut buf: AVec<u8, ConstAlign<16>> = AVec::new(16);
buf.resize(256, 0);
buf[60] = 17;
assert!(bytemuck::checked::try_from_bytes::<GenericHeader>(&buf).is_err());
}
}
11 changes: 9 additions & 2 deletions core/binary_protocol/src/consensus/header.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ use std::mem::offset_of;

pub const HEADER_SIZE: usize = 256;

/// Length of [`GenericHeader::reserved_command`], the per-command scratch area
/// the replica-auth handshake writes its nonce / MAC / reject-reason into.
pub const RESERVED_COMMAND_LEN: usize = 128;

/// Byte offset of [`GenericHeader::size`] within the on-wire header.
///
/// Single source of truth for transports that decode the size field
Expand Down Expand Up @@ -89,7 +93,7 @@ pub struct GenericHeader {
pub command: Command2,
pub replica: u8,
pub reserved_frame: [u8; 66],
pub reserved_command: [u8; 128],
pub reserved_command: [u8; RESERVED_COMMAND_LEN],
}
const _: () = {
assert!(size_of::<GenericHeader>() == HEADER_SIZE);
Expand All @@ -101,7 +105,10 @@ const _: () = {
offset_of!(GenericHeader, reserved_command)
== offset_of!(GenericHeader, reserved_frame) + size_of::<[u8; 66]>()
);
assert!(offset_of!(GenericHeader, reserved_command) + size_of::<[u8; 128]>() == HEADER_SIZE);
assert!(
offset_of!(GenericHeader, reserved_command) + size_of::<[u8; RESERVED_COMMAND_LEN]>()
== HEADER_SIZE
);
};

impl ConsensusHeader for GenericHeader {
Expand Down
4 changes: 2 additions & 2 deletions core/binary_protocol/src/consensus/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ pub use command::Command2;
pub use error::ConsensusError;
pub use header::{
CommitHeader, ConsensusHeader, DoViewChangeHeader, EvictionHeader, EvictionReason,
GenericHeader, HEADER_SIZE, PrepareHeader, PrepareOkHeader, ReplyHeader, RequestHeader,
SIZE_FIELD_OFFSET, StartViewChangeHeader, StartViewHeader, read_size_field,
GenericHeader, HEADER_SIZE, PrepareHeader, PrepareOkHeader, RESERVED_COMMAND_LEN, ReplyHeader,
RequestHeader, SIZE_FIELD_OFFSET, StartViewChangeHeader, StartViewHeader, read_size_field,
};
pub use operation::Operation;
4 changes: 2 additions & 2 deletions core/binary_protocol/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,8 @@ pub use codec::{WireDecode, WireEncode};
pub use consensus::{
Command2, CommitHeader, ConsensusError, ConsensusHeader, DoViewChangeHeader, EvictionHeader,
EvictionReason, GenericHeader, HEADER_SIZE, Operation, PrepareHeader, PrepareOkHeader,
ReplyHeader, RequestHeader, SIZE_FIELD_OFFSET, StartViewChangeHeader, StartViewHeader,
read_size_field,
RESERVED_COMMAND_LEN, ReplyHeader, RequestHeader, SIZE_FIELD_OFFSET, StartViewChangeHeader,
StartViewHeader, read_size_field,
};
pub use dispatch::{COMMAND_TABLE, CommandMeta, lookup_by_operation, lookup_command};
pub use error::WireError;
Expand Down
71 changes: 71 additions & 0 deletions core/configs/src/server_config/cluster.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,47 @@ pub struct ClusterConfig {
/// every node so operators ship one config. The running node's identity
/// is supplied out-of-band via the `--replica-id` CLI flag, which
/// selects the entry in this list that describes the current node.
//
// TODO(hubcio): IGGY-155 `register-replica` CLI (a validated roster
// append) is deferred - it is convenience only over a manual TOML edit,
// and `ClusterConfig::validate` already rejects a malformed roster at
// boot. Add it only if scripted/automated roster edits become a need.
#[serde(default)]
pub nodes: Vec<ClusterNodeConfig>,
/// Replica-to-replica authentication settings (PSK + BLAKE3 handshake).
#[serde(default)]
pub auth: ClusterAuthConfig,
}

/// Replica-to-replica authentication for the consensus (`tcp_replica`) port.
#[derive(Debug, Default, Deserialize, Serialize, Clone, ConfigEnv)]
#[serde(deny_unknown_fields)]
pub struct ClusterAuthConfig {
/// When true, every replica peer must complete the authenticated handshake
/// or be rejected, and [`Self::shared_secret`] is mandatory. When false
/// (default) the replica handshake stays in legacy unauthenticated mode and
/// `shared_secret` is not used for authentication. A configured non-empty
/// `shared_secret` must still meet the 32-byte minimum whenever the cluster
/// is enabled (a short value fails boot even with auth off).
///
/// Enabling auth is a coordinated-restart change, and not the only one: the
/// consensus `cluster_id` is derived from `ClusterConfig::name`
/// unconditionally, so a mixed-version roster fails to connect regardless of
/// this flag. Flip every node in one restart.
#[serde(default)]
pub enabled: bool,
/// Cluster-wide pre-shared key for replica-to-replica authentication.
///
/// At least 32 bytes of CSPRNG output, byte-identical across every node.
/// Provisioned out-of-band, normally via `IGGY_CLUSTER_AUTH_SHARED_SECRET`
/// rather than the on-disk config.
// skip_serializing keeps the PSK out of the runtime `current_config.toml`
// (and the `ServerConfig` diagnostic snapshot that cats it). The live
// secret is read from env / on-disk config at boot, never from the
// snapshot, so it must never be persisted there. Deserialize is retained.
#[serde(default, skip_serializing)]
#[config_env(secret)]
pub shared_secret: String,
}

#[derive(Debug, Deserialize, Serialize, Clone, ConfigEnv)]
Expand All @@ -52,3 +91,35 @@ pub struct TransportPorts {
/// Dedicated port for replica-to-replica consensus traffic.
pub tcp_replica: Option<u16>,
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn shared_secret_is_never_serialized() {
// Regression guard: the runtime current_config.toml (and the
// ServerConfig diagnostic snapshot that cats it) are produced by
// serializing this struct, so the PSK must not survive serialize.
// skip_serializing is format-agnostic, so a JSON dump proves the toml
// path too.
let config = ClusterConfig {
enabled: true,
name: "iggy-cluster".to_owned(),
nodes: Vec::new(),
auth: ClusterAuthConfig {
enabled: true,
shared_secret: "current-psk-MUST-NOT-be-persisted".to_owned(),
},
};
let serialized = serde_json::to_string(&config).expect("serialize cluster config");
assert!(
!serialized.contains("MUST-NOT-be-persisted"),
"PSK leaked into serialized config: {serialized}"
);
assert!(
!serialized.contains("shared_secret"),
"shared_secret field present in serialized config: {serialized}"
);
}
}
3 changes: 2 additions & 1 deletion core/configs/src/server_config/defaults.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
// specific language governing permissions and limitations
// under the License.

use super::cluster::{ClusterConfig, ClusterNodeConfig, TransportPorts};
use super::cluster::{ClusterAuthConfig, ClusterConfig, ClusterNodeConfig, TransportPorts};
use super::http::{HttpConfig, HttpCorsConfig, HttpJwtConfig, HttpMetricsConfig, HttpTlsConfig};
use super::quic::{QuicCertificateConfig, QuicConfig, QuicSocketConfig};
use super::server::{
Expand Down Expand Up @@ -605,6 +605,7 @@ impl Default for ClusterConfig {
},
})
.collect(),
auth: ClusterAuthConfig::default(),
}
}
}
38 changes: 38 additions & 0 deletions core/configs/src/server_config/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,13 @@ pub struct HttpJwtConfig {
#[config_env(leaf)]
#[serde_as(as = "DisplayFromStr")]
pub not_before: IggyDuration,
// skip_serializing keeps the secrets out of the runtime current_config.toml
// (and the diagnostic snapshot that cats it). The live secrets are read from
// env / on-disk config at boot, never from the snapshot.
#[serde(default, skip_serializing)]
#[config_env(secret)]
pub encoding_secret: String,
#[serde(default, skip_serializing)]
#[config_env(secret)]
pub decoding_secret: String,
pub use_base64_secret: bool,
Expand Down Expand Up @@ -158,3 +163,36 @@ impl HttpJwtConfig {
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn jwt_secrets_are_never_serialized() {
// current_config.toml (and the diagnostic snapshot that cats it) is
// produced by serializing this struct, so the JWT secrets must not
// survive a serialize. Built via deserialize to skip enumerating the
// duration fields; skip_serializing is format-agnostic, so a JSON dump
// proves the toml path too.
let json = r#"{
"algorithm": "HS256",
"issuer": "iggy.apache.org",
"audience": "iggy.apache.org",
"valid_issuers": ["iggy.apache.org"],
"valid_audiences": ["iggy.apache.org"],
"access_token_expiry": "1 h",
"clock_skew": "5 s",
"not_before": "0 s",
"encoding_secret": "encoding-MUST-NOT-be-persisted",
"decoding_secret": "decoding-MUST-NOT-be-persisted",
"use_base64_secret": false
}"#;
let config: HttpJwtConfig = serde_json::from_str(json).expect("deserialize jwt config");
let serialized = serde_json::to_string(&config).expect("serialize jwt config");
assert!(
!serialized.contains("MUST-NOT-be-persisted"),
"JWT secret leaked into serialized config: {serialized}"
);
}
}
26 changes: 26 additions & 0 deletions core/configs/src/server_config/system.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@ pub struct LoggingConfig {
#[derive(Debug, Deserialize, Serialize, ConfigEnv)]
pub struct EncryptionConfig {
pub enabled: bool,
// skip_serializing keeps the key out of the runtime current_config.toml (and
// the diagnostic snapshot that cats it). The live key is read from env /
// on-disk config at boot, never from the snapshot.
#[serde(default, skip_serializing)]
#[config_env(secret)]
pub key: String,
}
Expand Down Expand Up @@ -354,3 +358,25 @@ impl SystemPaths for SystemConfig {
SystemConfig::get_runtime_path(self)
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn encryption_key_is_never_serialized() {
// current_config.toml (and the diagnostic snapshot that cats it) is
// produced by serializing this struct, so the key must not survive a
// serialize. skip_serializing is format-agnostic, so a JSON dump proves
// the toml path too.
let config = EncryptionConfig {
enabled: true,
key: "encryption-key-MUST-NOT-be-persisted".to_owned(),
};
let serialized = serde_json::to_string(&config).expect("serialize encryption config");
assert!(
!serialized.contains("MUST-NOT-be-persisted"),
"encryption key leaked into serialized config: {serialized}"
);
}
}
Loading
Loading