Skip to content

Commit bce9db3

Browse files
committed
Merge branch 'security': harden cluster bootstrap and CRDT sync
Reconciles the security hardening with main's CRDT tenant_state refactor and RowPush/pgwire work. Conflict resolutions: - crdt apply path: combine security's detached-candidate validated apply (+ signing admission) with main's PendingDependencies outcome, mapping a causally-pending candidate import to PendingDependencies rather than a silent clean no-op. Ported security's changes into main's split modules (import_snapshot_bytes -> snapshot_io, apply_committed_delta demoted to test-only in apply, delta-signing setters -> constraints) and handled the new outcome at every apply site. - sync listener: keep main's bind/serve split and configurable port; move security's loopback-only guard into bind_sync_listener so both the boot and convenience paths enforce it; keep both test sets. - session RowPush literal: main's RowPushMsg (signing fields belong on DeltaPushMsg, which retains them). transport_security baseline tests (l1_same_ca / l2_many_sequential) now register peer identities in a shared topology so the hardened identity store admits them.
2 parents a2fdf9e + 8822e4c commit bce9db3

117 files changed

Lines changed: 5211 additions & 959 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/test.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ jobs:
6464
run: bash scripts/ci/check_calvin_determinism.sh
6565
- name: Plane-separation gate
6666
run: bash scripts/ci/check_plane_separation.sh
67+
- name: Raft transport-boundary gate
68+
run: python3 scripts/ci/check_raft_transport_boundary.py
6769
- name: Reconstructed-SQL gate
6870
run: |
6971
python3 scripts/ci/check_reconstructed_sql.py --self-test

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

nodedb-cluster-tests/tests/bootstrap_listener_join.rs

Lines changed: 137 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,27 @@ fn epoch_ms() -> u64 {
2929
.unwrap_or(0)
3030
}
3131

32-
fn mint_token(secret: &[u8; 32], for_node: u64, ttl_secs: u64) -> String {
32+
fn mint_token(
33+
secret: &[u8; 32],
34+
for_node: u64,
35+
ttl_secs: u64,
36+
bootstrap_issuer_spki: [u8; 32],
37+
bootstrap_ca_der: &[u8],
38+
) -> String {
3339
use std::time::{SystemTime, UNIX_EPOCH};
3440
let expiry = SystemTime::now()
3541
.duration_since(UNIX_EPOCH)
3642
.unwrap()
3743
.as_secs()
3844
+ ttl_secs;
39-
tok::issue_token(secret, for_node, expiry).unwrap()
45+
tok::issue_token(
46+
secret,
47+
for_node,
48+
expiry,
49+
bootstrap_issuer_spki,
50+
bootstrap_ca_der,
51+
)
52+
.unwrap()
4053
}
4154

4255
/// A `BootstrapHandler` that wires token state machine + audit writer
@@ -72,12 +85,12 @@ impl<B: TokenStateBackend, A: AuditWriter> BootstrapHandler for StatefulBootstra
7285
fn handle<'a>(
7386
&'a self,
7487
req: BootstrapCredsRequest,
88+
remote_addr: SocketAddr,
7589
) -> std::pin::Pin<Box<dyn std::future::Future<Output = BootstrapCredsResponse> + Send + 'a>>
7690
{
7791
Box::pin(async move {
7892
// Step 1: constant-time HMAC verification.
79-
let (token_node, expiry_secs) = match verify_token(&req.token_hex, &self.cluster_secret)
80-
{
93+
let verified = match verify_token(&req.token_hex, &self.cluster_secret) {
8194
Ok(v) => v,
8295
Err(e) => {
8396
// PERSIST audit before responding (acquire-log-then-respond).
@@ -95,7 +108,7 @@ impl<B: TokenStateBackend, A: AuditWriter> BootstrapHandler for StatefulBootstra
95108
}
96109
};
97110

98-
if token_node != req.node_id {
111+
if verified.for_node != req.node_id {
99112
let th = tok::token_hash(&req.token_hex).unwrap_or([0u8; 32]);
100113
self.audit
101114
.append(nodedb_cluster::auth::audit::AuditEvent::new(
@@ -107,8 +120,8 @@ impl<B: TokenStateBackend, A: AuditWriter> BootstrapHandler for StatefulBootstra
107120
},
108121
));
109122
return BootstrapCredsResponse::error(format!(
110-
"node id mismatch: token bound to {token_node}, request claims {}",
111-
req.node_id
123+
"node id mismatch: token bound to {}, request claims {}",
124+
verified.for_node, req.node_id
112125
));
113126
}
114127

@@ -119,15 +132,18 @@ impl<B: TokenStateBackend, A: AuditWriter> BootstrapHandler for StatefulBootstra
119132
}
120133
};
121134

122-
// Step 2: check token state machine.
123-
// Use a dummy addr since we don't have the remote addr here.
124-
let dummy_addr: SocketAddr = "0.0.0.0:0".parse().unwrap();
125-
match self
135+
if verified.bootstrap_ca_der.as_slice() != self.ca.cert_der().as_ref() {
136+
return BootstrapCredsResponse::error("token CA mismatch");
137+
}
138+
let expiry_secs = verified.expiry_unix_secs;
139+
140+
// Step 2: check token state machine, bound to the actual peer.
141+
let lease = match self
126142
.token_store
127-
.begin_inflight(&token_hash, dummy_addr)
143+
.begin_inflight(&token_hash, remote_addr)
128144
.await
129145
{
130-
Ok(()) => {}
146+
Ok(lease) => lease,
131147
Err(TokenStateError::AlreadyConsumed) => {
132148
self.audit
133149
.append(nodedb_cluster::auth::audit::AuditEvent::new(
@@ -160,40 +176,43 @@ impl<B: TokenStateBackend, A: AuditWriter> BootstrapHandler for StatefulBootstra
160176
attempt: 0,
161177
})
162178
.await;
163-
if self
179+
match self
164180
.token_store
165-
.begin_inflight(&token_hash, dummy_addr)
181+
.begin_inflight(&token_hash, remote_addr)
166182
.await
167-
.is_err()
168183
{
169-
return BootstrapCredsResponse::error("token state conflict");
184+
Ok(lease) => lease,
185+
Err(_) => {
186+
return BootstrapCredsResponse::error("token state conflict");
187+
}
170188
}
171189
}
172190
Err(e) => {
173191
return BootstrapCredsResponse::error(format!("token state: {e}"));
174192
}
175-
}
193+
};
176194

177195
// Spawn the dead-man timer.
178196
nodedb_cluster::auth::token_state::spawn_inflight_timeout(
179197
Arc::clone(&self.token_store),
180198
token_hash,
199+
lease,
181200
self.inflight_timeout,
182201
);
183202

184203
// Step 3: issue creds.
185204
let resp = match self.issue(req.node_id) {
186205
Ok(r) => r,
187206
Err(e) => {
188-
let _ = self.token_store.revert_inflight(&token_hash).await;
207+
let _ = self.token_store.revert_inflight(&token_hash, lease).await;
189208
return BootstrapCredsResponse::error(e);
190209
}
191210
};
192211

193212
// Step 4: transition to Consumed — persist audit BEFORE returning.
194213
let _ = self
195214
.token_store
196-
.mark_consumed(&token_hash, dummy_addr, epoch_ms())
215+
.mark_consumed(&token_hash, remote_addr, lease, epoch_ms(), Vec::new())
197216
.await;
198217
self.audit
199218
.append(nodedb_cluster::auth::audit::AuditEvent::new(
@@ -227,18 +246,38 @@ fn make_handler(
227246
(h, audit)
228247
}
229248

249+
fn spawn_test_listener<H: BootstrapHandler>(
250+
ca: &nexar::transport::tls::ClusterCa,
251+
handler: Arc<H>,
252+
shutdown: tokio::sync::watch::Receiver<bool>,
253+
) -> (SocketAddr, tokio::task::JoinHandle<()>, [u8; 32]) {
254+
let credentials =
255+
issue_leaf_for_sans(ca, &[nodedb_cluster::transport::config::SNI_HOSTNAME]).unwrap();
256+
let issuer_spki = credentials.spki_pin;
257+
let (addr, task) = spawn_listener(
258+
"127.0.0.1:0".parse().unwrap(),
259+
credentials.cert,
260+
credentials.key,
261+
handler,
262+
shutdown,
263+
)
264+
.unwrap();
265+
(addr, task, issuer_spki)
266+
}
267+
230268
// ── Tests ────────────────────────────────────────────────────────────────────
231269

232270
#[tokio::test]
233271
async fn valid_token_accepted_and_audit_logged() {
234272
let secret = [0x11u8; 32];
235273
let (handler, audit) = make_handler(secret);
274+
let ca = Arc::clone(&handler.ca);
236275

237276
let (tx, rx) = tokio::sync::watch::channel(false);
238-
let (local, _join) = spawn_listener("127.0.0.1:0".parse().unwrap(), handler, rx).unwrap();
277+
let (local, _join, issuer_spki) = spawn_test_listener(&ca, handler, rx);
239278
tokio::time::sleep(Duration::from_millis(30)).await;
240279

241-
let token = mint_token(&secret, 5, 60);
280+
let token = mint_token(&secret, 5, 60, issuer_spki, ca.cert_der().as_ref());
242281
let resp =
243282
nodedb_cluster::bootstrap_listener::fetch_creds(local, &token, 5, Duration::from_secs(3))
244283
.await
@@ -262,17 +301,72 @@ async fn valid_token_accepted_and_audit_logged() {
262301
tx.send(true).unwrap();
263302
}
264303

304+
#[tokio::test]
305+
async fn token_bound_to_wrong_ca_is_rejected_before_bootstrap_request() {
306+
let secret = [0x12u8; 32];
307+
let (handler, audit) = make_handler(secret);
308+
let server_ca = Arc::clone(&handler.ca);
309+
let (other_ca, other_creds) = generate_node_credentials_multi_san(&["other-ca"]).unwrap();
310+
let token = mint_token(
311+
&secret,
312+
6,
313+
60,
314+
other_creds.spki_pin,
315+
other_ca.cert_der().as_ref(),
316+
);
317+
318+
let (tx, rx) = tokio::sync::watch::channel(false);
319+
let (local, _join, _) = spawn_test_listener(&server_ca, handler, rx);
320+
tokio::time::sleep(Duration::from_millis(30)).await;
321+
322+
let error =
323+
nodedb_cluster::bootstrap_listener::fetch_creds(local, &token, 6, Duration::from_secs(3))
324+
.await
325+
.unwrap_err();
326+
assert!(error.to_string().contains("bootstrap connect"));
327+
assert!(
328+
audit.snapshot().is_empty(),
329+
"server handler must not see the token"
330+
);
331+
tx.send(true).unwrap();
332+
}
333+
334+
#[tokio::test]
335+
async fn token_bound_to_same_ca_nonissuer_is_rejected_before_request() {
336+
let secret = [0x13u8; 32];
337+
let (handler, audit) = make_handler(secret);
338+
let ca = Arc::clone(&handler.ca);
339+
let nonissuer = issue_leaf_for_sans(&ca, &["nonissuer"]).unwrap();
340+
let token = mint_token(&secret, 6, 60, nonissuer.spki_pin, ca.cert_der().as_ref());
341+
342+
let (tx, rx) = tokio::sync::watch::channel(false);
343+
let (local, _join, _) = spawn_test_listener(&ca, handler, rx);
344+
tokio::time::sleep(Duration::from_millis(30)).await;
345+
346+
let error =
347+
nodedb_cluster::bootstrap_listener::fetch_creds(local, &token, 6, Duration::from_secs(3))
348+
.await
349+
.unwrap_err();
350+
assert!(error.to_string().contains("issuer identity mismatch"));
351+
assert!(
352+
audit.snapshot().is_empty(),
353+
"handler must not see bearer token"
354+
);
355+
tx.send(true).unwrap();
356+
}
357+
265358
#[tokio::test]
266359
async fn invalid_hmac_rejected_audit_logged() {
267360
let secret = [0x22u8; 32];
268361
let (handler, audit) = make_handler(secret);
362+
let ca = Arc::clone(&handler.ca);
269363

270364
let (tx, rx) = tokio::sync::watch::channel(false);
271-
let (local, _join) = spawn_listener("127.0.0.1:0".parse().unwrap(), handler, rx).unwrap();
365+
let (local, _join, issuer_spki) = spawn_test_listener(&ca, handler, rx);
272366
tokio::time::sleep(Duration::from_millis(30)).await;
273367

274368
// Token issued under a different secret.
275-
let bad_token = mint_token(&[0xFFu8; 32], 2, 60);
369+
let bad_token = mint_token(&[0xFFu8; 32], 2, 60, issuer_spki, ca.cert_der().as_ref());
276370
let err = nodedb_cluster::bootstrap_listener::fetch_creds(
277371
local,
278372
&bad_token,
@@ -301,12 +395,13 @@ async fn invalid_hmac_rejected_audit_logged() {
301395
async fn replayed_token_rejected() {
302396
let secret = [0x33u8; 32];
303397
let (handler, audit) = make_handler(secret);
398+
let ca = Arc::clone(&handler.ca);
304399

305400
let (tx, rx) = tokio::sync::watch::channel(false);
306-
let (local, _join) = spawn_listener("127.0.0.1:0".parse().unwrap(), handler, rx).unwrap();
401+
let (local, _join, issuer_spki) = spawn_test_listener(&ca, handler, rx);
307402
tokio::time::sleep(Duration::from_millis(30)).await;
308403

309-
let token = mint_token(&secret, 7, 60);
404+
let token = mint_token(&secret, 7, 60, issuer_spki, ca.cert_der().as_ref());
310405

311406
// First use: should succeed.
312407
let resp1 =
@@ -343,13 +438,15 @@ async fn replayed_token_rejected() {
343438
async fn expired_token_rejected_audit_logged() {
344439
let secret = [0x44u8; 32];
345440
let (handler, audit) = make_handler(secret);
441+
let ca = Arc::clone(&handler.ca);
346442

347443
let (tx, rx) = tokio::sync::watch::channel(false);
348-
let (local, _join) = spawn_listener("127.0.0.1:0".parse().unwrap(), handler, rx).unwrap();
444+
let (local, _join, issuer_spki) = spawn_test_listener(&ca, handler, rx);
349445
tokio::time::sleep(Duration::from_millis(30)).await;
350446

351447
// Issue a token that is already expired (expiry = 1 second past unix epoch).
352-
let expired_token = tok::issue_token(&secret, 3, 1).unwrap();
448+
let expired_token =
449+
tok::issue_token(&secret, 3, 1, issuer_spki, ca.cert_der().as_ref()).unwrap();
353450
let err = nodedb_cluster::bootstrap_listener::fetch_creds(
354451
local,
355452
&expired_token,
@@ -432,8 +529,11 @@ async fn token_state_issued_to_consumed() {
432529
attempt: 0,
433530
})
434531
.await;
435-
store.begin_inflight(&hash, addr).await.unwrap();
436-
store.mark_consumed(&hash, addr, epoch_ms()).await.unwrap();
532+
let lease = store.begin_inflight(&hash, addr).await.unwrap();
533+
store
534+
.mark_consumed(&hash, addr, lease, epoch_ms(), Vec::new())
535+
.await
536+
.unwrap();
437537
let s = store.get(&hash).unwrap();
438538
assert!(matches!(s.lifecycle, JoinTokenLifecycle::Consumed { .. }));
439539
}
@@ -452,8 +552,11 @@ async fn token_state_replay_on_consumed_returns_error() {
452552
attempt: 0,
453553
})
454554
.await;
455-
store.begin_inflight(&hash, addr).await.unwrap();
456-
store.mark_consumed(&hash, addr, epoch_ms()).await.unwrap();
555+
let lease = store.begin_inflight(&hash, addr).await.unwrap();
556+
store
557+
.mark_consumed(&hash, addr, lease, epoch_ms(), Vec::new())
558+
.await
559+
.unwrap();
457560
assert_eq!(
458561
store.begin_inflight(&hash, addr).await.unwrap_err(),
459562
TokenStateError::AlreadyConsumed
@@ -494,8 +597,8 @@ async fn token_state_inflight_reverts_on_timeout() {
494597
attempt: 0,
495598
})
496599
.await;
497-
store.begin_inflight(&hash, addr).await.unwrap();
498-
store.revert_inflight(&hash).await.unwrap();
600+
let lease = store.begin_inflight(&hash, addr).await.unwrap();
601+
store.revert_inflight(&hash, lease).await.unwrap();
499602
let s = store.get(&hash).unwrap();
500603
assert_eq!(s.lifecycle, JoinTokenLifecycle::Issued);
501604
assert_eq!(s.attempt, 1);

nodedb-cluster-tests/tests/sync_delta_reject_unique_violation.rs

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,10 @@
44
//!
55
//! ## What this guards
66
//!
7-
//! Loro CRDT deltas are commutative: once a delta from a peer is imported
8-
//! into the tenant document, it cannot be un-imported — the merge already
9-
//! happened. Constraint enforcement therefore runs *after* import, as a
10-
//! post-hoc validation pass, and a violation cannot roll back the merge.
11-
//! What it CAN do is tell the client precisely what went wrong so the edge
12-
//! can compensate (regenerate the value, prompt the user, etc.) and make
13-
//! sure the offending row is quarantined rather than silently accepted.
7+
//! Peer deltas are imported into a detached candidate and constraint-checked
8+
//! before that candidate can replace authoritative state. A violation must
9+
//! leave the existing state untouched while telling the edge precisely how to
10+
//! compensate (regenerate the value, prompt the user, etc.).
1411
//!
1512
//! This test drives the sync WebSocket end-to-end: a delta that violates a
1613
//! UNIQUE(email) constraint must come back as a `DeltaReject` carrying a

nodedb-cluster-tests/tests/sync_failover.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ async fn cluster_sync_producer_fence_survives_failover() {
5555

5656
const LITE_ID: &str = "device-failover-7x9";
5757
const TENANT: u64 = 0;
58+
const USER: u64 = 7;
5859
const CREATED_MS: i64 = 1_700_000_000_000;
5960

6061
// Mirror the handshake exactly: allocate + write locally on the leader,
@@ -66,13 +67,14 @@ async fn cluster_sync_producer_fence_survives_failover() {
6667
.as_ref()
6768
.expect("producer_registry present on leader");
6869
let registration = reg
69-
.register(LITE_ID, TENANT, 1, CREATED_MS)
70+
.register(LITE_ID, TENANT, USER, 1, CREATED_MS)
7071
.expect("local register");
7172
nodedb::control::metadata_proposer::propose_sync_producer_register(
7273
&cluster.nodes[leader_idx].shared,
7374
LITE_ID,
7475
registration.producer_id,
7576
TENANT,
77+
USER,
7678
registration.current_epoch,
7779
CREATED_MS,
7880
)

0 commit comments

Comments
 (0)