@@ -35,9 +35,9 @@ use generic_log_worker::{
3535} ;
3636use length_prefixed:: { ReadLengthPrefixedBytesExt as _, WriteLengthPrefixedBytesExt as _} ;
3737use 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) ]
4242use 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 .
7179pub ( 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