@@ -11,10 +11,39 @@ use super::keys::{DerivedKey, MasterKey};
1111
1212const INFO_MASTER_PREFIX : & [ u8 ] = b"pagedb/master/v1/" ;
1313const 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" ;
1617const 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