Skip to main content

citadel/
key_codec.rs

1//! Shared crash-safe slot/header byte codec for the per-region and per-atom key stores.
2//!
3//! Layout (units = [`BLOCK`] = 512 bytes): header copy A @ 0, header copy B @ BLOCK,
4//! slot `i` copy A @ `2*BLOCK + i*2*BLOCK`, slot `i` copy B @ `... + BLOCK`. Header and
5//! every slot are double-buffered: a write lands in the inactive copy with `gen+1` and
6//! readers pick the MAC-valid copy with the highest `gen`. A torn 512-byte write fails
7//! its HMAC and is ignored in favour of the intact sibling, so a crash never surfaces a
8//! partial slot as a key. The slot/header HMAC (keyed by the store MAC key) provides
9//! integrity and torn-write detection only; wrapped-key secrecy rests on AES-256-KW.
10//!
11//! The two stores differ only in the header `magic`/`version` and their I/O strategy
12//! (the region store reads the whole small file; the atom store does random-access
13//! single-slot reads), so they share this codec but not their store types.
14
15use citadel_core::{KEY_SIZE, REGION_STORE_BLOCK, WRAPPED_KEY_SIZE};
16use citadel_crypto::mac::{hmac_sha256, verify_hmac_sha256};
17
18pub(crate) const BLOCK: usize = REGION_STORE_BLOCK;
19/// Header bytes authenticated by its HMAC: magic, version, file_id, slot_count, gen.
20pub(crate) const HEADER_MAC_INPUT: usize = 28;
21/// Slot bytes authenticated by its HMAC: state, owner_id, gen, wrapped_key.
22pub(crate) const SLOT_MAC_INPUT: usize = 60;
23
24/// Lifecycle state of a logical slot.
25#[derive(Clone, Copy, PartialEq, Eq, Debug)]
26pub enum SlotState {
27    Empty = 0,
28    Live = 1,
29    Tombstone = 2,
30}
31
32impl SlotState {
33    pub(crate) fn from_u32(v: u32) -> Option<Self> {
34        match v {
35            0 => Some(SlotState::Empty),
36            1 => Some(SlotState::Live),
37            2 => Some(SlotState::Tombstone),
38            _ => None,
39        }
40    }
41}
42
43/// The authoritative (highest-`gen`, MAC-valid) view of a logical slot. `region_id` is
44/// the generic owner id: a region id in the region store, an atom id in the atom store.
45#[derive(Clone, Copy, Debug)]
46pub struct SlotRecord {
47    pub state: SlotState,
48    pub region_id: u64,
49    pub gen: u64,
50    pub wrapped: [u8; WRAPPED_KEY_SIZE],
51}
52
53/// Offset of header copy `b` (false = A, true = B).
54pub(crate) fn header_offset(copy_b: bool) -> u64 {
55    if copy_b {
56        BLOCK as u64
57    } else {
58        0
59    }
60}
61
62/// Offset of slot `i`'s copy `b` (false = A, true = B).
63pub(crate) fn slot_offset(i: u32, copy_b: bool) -> u64 {
64    let base = 2 * BLOCK as u64 + i as u64 * 2 * BLOCK as u64;
65    base + if copy_b { BLOCK as u64 } else { 0 }
66}
67
68pub(crate) fn build_header_block(
69    mac_key: &[u8; KEY_SIZE],
70    magic: u32,
71    version: u32,
72    file_id: u64,
73    slot_count: u32,
74    gen: u64,
75) -> [u8; BLOCK] {
76    let mut b = [0u8; BLOCK];
77    b[0..4].copy_from_slice(&magic.to_le_bytes());
78    b[4..8].copy_from_slice(&version.to_le_bytes());
79    b[8..16].copy_from_slice(&file_id.to_le_bytes());
80    b[16..20].copy_from_slice(&slot_count.to_le_bytes());
81    b[20..28].copy_from_slice(&gen.to_le_bytes());
82    let mac = hmac_sha256(mac_key, &b[0..HEADER_MAC_INPUT]);
83    b[HEADER_MAC_INPUT..HEADER_MAC_INPUT + 32].copy_from_slice(&mac);
84    b
85}
86
87/// Parse a header copy, returning `(slot_count, gen)` if magic/version/file_id match and
88/// the HMAC verifies; `None` for an absent, mismatched, or torn copy.
89pub(crate) fn parse_header_block(
90    mac_key: &[u8; KEY_SIZE],
91    magic: u32,
92    version: u32,
93    file_id: u64,
94    b: &[u8],
95) -> Option<(u32, u64)> {
96    if b.len() < HEADER_MAC_INPUT + 32 {
97        return None;
98    }
99    if u32::from_le_bytes(b[0..4].try_into().ok()?) != magic {
100        return None;
101    }
102    if u32::from_le_bytes(b[4..8].try_into().ok()?) != version {
103        return None;
104    }
105    let tag: [u8; 32] = b[HEADER_MAC_INPUT..HEADER_MAC_INPUT + 32].try_into().ok()?;
106    if !verify_hmac_sha256(mac_key, &b[0..HEADER_MAC_INPUT], &tag) {
107        return None;
108    }
109    if u64::from_le_bytes(b[8..16].try_into().ok()?) != file_id {
110        return None;
111    }
112    let slot_count = u32::from_le_bytes(b[16..20].try_into().ok()?);
113    let gen = u64::from_le_bytes(b[20..28].try_into().ok()?);
114    Some((slot_count, gen))
115}
116
117pub(crate) fn build_slot_block(
118    mac_key: &[u8; KEY_SIZE],
119    state: SlotState,
120    region_id: u64,
121    gen: u64,
122    wrapped: &[u8; WRAPPED_KEY_SIZE],
123) -> [u8; BLOCK] {
124    let mut b = [0u8; BLOCK];
125    b[0..4].copy_from_slice(&(state as u32).to_le_bytes());
126    b[4..12].copy_from_slice(&region_id.to_le_bytes());
127    b[12..20].copy_from_slice(&gen.to_le_bytes());
128    b[20..20 + WRAPPED_KEY_SIZE].copy_from_slice(wrapped);
129    let mac = hmac_sha256(mac_key, &b[0..SLOT_MAC_INPUT]);
130    b[SLOT_MAC_INPUT..SLOT_MAC_INPUT + 32].copy_from_slice(&mac);
131    b
132}
133
134/// The empty-slot block (`state=EMPTY`, zeroed key) with a valid HMAC, so pre-allocated
135/// and recycled slots are MAC-valid and a torn write reads as invalid, not empty.
136pub(crate) fn empty_slot_block(mac_key: &[u8; KEY_SIZE]) -> [u8; BLOCK] {
137    build_slot_block(mac_key, SlotState::Empty, 0, 0, &[0u8; WRAPPED_KEY_SIZE])
138}
139
140pub(crate) fn parse_slot_block(mac_key: &[u8; KEY_SIZE], b: &[u8]) -> Option<SlotRecord> {
141    if b.len() < SLOT_MAC_INPUT + 32 {
142        return None;
143    }
144    let tag: [u8; 32] = b[SLOT_MAC_INPUT..SLOT_MAC_INPUT + 32].try_into().ok()?;
145    if !verify_hmac_sha256(mac_key, &b[0..SLOT_MAC_INPUT], &tag) {
146        return None;
147    }
148    let state = SlotState::from_u32(u32::from_le_bytes(b[0..4].try_into().ok()?))?;
149    let region_id = u64::from_le_bytes(b[4..12].try_into().ok()?);
150    let gen = u64::from_le_bytes(b[12..20].try_into().ok()?);
151    let wrapped: [u8; WRAPPED_KEY_SIZE] = b[20..20 + WRAPPED_KEY_SIZE].try_into().ok()?;
152    Some(SlotRecord {
153        state,
154        region_id,
155        gen,
156        wrapped,
157    })
158}