Skip to content

Commit 8bbd6d7

Browse files
committed
feat(crypto)!: scope keys to file identity and name open failures
The AEAD nonce for a file is `file_id[0..6] ‖ counter48`, so a key's nonce space is only as isolated as the leading 6 bytes of the identity that issues into it. A shared per-realm key made two files with a colliding prefix a birthday problem over 2^48 rather than an impossibility. `derive_dek`/`derive_ik` now fold the full 16-byte file identity into the HKDF info alongside the realm, so every file gets its own key and nonce reuse is ruled out by construction. The DEK/IK LRU cache is keyed the same way so it can't hand one file the cipher state derived for another. Opening a store also now tells a caller *which* parameter is wrong instead of reporting every open failure as corruption: - The realm is recorded and HK-MAC'd in the main.db header, so a mismatched realm is refused up front (`RealmMismatch`) rather than surfacing as an unreadable page later — including the case where the store was still empty and would otherwise have silently taken writes under the wrong realm. - Page size and on-disk format version are cleartext and checked before any key is derived (`PageSizeMismatch`, `FormatVersionUnsupported`), so those mistakes are never mistaken for a bad key or for damage. - Once both header slots fail to verify, the framing is inspected to tell a wrong key (`KeyMismatch`) from a genuinely corrupt header, since only the former is safe to retry. `pagedb-fsck` drops its all-zero default KEK to match: a tool for inspecting a store should never silently assume a key.
1 parent a7b489f commit 8bbd6d7

37 files changed

Lines changed: 996 additions & 160 deletions

src/bin/pagedb-fsck/cli.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ usage: pagedb-fsck <path> [--deep] [--page-size <bytes>] [--realm <hex16>] [<hex
3636
--deep authenticate and walk every page, not just the header
3737
--page-size <bytes> page size the store was created with (default 4096)
3838
--realm <hex16> 32 hex characters; default all-ones, nodedb-lite uses all-zeros
39-
<hex-kek> 64 hex characters; also read from PAGEDB_KEK, default all-zeros
39+
<hex-kek> 64 hex characters; required, or supply it in PAGEDB_KEK
4040
-h, --help print this message
4141
4242
No option may be repeated, and a path beginning with '-' must be written as

src/bin/pagedb-fsck/run.rs

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,16 +51,26 @@ async fn check(args: CliArgs) -> ExitCode {
5151
kek_hex,
5252
} = args;
5353

54-
// An explicit positional key wins over the environment, which wins over
55-
// the all-zero default.
54+
// An explicit positional key wins over the environment. There is no
55+
// default: a tool that silently assumes a key encourages treating that key
56+
// as a real one, and an all-zero fallback turns "you did not supply the
57+
// key" into "this store will not open", which is the one distinction this
58+
// tool exists to preserve.
5659
let kek_hex = kek_hex.or_else(|| std::env::var("PAGEDB_KEK").ok());
5760
let kek = match kek_hex.as_deref().map(pagedb::hex::parse_hex::<32>) {
5861
Some(Some(kek)) => kek,
5962
Some(None) => {
6063
eprintln!("pagedb-fsck: invalid hex KEK (must be 64 hex chars / 32 bytes)");
6164
return ExitCode::from(EXIT_USAGE);
6265
}
63-
None => [0u8; 32],
66+
None => {
67+
eprintln!(
68+
"pagedb-fsck: a KEK is required; pass it as the trailing positional \
69+
argument or in PAGEDB_KEK"
70+
);
71+
eprintln!("{}", cli::USAGE);
72+
return ExitCode::from(EXIT_USAGE);
73+
}
6474
};
6575

6676
let realm = match realm_hex.as_deref().map(pagedb::hex::parse_hex::<16>) {

src/compaction/helpers.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ pub(super) async fn replace_segment_compact<V: Vfs + Clone>(
176176
catalog_root_bytes[8..].copy_from_slice(&new_commit_id.to_le_bytes());
177177

178178
let fields = MainDbHeaderFields {
179-
format_version: 1,
179+
format_version: crate::pager::structural_header::MAIN_FORMAT_VERSION,
180180
cipher_id: db.cipher_id.as_byte(),
181181
page_size_log2: page_size_log2(db.page_size)?,
182182
flags: 0,
@@ -198,6 +198,7 @@ pub(super) async fn replace_segment_compact<V: Vfs + Clone>(
198198
next_page_id: new_next,
199199
commit_retain_policy_tag: 0,
200200
commit_retain_policy_value: 0,
201+
realm_id: db.realm_id,
201202
};
202203

203204
let hk_clone = { db.hk.read().clone() };
@@ -268,7 +269,7 @@ pub(super) fn make_header_fields<V: Vfs + Clone>(
268269
catalog_root_bytes[..8].copy_from_slice(&new_cat_root.to_le_bytes());
269270
catalog_root_bytes[8..].copy_from_slice(&new_commit_id.to_le_bytes());
270271
MainDbHeaderFields {
271-
format_version: 1,
272+
format_version: crate::pager::structural_header::MAIN_FORMAT_VERSION,
272273
cipher_id: db.cipher_id.as_byte(),
273274
page_size_log2: page_size_log2(db.page_size).unwrap_or(12),
274275
flags: 0,
@@ -290,6 +291,7 @@ pub(super) fn make_header_fields<V: Vfs + Clone>(
290291
next_page_id: new_next,
291292
commit_retain_policy_tag: 0,
292293
commit_retain_policy_value: 0,
294+
realm_id: db.realm_id,
293295
}
294296
}
295297

src/crypto/cipher.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -195,8 +195,9 @@ mod tests {
195195

196196
fn fixture() -> (Cipher, Cipher, Cipher, Aad, Nonce) {
197197
let mk = derive_mk(&[7u8; 32], &[0xAB; 16], 0).unwrap();
198-
let dek = derive_dek(&mk, RealmId([1; 16])).unwrap();
199-
let ik = derive_ik(&mk).unwrap();
198+
let file_id = [0x5A; 16];
199+
let dek = derive_dek(&mk, RealmId([1; 16]), &file_id).unwrap();
200+
let ik = derive_ik(&mk, RealmId([1; 16]), &file_id).unwrap();
200201
let aes = Cipher::new_aes_gcm(&dek);
201202
let cc = Cipher::new_chacha20(&dek);
202203
let pt = Cipher::new_plaintext_mac(ik);

src/crypto/kdf.rs

Lines changed: 124 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,39 @@ use super::keys::{DerivedKey, MasterKey};
1111

1212
const INFO_MASTER_PREFIX: &[u8] = b"pagedb/master/v1/";
1313
const INFO_REALM_PREFIX: &[u8] = b"pagedb/realm/";
14-
const INFO_REALM_SUFFIX: &[u8] = b"/v1";
15-
const INFO_INTEGRITY: &[u8] = b"pagedb/integrity/v1";
14+
const INFO_INTEGRITY_PREFIX: &[u8] = b"pagedb/integrity/";
15+
const INFO_FILE_INFIX: &[u8] = b"/file/";
16+
const INFO_V2_SUFFIX: &[u8] = b"/v2";
1617
const INFO_HEADER_MAC: &[u8] = b"pagedb/header-mac/v1";
1718

19+
/// Build `prefix ‖ realm_id ‖ "/file/" ‖ file_id ‖ "/v2"`.
20+
///
21+
/// The realm and the file identity are both fixed-width, and the literals
22+
/// around them are constant, so the encoding is unambiguous without a length
23+
/// prefix: no two distinct `(realm, file)` pairs can produce the same bytes.
24+
fn scoped_info<const N: usize>(prefix: &[u8], realm_id: RealmId, file_id: &[u8; 16]) -> [u8; N] {
25+
let mut info = [0u8; N];
26+
let mut off = 0;
27+
info[off..off + prefix.len()].copy_from_slice(prefix);
28+
off += prefix.len();
29+
info[off..off + 16].copy_from_slice(&realm_id.0);
30+
off += 16;
31+
info[off..off + INFO_FILE_INFIX.len()].copy_from_slice(INFO_FILE_INFIX);
32+
off += INFO_FILE_INFIX.len();
33+
info[off..off + 16].copy_from_slice(file_id);
34+
off += 16;
35+
info[off..off + INFO_V2_SUFFIX.len()].copy_from_slice(INFO_V2_SUFFIX);
36+
info
37+
}
38+
39+
/// Width of a `scoped_info` buffer for a given prefix.
40+
const fn scoped_info_len(prefix_len: usize) -> usize {
41+
prefix_len + 16 + INFO_FILE_INFIX.len() + 16 + INFO_V2_SUFFIX.len()
42+
}
43+
44+
const DEK_INFO_LEN: usize = scoped_info_len(INFO_REALM_PREFIX.len());
45+
const IK_INFO_LEN: usize = scoped_info_len(INFO_INTEGRITY_PREFIX.len());
46+
1847
/// Derive the master key from the embedder-supplied KEK.
1948
///
2049
/// `info` = `INFO_MASTER_PREFIX ‖ mk_epoch.to_le_bytes()` (17 + 8 = 25 bytes).
@@ -30,24 +59,47 @@ pub fn derive_mk(kek: &[u8; 32], kek_salt: &[u8; 16], mk_epoch: u64) -> Result<M
3059
Ok(MasterKey::from_bytes(out))
3160
}
3261

33-
/// Derive the per-realm Data Encryption Key (used by AEAD modes).
62+
/// Derive the Data Encryption Key for one realm **within one file** (used by
63+
/// AEAD modes).
3464
///
35-
/// `info` = `"pagedb/realm/" ‖ realm_id ‖ "/v1"` (13 + 16 + 3 = 32 bytes).
36-
pub fn derive_dek(mk: &MasterKey, realm_id: RealmId) -> Result<DerivedKey> {
37-
let mut info = [0u8; INFO_REALM_PREFIX.len() + 16 + INFO_REALM_SUFFIX.len()];
38-
let mut off = 0;
39-
info[off..off + INFO_REALM_PREFIX.len()].copy_from_slice(INFO_REALM_PREFIX);
40-
off += INFO_REALM_PREFIX.len();
41-
info[off..off + 16].copy_from_slice(&realm_id.0);
42-
off += 16;
43-
info[off..off + INFO_REALM_SUFFIX.len()].copy_from_slice(INFO_REALM_SUFFIX);
44-
65+
/// `info` = `"pagedb/realm/" ‖ realm_id ‖ "/file/" ‖ file_id ‖ "/v2"`.
66+
///
67+
/// # Why the file identity is part of the key
68+
///
69+
/// An AEAD nonce here is `file_id[0..6] ‖ counter48` (see
70+
/// [`crate::crypto::nonce`]), so a key's nonce space is partitioned only by
71+
/// the leading 6 bytes of the identity that issues into it. `main.db`, every
72+
/// segment, and every apply journal in a realm are separate identities, and
73+
/// those identities are random — which makes a shared per-realm key a birthday
74+
/// problem over 2^48, not a guarantee. Two files whose identities happen to
75+
/// share a 6-byte prefix would issue the same nonce under the same key, and
76+
/// GCM nonce reuse leaks the plaintext XOR and exposes the authentication key.
77+
///
78+
/// Scoping the key by the full 16-byte identity removes the question instead of
79+
/// making it unlikely: each file gets its own key, so its 48-bit counter is the
80+
/// *whole* nonce space for that key and reuse is impossible by construction
81+
/// rather than improbable by measure.
82+
///
83+
/// `file_id` must be the same identity that seeds the file's nonce generator:
84+
/// the header's `file_id` for `main.db`, the `segment_id` for a segment, the
85+
/// journal id for an apply journal.
86+
pub fn derive_dek(mk: &MasterKey, realm_id: RealmId, file_id: &[u8; 16]) -> Result<DerivedKey> {
87+
let info: [u8; DEK_INFO_LEN] = scoped_info(INFO_REALM_PREFIX, realm_id, file_id);
4588
expand(mk.as_bytes(), &info)
4689
}
4790

48-
/// Derive the Integrity Key (used by plaintext+MAC mode, shared across realms).
49-
pub fn derive_ik(mk: &MasterKey) -> Result<DerivedKey> {
50-
expand(mk.as_bytes(), INFO_INTEGRITY)
91+
/// Derive the Integrity Key for one realm within one file (used by the
92+
/// plaintext+MAC mode).
93+
///
94+
/// `info` = `"pagedb/integrity/" ‖ realm_id ‖ "/file/" ‖ file_id ‖ "/v2"`.
95+
///
96+
/// An HMAC needs no nonce, so this mode has no reuse hazard to avoid. It is
97+
/// scoped identically to [`derive_dek`] anyway so that "one key per (realm,
98+
/// file, epoch, cipher)" is a single rule with no exception to remember — a
99+
/// mode added later inherits the safe shape by default.
100+
pub fn derive_ik(mk: &MasterKey, realm_id: RealmId, file_id: &[u8; 16]) -> Result<DerivedKey> {
101+
let info: [u8; IK_INFO_LEN] = scoped_info(INFO_INTEGRITY_PREFIX, realm_id, file_id);
102+
expand(mk.as_bytes(), &info)
51103
}
52104

53105
/// Derive the Header Key (used to MAC main.db headers and segment headers/footers).
@@ -165,22 +217,74 @@ mod tests {
165217
assert_ne!(a.as_bytes(), b.as_bytes());
166218
}
167219

220+
const FILE_A: [u8; 16] = [0x5A; 16];
221+
const FILE_B: [u8; 16] = [0xA5; 16];
222+
168223
#[test]
169224
fn dek_isolates_per_realm() {
170225
let mk = derive_mk(&fixed_kek(), &[0; 16], 0).unwrap();
171-
let a = derive_dek(&mk, RealmId([0x11; 16])).unwrap();
172-
let b = derive_dek(&mk, RealmId([0x22; 16])).unwrap();
226+
let a = derive_dek(&mk, RealmId([0x11; 16]), &FILE_A).unwrap();
227+
let b = derive_dek(&mk, RealmId([0x22; 16]), &FILE_A).unwrap();
173228
assert_ne!(a.as_bytes(), b.as_bytes());
174229
}
175230

231+
/// The property the whole per-file scoping exists for: two files in one
232+
/// realm never share a key, so neither can issue a nonce the other has
233+
/// already used.
234+
#[test]
235+
fn dek_isolates_per_file_within_one_realm() {
236+
let mk = derive_mk(&fixed_kek(), &[0; 16], 0).unwrap();
237+
let realm = RealmId([0x11; 16]);
238+
let a = derive_dek(&mk, realm, &FILE_A).unwrap();
239+
let b = derive_dek(&mk, realm, &FILE_B).unwrap();
240+
assert_ne!(a.as_bytes(), b.as_bytes());
241+
}
242+
243+
/// Identities that collide in the 6 bytes the nonce is built from are
244+
/// exactly the case a per-realm key could not survive. They must still
245+
/// derive different keys, so a prefix collision costs nothing.
246+
#[test]
247+
fn dek_isolates_files_sharing_a_nonce_prefix() {
248+
let mk = derive_mk(&fixed_kek(), &[0; 16], 0).unwrap();
249+
let realm = RealmId([0x11; 16]);
250+
let mut shared_prefix = FILE_A;
251+
shared_prefix[15] ^= 0xFF;
252+
assert_eq!(FILE_A[..6], shared_prefix[..6]);
253+
let a = derive_dek(&mk, realm, &FILE_A).unwrap();
254+
let b = derive_dek(&mk, realm, &shared_prefix).unwrap();
255+
assert_ne!(a.as_bytes(), b.as_bytes());
256+
}
257+
258+
#[test]
259+
fn ik_isolates_per_realm_and_file() {
260+
let mk = derive_mk(&fixed_kek(), &[0; 16], 0).unwrap();
261+
let base = derive_ik(&mk, RealmId([0x11; 16]), &FILE_A).unwrap();
262+
let other_realm = derive_ik(&mk, RealmId([0x22; 16]), &FILE_A).unwrap();
263+
let other_file = derive_ik(&mk, RealmId([0x11; 16]), &FILE_B).unwrap();
264+
assert_ne!(base.as_bytes(), other_realm.as_bytes());
265+
assert_ne!(base.as_bytes(), other_file.as_bytes());
266+
}
267+
176268
#[test]
177269
fn ik_hk_are_distinct_from_dek() {
178270
let mk = derive_mk(&fixed_kek(), &[0; 16], 0).unwrap();
179-
let dek = derive_dek(&mk, RealmId([0x11; 16])).unwrap();
180-
let ik = derive_ik(&mk).unwrap();
271+
let realm = RealmId([0x11; 16]);
272+
let dek = derive_dek(&mk, realm, &FILE_A).unwrap();
273+
let ik = derive_ik(&mk, realm, &FILE_A).unwrap();
181274
let hk = derive_hk(&mk).unwrap();
182275
assert_ne!(dek.as_bytes(), ik.as_bytes());
183276
assert_ne!(dek.as_bytes(), hk.as_bytes());
184277
assert_ne!(ik.as_bytes(), hk.as_bytes());
185278
}
279+
280+
/// The realm/file boundary must not be forgeable by shifting bytes across
281+
/// it. Fixed-width fields plus constant literals make that structural, and
282+
/// this pins it: swapping the two 16-byte fields is a different key.
283+
#[test]
284+
fn realm_and_file_fields_are_not_interchangeable() {
285+
let mk = derive_mk(&fixed_kek(), &[0; 16], 0).unwrap();
286+
let a = derive_dek(&mk, RealmId(FILE_A), &FILE_B).unwrap();
287+
let b = derive_dek(&mk, RealmId(FILE_B), &FILE_A).unwrap();
288+
assert_ne!(a.as_bytes(), b.as_bytes());
289+
}
186290
}

0 commit comments

Comments
 (0)