Skip to content

Commit 76a297a

Browse files
committed
mirror_worker: authenticate reloaded persisted-leaf prefix
read_persisted_leaves returned reloaded entry-bundle bytes without checking them, so a corrupted or stale bundle flowed into package verification and surfaced as a client 422 for a mirror storage fault. Authenticate the decoded leaves against the frontier root via the tlog-tiles tile-hash reader (edge tiles alone cannot cover a resume that lands below the frontier's edge tile, which the excess_entries bound permits). Addresses bonk #264 review on commit.rs read_persisted_leaves.
1 parent 1e91695 commit 76a297a

2 files changed

Lines changed: 136 additions & 18 deletions

File tree

crates/mirror_worker/src/add_entries.rs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,13 @@ pub(crate) async fn add_entries(
163163
return Ok(mirror_info_409(&env, &snapshot, &header.log_origin));
164164
}
165165

166-
let first_prefix = first_package_prefix(&env, &header, snapshot.next_entry.size).await?;
166+
let first_prefix = first_package_prefix(
167+
&env,
168+
&header,
169+
snapshot.next_entry.size,
170+
snapshot.next_entry.hash,
171+
)
172+
.await?;
167173

168174
verify_and_persist(
169175
&env,
@@ -439,14 +445,19 @@ async fn cosign_and_serve(
439445
/// next_entry.size` is enforced upstream, the requested leaves are always
440446
/// present in storage.
441447
///
448+
/// The prefix is authenticated against the frontier hash tiles inside
449+
/// [`commit::read_persisted_leaves`]; `persisted_hash` is the frontier
450+
/// root at `persisted_size`.
451+
///
442452
/// # Errors
443453
///
444454
/// Returns an error if opening the origin bucket or reading the persisted
445-
/// entry bundle fails.
455+
/// entry bundle fails, or the reloaded leaves fail authentication.
446456
async fn first_package_prefix(
447457
env: &Env,
448458
header: &AddEntriesRequestHeader,
449459
persisted_size: u64,
460+
persisted_hash: Hash,
450461
) -> Result<Vec<Vec<u8>>> {
451462
let subtree_start = (header.upload_start / PACKAGE_ALIGNMENT) * PACKAGE_ALIGNMENT;
452463
if header.upload_start == subtree_start {
@@ -458,6 +469,7 @@ async fn first_package_prefix(
458469
subtree_start,
459470
header.upload_start - subtree_start,
460471
persisted_size,
472+
persisted_hash,
461473
)
462474
.await
463475
}

crates/mirror_worker/src/commit.rs

Lines changed: 122 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,9 @@ use generic_log_worker::{
3535
};
3636
use length_prefixed::{ReadLengthPrefixedBytesExt as _, WriteLengthPrefixedBytesExt as _};
3737
use tlog_core::{
38-
Hash, HashReader as _, record_hash, stored_hash_index, stored_hashes_for_record_hash,
38+
Hash, HashReader as _, TlogError, record_hash, stored_hash_index, stored_hashes_for_record_hash,
3939
};
40-
use tlog_tiles::{PathElem, TlogTile};
40+
use tlog_tiles::{PathElem, PreloadedTlogTileReader, TileHashReader, TlogTile, TlogTileRecorder};
4141
#[allow(clippy::wildcard_imports)]
4242
use worker::*;
4343

@@ -63,16 +63,25 @@ pub(crate) const CHECKPOINT_KEY: &str = "checkpoint";
6363
/// upload_start)` are already in the log and therefore absent from the
6464
/// uploaded package.
6565
///
66+
/// The reloaded bytes are untrusted storage output, so each decoded entry
67+
/// is authenticated against the hash tiles committed at the persisted
68+
/// frontier (`persisted_hash` is the frontier root, used to authenticate
69+
/// the edge tiles via [`read_edge_tiles`]). Without this a corrupted or
70+
/// stale bundle would flow into package verification and surface as a
71+
/// client "422 Unprocessable Entity" for what is actually a mirror
72+
/// storage fault.
73+
///
6674
/// # Errors
6775
///
68-
/// Returns an error if the bundle is missing from storage or is shorter
69-
/// than `count` entries (i.e. the requested leaves were not actually
70-
/// persisted).
76+
/// Returns an error if the bundle is missing from storage, is shorter than
77+
/// `count` entries, or a decoded entry does not match its authenticated
78+
/// leaf hash.
7179
pub(crate) async fn read_persisted_leaves(
7280
object: &impl ObjectBackend,
7381
start: u64,
7482
count: u64,
7583
persisted_size: u64,
84+
persisted_hash: Hash,
7685
) -> Result<Vec<Vec<u8>>> {
7786
debug_assert!(
7887
start.is_multiple_of(TILE_WIDTH),
@@ -93,20 +102,81 @@ pub(crate) async fn read_persisted_leaves(
93102
.await?
94103
.ok_or_else(|| Error::from(format!("persisted entry bundle missing: {}", tile.path())))?;
95104

105+
// Authenticate the decoded leaves against the frontier hash tiles.
106+
// Leaves below the frontier's edge tile are not covered by
107+
// `read_edge_tiles`, and the `excess_entries` bound lets a resume land
108+
// in the tile just before the edge, so authenticate against the tree
109+
// root via the full tile-hash reader instead.
110+
let indexes: Vec<u64> = (start..start + count)
111+
.map(|leaf| stored_hash_index(0, leaf))
112+
.collect();
113+
let want = authenticated_leaf_hashes(object, persisted_size, persisted_hash, &indexes).await?;
114+
96115
let mut cur: &[u8] = &bytes;
97116
let mut out = Vec::with_capacity(usize::try_from(count).unwrap_or(0));
98117
for i in 0..count {
99-
let entry = cur.read_length_prefixed(2).map_err(|e| {
100-
Error::from(format!(
101-
"persisted bundle leaf {} truncated: {e}",
102-
start + i
103-
))
104-
})?;
118+
let leaf = start + i;
119+
let entry = cur
120+
.read_length_prefixed(2)
121+
.map_err(|e| Error::from(format!("persisted bundle leaf {leaf} truncated: {e}")))?;
122+
let idx = usize::try_from(i).unwrap_or(usize::MAX);
123+
if record_hash(&entry) != want[idx] {
124+
return Err(Error::from(format!(
125+
"persisted bundle leaf {leaf} does not match its authenticated hash"
126+
)));
127+
}
105128
out.push(entry);
106129
}
107130
Ok(out)
108131
}
109132

133+
/// Fetch and authenticate the record hashes for `indexes` (level-0 leaf
134+
/// stored-hash indexes) against the frontier `(tree_size, tree_hash)`.
135+
///
136+
/// Runs the standard tlog-tiles two-pass [`TileHashReader`] protocol: a
137+
/// recording pass discovers the hash tiles needed to prove the requested
138+
/// leaves, they are fetched from storage, then a verifying pass
139+
/// authenticates them against `tree_hash`. A storage fault (missing,
140+
/// stale, or tampered tile) therefore surfaces as an error here rather
141+
/// than as a spurious client rejection downstream.
142+
///
143+
/// # Errors
144+
///
145+
/// Returns an error if a required hash tile is missing from storage or the
146+
/// fetched tiles do not authenticate against `tree_hash`.
147+
async fn authenticated_leaf_hashes(
148+
object: &impl ObjectBackend,
149+
tree_size: u64,
150+
tree_hash: Hash,
151+
indexes: &[u64],
152+
) -> Result<Vec<Hash>> {
153+
// Recording pass: `TlogTileRecorder` short-circuits `read_hashes` with
154+
// `RecordedTilesOnly` after collecting the tiles it would need.
155+
let recorder = TlogTileRecorder::default();
156+
match TileHashReader::new(tree_size, Hash::default(), &recorder).read_hashes(indexes) {
157+
Err(TlogError::RecordedTilesOnly) => {}
158+
other => {
159+
return Err(Error::from(format!(
160+
"expected RecordedTilesOnly while recording hash tiles, got {other:?}"
161+
)));
162+
}
163+
}
164+
165+
let mut tiles: HashMap<TlogTile, Vec<u8>> = HashMap::new();
166+
for tile in recorder.0.into_inner() {
167+
let bytes = object
168+
.fetch(tile.path())
169+
.await?
170+
.ok_or_else(|| Error::from(format!("persisted hash tile missing: {}", tile.path())))?;
171+
tiles.insert(tile, bytes);
172+
}
173+
174+
let reader = PreloadedTlogTileReader(tiles);
175+
TileHashReader::new(tree_size, tree_hash, &reader)
176+
.read_hashes(indexes)
177+
.map_err(|e| Error::from(format!("authenticate persisted leaf hashes: {e:?}")))
178+
}
179+
110180
/// Authenticate a reloaded partial entry bundle before it is extended and
111181
/// re-served.
112182
///
@@ -536,29 +606,65 @@ mod tests {
536606
async fn read_persisted_leaves_from_full_and_partial_bundles() {
537607
let obj = MemBackend::default();
538608
// 300 leaves: one full bundle [0,256) + a partial bundle [256,300).
539-
persist_entries(&obj, 0, EMPTY_HASH, 300, &leaves(0..300))
609+
let root = persist_entries(&obj, 0, EMPTY_HASH, 300, &leaves(0..300))
540610
.await
541611
.unwrap();
542612

543613
// Prefix within the full bundle (persisted_size 300 -> stored
544614
// width 256 for tile 0).
545-
let got = read_persisted_leaves(&obj, 0, 44, 300).await.unwrap();
615+
let got = read_persisted_leaves(&obj, 0, 44, 300, root).await.unwrap();
546616
assert_eq!(got, leaves(0..44));
547617

548618
// Whole full bundle.
549-
let got = read_persisted_leaves(&obj, 0, 256, 300).await.unwrap();
619+
let got = read_persisted_leaves(&obj, 0, 256, 300, root)
620+
.await
621+
.unwrap();
550622
assert_eq!(got, leaves(0..256));
551623

552624
// Prefix within the trailing partial bundle (tile 1, stored width
553625
// 300 - 256 = 44).
554-
let got = read_persisted_leaves(&obj, 256, 20, 300).await.unwrap();
626+
let got = read_persisted_leaves(&obj, 256, 20, 300, root)
627+
.await
628+
.unwrap();
555629
assert_eq!(got, leaves(256..276));
556630
}
557631

558632
#[tokio::test]
559633
async fn read_persisted_leaves_missing_bundle_errors() {
560634
let obj = MemBackend::default();
561-
assert!(read_persisted_leaves(&obj, 0, 10, 300).await.is_err());
635+
assert!(
636+
read_persisted_leaves(&obj, 0, 10, 300, EMPTY_HASH)
637+
.await
638+
.is_err()
639+
);
640+
}
641+
642+
#[tokio::test]
643+
async fn read_persisted_leaves_rejects_tampered_bundle() {
644+
let obj = MemBackend::default();
645+
let root = persist_entries(&obj, 0, EMPTY_HASH, 300, &leaves(0..300))
646+
.await
647+
.unwrap();
648+
649+
// Corrupt a byte inside the full bundle [0,256) while keeping the
650+
// length framing valid, so it still decodes but no longer matches
651+
// its authenticated leaf hash.
652+
let key = TlogTile::from_index(stored_hash_index(0, 255))
653+
.with_data_path(PathElem::Entries)
654+
.path();
655+
let mut bytes = obj.fetch(&key).await.unwrap().expect("full bundle stored");
656+
let last = bytes.len() - 1;
657+
bytes[last] ^= 0xff;
658+
obj.upload(&key, bytes, &immutable_tile_opts())
659+
.await
660+
.unwrap();
661+
662+
assert!(
663+
read_persisted_leaves(&obj, 0, 256, 300, root)
664+
.await
665+
.is_err(),
666+
"tampered persisted bundle must be rejected"
667+
);
562668
}
563669

564670
#[tokio::test]

0 commit comments

Comments
 (0)