@@ -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 ( ) ;
0 commit comments