Skip to content

Commit 599abb2

Browse files
committed
feat(db): make rekey recovery durable and key-aware
1 parent f30565e commit 599abb2

34 files changed

Lines changed: 2826 additions & 925 deletions

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.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ chacha20poly1305 = "0.10"
2525
hkdf = "0.12"
2626
hmac = "0.12"
2727
sha2 = "0.10"
28+
subtle = "2.6"
2829
zeroize = { version = "1.8", features = ["zeroize_derive"] }
2930
thiserror = "2"
3031
bytes = "1"

src/catalog/codec.rs

Lines changed: 289 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@ pub enum CatalogRowKind {
1919
/// under this row kind. The page-kind reservation remains available for
2020
/// that later optimisation.
2121
Counter = 0x02,
22-
/// Durable rekey watermark. Cleared on rekey completion. Key is `[0x03]`
23-
/// (singleton; no name suffix). Value is `RekeyState` encoded as 13 bytes:
24-
/// `target_mk_epoch[8] || main_db_done[1] || segments_remaining_idx[4]`.
22+
/// Durable versioned rekey intent. Key is `[0x03]` (singleton; no name
23+
/// suffix). Its fixed-size value records both cryptographic epochs and
24+
/// keys' non-secret proofs; it is never a segment-list index.
2525
RekeyState = 0x03,
2626
// 0x04 and 0x05 are reserved: they were the in-catalog free-list and
2727
// deferred-free queue, superseded by the durable free-list chain rooted in
@@ -32,22 +32,98 @@ pub enum CatalogRowKind {
3232
/// Retained as a row-kind boundary and so any legacy row is recognised and
3333
/// dropped during compaction.
3434
CompactionState = 0x07,
35+
/// Fixed-size progress for one immutable source segment. The key suffix is
36+
/// its old `segment_id`, never a catalog-order index.
37+
RekeySegmentProgress = 0x08,
3538
}
3639

37-
/// Rekey watermark persisted in the catalog during an online rekey operation.
38-
/// A present row means a rekey is in flight or was interrupted by a crash.
40+
/// Explicit durable rekey transition points. They are ordered so recovery can
41+
/// reject an A/B header that is newer than the intent's durable transition.
42+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43+
#[repr(u8)]
44+
pub enum RekeyStage {
45+
Intent = 1,
46+
MainPagesTargetReadable = 2,
47+
HeaderTargetPublished = 3,
48+
MainDone = 4,
49+
SegmentsPending = 5,
50+
}
51+
52+
impl RekeyStage {
53+
fn from_byte(byte: u8) -> Result<Self> {
54+
match byte {
55+
1 => Ok(Self::Intent),
56+
2 => Ok(Self::MainPagesTargetReadable),
57+
3 => Ok(Self::HeaderTargetPublished),
58+
4 => Ok(Self::MainDone),
59+
5 => Ok(Self::SegmentsPending),
60+
_ => Err(PagedbError::corruption(
61+
crate::errors::CorruptionDetail::HeaderUnverifiable,
62+
)),
63+
}
64+
}
65+
}
66+
67+
/// Version-one durable rekey intent. HK proofs are one-way identifiers used to
68+
/// validate caller-provided key material; neither KEKs nor master keys are
69+
/// ever persisted.
3970
#[derive(Debug, Clone, PartialEq, Eq)]
40-
pub struct RekeyStateRow {
41-
/// The `mk_epoch` the rekey is converging toward.
71+
pub struct RekeyIntent {
72+
pub source_mk_epoch: u64,
73+
pub target_mk_epoch: u64,
74+
pub source_cipher_id: u8,
75+
pub target_cipher_id: u8,
76+
pub same_kek: bool,
77+
pub stage: RekeyStage,
78+
pub source_hk_proof: [u8; 16],
79+
pub target_hk_proof: [u8; 16],
80+
}
81+
82+
/// Old, insufficient rekey state. It is decoded only to admit a conservative
83+
/// same-KEK upgrade; its positional segment index is never correctness state.
84+
#[derive(Debug, Clone, PartialEq, Eq)]
85+
pub struct LegacyRekeyState {
4286
pub target_mk_epoch: u64,
43-
/// True once every main.db B+ tree page has been rewritten.
4487
pub main_db_done: bool,
45-
/// Index into the segment list at which resume should start.
46-
/// Segments at indices `< segments_remaining_idx` have been rekeyed.
47-
pub segments_remaining_idx: u32,
88+
pub discarded_segments_index: u32,
89+
}
90+
91+
#[derive(Debug, Clone, PartialEq, Eq)]
92+
pub enum RekeyStateRow {
93+
V1(RekeyIntent),
94+
Legacy(LegacyRekeyState),
95+
}
96+
97+
pub const LEGACY_REKEY_STATE_LEN: usize = 13;
98+
pub const REKEY_INTENT_V1_LEN: usize = 64;
99+
pub const REKEY_SEGMENT_PROGRESS_LEN: usize = 20;
100+
101+
/// Durable state of a replacement segment recorded under its source identity.
102+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103+
#[repr(u8)]
104+
pub enum RekeySegmentProgressState {
105+
/// The replacement file was sealed and synced, but its catalog swap may not
106+
/// yet have been made durable.
107+
Sealed = 1,
48108
}
49109

50-
pub const REKEY_STATE_LEN: usize = 13;
110+
impl RekeySegmentProgressState {
111+
fn from_byte(byte: u8) -> Result<Self> {
112+
match byte {
113+
1 => Ok(Self::Sealed),
114+
_ => Err(PagedbError::corruption(
115+
crate::errors::CorruptionDetail::HeaderUnverifiable,
116+
)),
117+
}
118+
}
119+
}
120+
121+
/// Fixed-width replacement identity for a source segment.
122+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123+
pub struct RekeySegmentProgress {
124+
pub replacement_segment_id: [u8; 16],
125+
pub state: RekeySegmentProgressState,
126+
}
51127

52128
/// Engine-defined segment type tag. This slice ships only `Unspecified`;
53129
/// engine adapters add concrete variants later.
@@ -131,34 +207,139 @@ impl Catalog {
131207
vec![CatalogRowKind::RekeyState as u8]
132208
}
133209

134-
/// Encode a `RekeyStateRow` as 13 bytes.
210+
/// Per-source-segment progress key: `[0x08] || old_segment_id[16]`.
135211
#[must_use]
136-
pub fn encode_rekey_state(r: &RekeyStateRow) -> [u8; REKEY_STATE_LEN] {
137-
let mut o = [0u8; REKEY_STATE_LEN];
138-
o[0..8].copy_from_slice(&r.target_mk_epoch.to_le_bytes());
139-
o[8] = u8::from(r.main_db_done);
140-
o[9..13].copy_from_slice(&r.segments_remaining_idx.to_le_bytes());
141-
o
212+
pub fn rekey_segment_progress_key(old_segment_id: [u8; 16]) -> [u8; 17] {
213+
let mut key = [0u8; 17];
214+
key[0] = CatalogRowKind::RekeySegmentProgress as u8;
215+
key[1..].copy_from_slice(&old_segment_id);
216+
key
142217
}
143218

144-
/// Decode a `RekeyStateRow` from a 13-byte slice.
219+
/// Encode a V1 rekey intent. All reserved bytes are emitted as zero.
220+
#[must_use]
221+
pub fn encode_rekey_intent(intent: &RekeyIntent) -> [u8; REKEY_INTENT_V1_LEN] {
222+
let mut out = [0u8; REKEY_INTENT_V1_LEN];
223+
out[0] = 1;
224+
out[1] = intent.stage as u8;
225+
out[2] = u8::from(intent.same_kek);
226+
out[4..12].copy_from_slice(&intent.source_mk_epoch.to_le_bytes());
227+
out[12..20].copy_from_slice(&intent.target_mk_epoch.to_le_bytes());
228+
out[20] = intent.source_cipher_id;
229+
out[21] = intent.target_cipher_id;
230+
out[24..40].copy_from_slice(&intent.source_hk_proof);
231+
out[40..56].copy_from_slice(&intent.target_hk_proof);
232+
out
233+
}
234+
235+
/// Decode either a fixed V1 intent or the legacy 13-byte row. Legacy
236+
/// positional progress is deliberately preserved only for diagnostics.
145237
pub fn decode_rekey_state(bytes: &[u8]) -> Result<RekeyStateRow> {
146-
if bytes.len() != REKEY_STATE_LEN {
238+
if bytes.len() == LEGACY_REKEY_STATE_LEN {
239+
let target_mk_epoch = u64::from_le_bytes(bytes[0..8].try_into().map_err(|_| {
240+
PagedbError::corruption(crate::errors::CorruptionDetail::HeaderUnverifiable)
241+
})?);
242+
if target_mk_epoch == 0 {
243+
return Err(PagedbError::corruption(
244+
crate::errors::CorruptionDetail::HeaderUnverifiable,
245+
));
246+
}
247+
return Ok(RekeyStateRow::Legacy(LegacyRekeyState {
248+
target_mk_epoch,
249+
main_db_done: match bytes[8] {
250+
0 => false,
251+
1 => true,
252+
_ => {
253+
return Err(PagedbError::corruption(
254+
crate::errors::CorruptionDetail::HeaderUnverifiable,
255+
));
256+
}
257+
},
258+
discarded_segments_index: u32::from_le_bytes(bytes[9..13].try_into().map_err(
259+
|_| {
260+
PagedbError::corruption(crate::errors::CorruptionDetail::HeaderUnverifiable)
261+
},
262+
)?),
263+
}));
264+
}
265+
if bytes.len() != REKEY_INTENT_V1_LEN
266+
|| bytes[0] != 1
267+
|| bytes[3] != 0
268+
|| bytes[22..24].iter().any(|byte| *byte != 0)
269+
|| bytes[56..].iter().any(|byte| *byte != 0)
270+
{
147271
return Err(PagedbError::corruption(
148272
crate::errors::CorruptionDetail::HeaderUnverifiable,
149273
));
150274
}
151-
let mut ep = [0u8; 8];
152-
ep.copy_from_slice(&bytes[0..8]);
153-
let target_mk_epoch = u64::from_le_bytes(ep);
154-
let main_db_done = bytes[8] != 0;
155-
let mut idx = [0u8; 4];
156-
idx.copy_from_slice(&bytes[9..13]);
157-
let segments_remaining_idx = u32::from_le_bytes(idx);
158-
Ok(RekeyStateRow {
275+
let source_mk_epoch = u64::from_le_bytes(bytes[4..12].try_into().map_err(|_| {
276+
PagedbError::corruption(crate::errors::CorruptionDetail::HeaderUnverifiable)
277+
})?);
278+
let target_mk_epoch = u64::from_le_bytes(bytes[12..20].try_into().map_err(|_| {
279+
PagedbError::corruption(crate::errors::CorruptionDetail::HeaderUnverifiable)
280+
})?);
281+
if target_mk_epoch == 0 || target_mk_epoch <= source_mk_epoch {
282+
return Err(PagedbError::corruption(
283+
crate::errors::CorruptionDetail::HeaderUnverifiable,
284+
));
285+
}
286+
let same_kek = match bytes[2] {
287+
0 => false,
288+
1 => true,
289+
_ => {
290+
return Err(PagedbError::corruption(
291+
crate::errors::CorruptionDetail::HeaderUnverifiable,
292+
));
293+
}
294+
};
295+
crate::crypto::CipherId::from_byte(bytes[20])?;
296+
crate::crypto::CipherId::from_byte(bytes[21])?;
297+
if bytes[20] != bytes[21] {
298+
return Err(PagedbError::rekey_state_invalid("target_cipher_id"));
299+
}
300+
let mut source_hk_proof = [0u8; 16];
301+
source_hk_proof.copy_from_slice(&bytes[24..40]);
302+
let mut target_hk_proof = [0u8; 16];
303+
target_hk_proof.copy_from_slice(&bytes[40..56]);
304+
Ok(RekeyStateRow::V1(RekeyIntent {
305+
source_mk_epoch,
159306
target_mk_epoch,
160-
main_db_done,
161-
segments_remaining_idx,
307+
source_cipher_id: bytes[20],
308+
target_cipher_id: bytes[21],
309+
same_kek,
310+
stage: RekeyStage::from_byte(bytes[1])?,
311+
source_hk_proof,
312+
target_hk_proof,
313+
}))
314+
}
315+
316+
/// Encode fixed rekey replacement progress:
317+
/// `version[1] || state[1] || reserved[2] || replacement_segment_id[16]`.
318+
#[must_use]
319+
pub fn encode_rekey_segment_progress(
320+
progress: RekeySegmentProgress,
321+
) -> [u8; REKEY_SEGMENT_PROGRESS_LEN] {
322+
let mut out = [0u8; REKEY_SEGMENT_PROGRESS_LEN];
323+
out[0] = 1;
324+
out[1] = progress.state as u8;
325+
out[4..20].copy_from_slice(&progress.replacement_segment_id);
326+
out
327+
}
328+
329+
pub fn decode_rekey_segment_progress(bytes: &[u8]) -> Result<RekeySegmentProgress> {
330+
if bytes.len() != REKEY_SEGMENT_PROGRESS_LEN
331+
|| bytes[0] != 1
332+
|| bytes[2..4].iter().any(|byte| *byte != 0)
333+
{
334+
return Err(PagedbError::corruption(
335+
crate::errors::CorruptionDetail::HeaderUnverifiable,
336+
));
337+
}
338+
let mut replacement_segment_id = [0u8; 16];
339+
replacement_segment_id.copy_from_slice(&bytes[4..20]);
340+
Ok(RekeySegmentProgress {
341+
replacement_segment_id,
342+
state: RekeySegmentProgressState::from_byte(bytes[1])?,
162343
})
163344
}
164345

@@ -452,6 +633,83 @@ mod tests {
452633
}
453634
}
454635

636+
#[test]
637+
fn rekey_intent_v1_round_trip() {
638+
let intent = RekeyIntent {
639+
source_mk_epoch: 0,
640+
target_mk_epoch: 27,
641+
source_cipher_id: 2,
642+
target_cipher_id: 2,
643+
same_kek: false,
644+
stage: RekeyStage::HeaderTargetPublished,
645+
source_hk_proof: [7; 16],
646+
target_hk_proof: [8; 16],
647+
};
648+
let encoded = Catalog::encode_rekey_intent(&intent);
649+
assert_eq!(
650+
Catalog::decode_rekey_state(&encoded).unwrap(),
651+
RekeyStateRow::V1(intent)
652+
);
653+
}
654+
655+
#[test]
656+
fn legacy_rekey_state_discards_positional_progress() {
657+
let mut bytes = [0u8; LEGACY_REKEY_STATE_LEN];
658+
bytes[..8].copy_from_slice(&4u64.to_le_bytes());
659+
bytes[8] = 1;
660+
bytes[9..].copy_from_slice(&u32::MAX.to_le_bytes());
661+
assert_eq!(
662+
Catalog::decode_rekey_state(&bytes).unwrap(),
663+
RekeyStateRow::Legacy(LegacyRekeyState {
664+
target_mk_epoch: 4,
665+
main_db_done: true,
666+
discarded_segments_index: u32::MAX,
667+
})
668+
);
669+
}
670+
671+
#[test]
672+
fn rekey_intent_rejects_invalid_boolean_epoch_and_progress_reserved_bytes() {
673+
let mut intent = RekeyIntent {
674+
source_mk_epoch: 1,
675+
target_mk_epoch: 2,
676+
source_cipher_id: 1,
677+
target_cipher_id: 1,
678+
same_kek: true,
679+
stage: RekeyStage::Intent,
680+
source_hk_proof: [0; 16],
681+
target_hk_proof: [0; 16],
682+
};
683+
let mut encoded = Catalog::encode_rekey_intent(&intent);
684+
encoded[2] = 2;
685+
assert!(Catalog::decode_rekey_state(&encoded).is_err());
686+
intent.target_mk_epoch = 0;
687+
assert!(Catalog::decode_rekey_state(&Catalog::encode_rekey_intent(&intent)).is_err());
688+
let progress = RekeySegmentProgress {
689+
replacement_segment_id: [5; 16],
690+
state: RekeySegmentProgressState::Sealed,
691+
};
692+
let mut encoded_progress = Catalog::encode_rekey_segment_progress(progress);
693+
assert_eq!(
694+
Catalog::decode_rekey_segment_progress(&encoded_progress).unwrap(),
695+
progress
696+
);
697+
encoded_progress[2] = 1;
698+
assert!(Catalog::decode_rekey_segment_progress(&encoded_progress).is_err());
699+
intent.target_mk_epoch = 2;
700+
let mut encoded_intent = Catalog::encode_rekey_intent(&intent);
701+
encoded_intent[21] = u8::MAX;
702+
assert!(Catalog::decode_rekey_state(&encoded_intent).is_err());
703+
let mut mixed_cipher_intent = Catalog::encode_rekey_intent(&intent);
704+
mixed_cipher_intent[21] = 2;
705+
assert!(matches!(
706+
Catalog::decode_rekey_state(&mixed_cipher_intent),
707+
Err(PagedbError::RekeyStateInvalid {
708+
field: "target_cipher_id"
709+
})
710+
));
711+
}
712+
455713
#[test]
456714
fn counter_decode_wrong_length_errors() {
457715
let err = Catalog::decode_counter(&[0u8; 7]).err().unwrap();

src/catalog/mod.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,7 @@
44
55
pub mod codec;
66

7-
pub use codec::{Catalog, CatalogRowKind, RekeyStateRow};
7+
pub use codec::{
8+
Catalog, CatalogRowKind, LegacyRekeyState, RekeyIntent, RekeySegmentProgress,
9+
RekeySegmentProgressState, RekeyStage, RekeyStateRow,
10+
};

0 commit comments

Comments
 (0)