Skip to content

Commit f318b79

Browse files
authored
fix(server-ng): add messages encryption (#3644)
This PR adds support for `messages` encryption in `server-ng` binary.
1 parent 3c93526 commit f318b79

12 files changed

Lines changed: 213 additions & 37 deletions

File tree

core/integration/tests/server/scenarios/encryption_scenario.rs

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,10 @@ async fn should_fill_data_with_headers_and_verify_after_restart_using_api(encryp
9393
.await
9494
.unwrap();
9595

96+
// server-ng has no flush primitive (FLUSH_UNSAVED_BUFFER denies typed);
97+
// the eager-flush envs in `build_server_config` make every committed
98+
// batch hit disk instead.
99+
#[cfg(not(feature = "vsr"))]
96100
client
97101
.flush_unsaved_buffer(
98102
&Identifier::named(stream_name).unwrap(),
@@ -103,7 +107,7 @@ async fn should_fill_data_with_headers_and_verify_after_restart_using_api(encryp
103107
.await
104108
.unwrap();
105109

106-
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
110+
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
107111

108112
// Verify on-disk encryption of headers and payload
109113
let data_path = harness.server().data_path();
@@ -247,6 +251,10 @@ async fn should_fill_data_with_headers_and_verify_after_restart_using_api(encryp
247251
.await
248252
.unwrap();
249253

254+
// server-ng has no flush primitive (FLUSH_UNSAVED_BUFFER denies typed);
255+
// the eager-flush envs in `build_server_config` make every committed
256+
// batch hit disk instead.
257+
#[cfg(not(feature = "vsr"))]
250258
client
251259
.flush_unsaved_buffer(
252260
&Identifier::named(stream_name).unwrap(),
@@ -257,7 +265,7 @@ async fn should_fill_data_with_headers_and_verify_after_restart_using_api(encryp
257265
.await
258266
.unwrap();
259267

260-
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
268+
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
261269

262270
let polled = client
263271
.poll_messages(
@@ -490,6 +498,17 @@ fn encryption_disabled() -> bool {
490498
fn build_server_config(encryption: bool) -> TestServerConfig {
491499
let mut extra_envs = HashMap::new();
492500

501+
// server-ng flushes on the journal thresholds (no flush primitive), so
502+
// force every committed batch straight to disk for the on-disk asserts.
503+
extra_envs.insert(
504+
"IGGY_SYSTEM_PARTITION_MESSAGES_REQUIRED_TO_SAVE".to_string(),
505+
"1".to_string(),
506+
);
507+
extra_envs.insert(
508+
"IGGY_SYSTEM_PARTITION_ENFORCE_FSYNC".to_string(),
509+
"true".to_string(),
510+
);
511+
493512
if encryption {
494513
extra_envs.insert(
495514
"IGGY_SYSTEM_ENCRYPTION_ENABLED".to_string(),

core/integration/tests/server/scenarios/mod.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ pub mod create_message_payload;
3434
// shard-0 HTTP listener and the create/delete commit through the metadata STM,
3535
// so the token replicates to every shard a TCP client may land on.
3636
pub mod cross_protocol_pat_scenario;
37-
#[cfg(not(feature = "vsr"))]
3837
pub mod encryption_scenario;
3938
pub mod invalid_consumer_offset_scenario;
4039
// Asserts server log-file rotation/archival policies; server-ng's file

core/partitions/src/iggy_partitions.rs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,10 @@ use crate::{IggyPartition, Partition, PollingArgs, PollingConsumer};
2323
use ahash::AHashSet;
2424
use consensus::{Consensus, Plane, PlaneIdentity, VsrConsensus};
2525
use iggy_binary_protocol::{
26-
Command2, ConsensusHeader, PrepareHeader, PrepareOkHeader, RequestHeader,
26+
Command2, ConsensusHeader, Operation, PrepareHeader, PrepareOkHeader, RequestHeader,
2727
};
2828
use message_bus::MessageBus;
29+
use server_common::send_messages2::{convert_request_message, encrypt_batch_request};
2930
use server_common::sharding::{IggyNamespace, LocalIdx, ShardId};
3031
#[cfg(debug_assertions)]
3132
use std::cell::Cell;
@@ -468,6 +469,32 @@ where
468469
);
469470
return;
470471
}
472+
// At-rest encryption happens HERE, once, before the op enters
473+
// consensus: canonicalize the wire form first so both the legacy and
474+
// v2 request encodings encrypt identically, then encrypt payload +
475+
// user headers per message. The ciphertext is what gets journaled,
476+
// replicated, checksummed, and persisted -- every replica stores
477+
// identical bytes -- and the poll reply is the single decrypt point.
478+
let message = if message.header().operation == Operation::SendMessages
479+
&& let Some(encryptor) = &self.config().encryptor
480+
{
481+
let canonical = convert_request_message(namespace, message)
482+
.and_then(|message| encrypt_batch_request(message, encryptor));
483+
match canonical {
484+
Ok(message) => message,
485+
Err(error) => {
486+
warn!(
487+
target: "iggy.partitions.diag",
488+
namespace_raw = namespace.inner(),
489+
%error,
490+
"dropping send_messages: failed to encrypt batch at ingestion"
491+
);
492+
return;
493+
}
494+
}
495+
} else {
496+
message
497+
};
471498
let Some(partition) = self.get_mut_by_ns(&namespace) else {
472499
warn!(
473500
target: "iggy.partitions.diag",

core/partitions/src/types.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,10 @@
1515
// specific language governing permissions and limitations
1616
// under the License.
1717

18-
use iggy_common::{IggyByteSize, PollingStrategy};
18+
use iggy_common::{EncryptorKind, IggyByteSize, PollingStrategy};
1919
use server_common::iobuf::Frozen;
2020
use smallvec::SmallVec;
21+
use std::sync::Arc;
2122

2223
#[derive(Debug, Clone)]
2324
pub struct Fragment<const ALIGN: usize = 4096> {
@@ -216,6 +217,13 @@ pub struct PartitionsConfig {
216217
pub enforce_fsync: bool,
217218
/// Maximum size of a single segment before rotation.
218219
pub segment_size: IggyByteSize,
220+
/// Server-side at-rest encryption. Applied ONCE, on the primary at
221+
/// ingestion, so the ciphertext replicates verbatim: every replica
222+
/// journals, acks, and persists identical bytes (checksums and the
223+
/// deterministic segment rolls both depend on that), and the poll path
224+
/// decrypts uniformly whether a fragment came from the resident journal
225+
/// or from disk.
226+
pub encryptor: Option<Arc<EncryptorKind>>,
219227
}
220228

221229
impl PartitionsConfig {

core/server-ng/src/bootstrap.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ use iggy_common::defaults::{
4646
DEFAULT_ROOT_USERNAME, MAX_PASSWORD_LENGTH, MAX_USERNAME_LENGTH, MIN_PASSWORD_LENGTH,
4747
MIN_USERNAME_LENGTH,
4848
};
49-
use iggy_common::{IggyByteSize, PartitionStats, variadic};
49+
use iggy_common::{Aes256GcmEncryptor, EncryptorKind, IggyByteSize, PartitionStats, variadic};
5050
use journal::Journal;
5151
use journal::prepare_journal::PrepareJournal;
5252
use message_bus::client_listener::{self, RequestHandler};
@@ -1414,6 +1414,16 @@ async fn build_shard_for_thread(
14141414
let owned_partitions_capacity = total_partitions
14151415
.div_ceil(usize::from(total_shards).max(1))
14161416
.saturating_mul(2);
1417+
// At-rest encryption: built once per shard from the shared config; the
1418+
// ingestion path encrypts on the primary and the poll reply decrypts.
1419+
// A bad key fails the boot rather than silently serving plaintext.
1420+
let encryptor = if config.system.encryption.enabled {
1421+
let aes = Aes256GcmEncryptor::from_base64_key(&config.system.encryption.key)
1422+
.map_err(|error| ServerNgError::Iggy(Box::new(error)))?;
1423+
Some(Arc::new(EncryptorKind::Aes256Gcm(aes)))
1424+
} else {
1425+
None
1426+
};
14171427
let partitions = IggyPartitions::with_capacity(
14181428
shard_local_id,
14191429
PartitionsConfig {
@@ -1424,6 +1434,7 @@ async fn build_shard_for_thread(
14241434
.size_of_messages_required_to_save,
14251435
enforce_fsync: config.system.partition.enforce_fsync,
14261436
segment_size: config.system.segment.size,
1437+
encryptor,
14271438
},
14281439
owned_partitions_capacity,
14291440
);

core/server-ng/src/dispatch.rs

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1512,15 +1512,20 @@ async fn handle_poll_messages(
15121512
Some(PartitionReadReply::Poll {
15131513
fragments,
15141514
current_offset,
1515-
}) => build_polled_messages_body(partition_id, current_offset, fragments)
1516-
.unwrap_or_else(|error| {
1517-
warn!(
1518-
transport_client_id,
1519-
error = %error,
1520-
"failed to re-encode polled batches; replying empty poll"
1521-
);
1522-
empty_polled_messages_body(partition_id)
1523-
}),
1515+
}) => build_polled_messages_body(
1516+
partition_id,
1517+
current_offset,
1518+
fragments,
1519+
shard.plane.partitions().config().encryptor.as_deref(),
1520+
)
1521+
.unwrap_or_else(|error| {
1522+
warn!(
1523+
transport_client_id,
1524+
error = %error,
1525+
"failed to re-encode polled batches; replying empty poll"
1526+
);
1527+
empty_polled_messages_body(partition_id)
1528+
}),
15241529
other => {
15251530
warn!(
15261531
transport_client_id,

core/server-ng/src/http/handlers.rs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ use axum::extract::{Path, Query, State};
2727
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header};
2828
use axum::response::{IntoResponse, Response};
2929
use chrono::Local;
30-
use consensus::MetadataHandle;
30+
use consensus::{MetadataHandle, PartitionsHandle};
3131
use iggy_binary_protocol::codes::{
3232
GET_CONSUMER_GROUP_CODE, GET_CONSUMER_GROUPS_CODE, GET_PERSONAL_ACCESS_TOKENS_CODE,
3333
GET_STATS_CODE, GET_STREAM_CODE, GET_STREAMS_CODE, GET_TOPIC_CODE, GET_TOPICS_CODE,
@@ -1033,8 +1033,13 @@ pub(in crate::http) async fn poll_messages(
10331033
fragments,
10341034
current_offset,
10351035
}) => {
1036-
let body = build_polled_messages_body(partition_id, current_offset, fragments)
1037-
.map_err(ReadError::Rejected)?;
1036+
let body = build_polled_messages_body(
1037+
partition_id,
1038+
current_offset,
1039+
fragments,
1040+
state.shard.plane.partitions().config().encryptor.as_deref(),
1041+
)
1042+
.map_err(ReadError::Rejected)?;
10381043
Ok(Json(
10391044
PolledMessages::from_bytes(body).map_err(ReadError::Rejected)?,
10401045
))

core/server-ng/src/http/wire.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -509,9 +509,13 @@ mod tests {
509509
header.base_offset = 41;
510510
header.base_timestamp = 999_999;
511511

512-
let body =
513-
build_polled_messages_body(3, 42, fragment_from_stored_batch(&header, &stored.blob))
514-
.expect("re-encodes wire body");
512+
let body = build_polled_messages_body(
513+
3,
514+
42,
515+
fragment_from_stored_batch(&header, &stored.blob),
516+
None,
517+
)
518+
.expect("re-encodes wire body");
515519
let polled = PolledMessages::from_bytes(body).expect("decodes as the SDK does");
516520

517521
assert_eq!(polled.partition_id, 3);

core/server-ng/src/partition_reconciler.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1054,6 +1054,7 @@ mod tests {
10541054
size_of_messages_required_to_save: iggy_common::IggyByteSize::from(1024_u64),
10551055
enforce_fsync: false,
10561056
segment_size: config.system.segment.size,
1057+
encryptor: None,
10571058
},
10581059
);
10591060
let shards_table = PapayaShardsTable::new();

core/server-ng/src/responses.rs

Lines changed: 52 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ use iggy_binary_protocol::{
7777
Command2, GenericHeader, IGGY_PROTOCOL_VERSION, KIND_CONSUMER_GROUP, Operation, ReplyHeader,
7878
RequestHeader, WireDecode, WireEncode, WireIdentifier, WireName, WirePartitioning,
7979
};
80-
use iggy_common::{Identifier, IggyError, IggyTimestamp, MaxTopicSize};
80+
use iggy_common::{EncryptorKind, Identifier, IggyError, IggyTimestamp, MaxTopicSize};
8181
use metadata::impls::metadata::StreamsFrontend;
8282
use partitions::PollFragments;
8383
use server_common::Message;
@@ -1307,6 +1307,7 @@ pub(crate) fn build_polled_messages_body(
13071307
partition_id: u32,
13081308
current_offset: u64,
13091309
fragments: PollFragments,
1310+
encryptor: Option<&EncryptorKind>,
13101311
) -> Result<Bytes, IggyError> {
13111312
// Body head: [partition_id:4][current_offset:8][count:4]. `count` sits at
13121313
// COUNT_OFFSET and is backpatched once the walk below knows it.
@@ -1374,20 +1375,56 @@ pub(crate) fn build_polled_messages_body(
13741375
body.extend_from_slice(&offset.to_le_bytes());
13751376
body.extend_from_slice(&timestamp.to_le_bytes());
13761377
body.extend_from_slice(&origin_timestamp.to_le_bytes());
1377-
body.extend_from_slice(
1378-
&u32::try_from(user_headers_length)
1379-
.expect("length came from u32")
1380-
.to_le_bytes(),
1381-
);
1382-
body.extend_from_slice(
1383-
&u32::try_from(payload_length)
1384-
.expect("length came from u32")
1385-
.to_le_bytes(),
1386-
);
1387-
body.extend_from_slice(&0u64.to_le_bytes()); // reserved
1388-
// Stored sections are already in legacy order
1389-
// (`[payload][user_headers]`): copy through contiguously.
1390-
body.extend_from_slice(&stream[sections_start..sections_end]);
1378+
if let Some(encryptor) = encryptor {
1379+
// At-rest encryption: stored sections are ciphertext (encrypted
1380+
// once at ingestion, replicated verbatim); this reply is the
1381+
// single decrypt point, so lengths are rewritten to the
1382+
// plaintext sizes. The stored per-message checksum still covers
1383+
// the ciphertext and is passed through untouched (the SDK does
1384+
// not re-validate it against the reply layout).
1385+
let payload_end = sections_start + payload_length;
1386+
let payload = encryptor
1387+
.decrypt(&stream[sections_start..payload_end])
1388+
.map_err(|_| IggyError::CannotDecryptData)?;
1389+
let user_headers = if user_headers_length > 0 {
1390+
Some(
1391+
encryptor
1392+
.decrypt(&stream[payload_end..sections_end])
1393+
.map_err(|_| IggyError::CannotDecryptData)?,
1394+
)
1395+
} else {
1396+
None
1397+
};
1398+
let user_headers_bytes: &[u8] = user_headers.as_deref().unwrap_or_default();
1399+
body.extend_from_slice(
1400+
&u32::try_from(user_headers_bytes.len())
1401+
.map_err(|_| IggyError::InvalidCommand)?
1402+
.to_le_bytes(),
1403+
);
1404+
body.extend_from_slice(
1405+
&u32::try_from(payload.len())
1406+
.map_err(|_| IggyError::InvalidCommand)?
1407+
.to_le_bytes(),
1408+
);
1409+
body.extend_from_slice(&0u64.to_le_bytes()); // reserved
1410+
body.extend_from_slice(&payload);
1411+
body.extend_from_slice(user_headers_bytes);
1412+
} else {
1413+
body.extend_from_slice(
1414+
&u32::try_from(user_headers_length)
1415+
.expect("length came from u32")
1416+
.to_le_bytes(),
1417+
);
1418+
body.extend_from_slice(
1419+
&u32::try_from(payload_length)
1420+
.expect("length came from u32")
1421+
.to_le_bytes(),
1422+
);
1423+
body.extend_from_slice(&0u64.to_le_bytes()); // reserved
1424+
// Stored sections are already in legacy order
1425+
// (`[payload][user_headers]`): copy through contiguously.
1426+
body.extend_from_slice(&stream[sections_start..sections_end]);
1427+
}
13911428

13921429
count += 1;
13931430
cursor = sections_end;

0 commit comments

Comments
 (0)