@@ -32,6 +32,7 @@ use pna::{
3232} ;
3333use std:: {
3434 borrow:: Cow ,
35+ collections:: HashMap ,
3536 fmt, fs,
3637 io:: { self , prelude:: * } ,
3738 path:: { Path , PathBuf } ,
@@ -1894,7 +1895,10 @@ pub(crate) fn apply_chroot(chroot: bool) -> anyhow::Result<()> {
18941895/// if a password is provided; without a password they will cause an error.
18951896///
18961897/// The entry data (FDAT chunks) is preserved as-is, maintaining the original
1897- /// compression and encryption. Only the entry headers (paths, ownership) are modified.
1898+ /// compression and encryption. Only the entry headers (paths, ownership) are
1899+ /// modified. The exception is renaming a cipher mode 2 (GCM) entry, whose data
1900+ /// is bound to the header bytes: it is decrypted and re-encrypted under the new
1901+ /// name using the provided password, and refused when no password is available.
18981902///
18991903/// Entries whose path matches the filter exclusion rules will be skipped.
19001904/// Time filters are also applied to filter entries by timestamps.
@@ -1907,6 +1911,7 @@ pub(crate) fn transform_archive_entries<R: io::Read>(
19071911 allow_concatenated_archives : bool ,
19081912) -> io:: Result < Vec < io:: Result < Option < NormalEntry > > > > {
19091913 let mut results = Vec :: new ( ) ;
1914+ let mut reencrypt_options = HashMap :: new ( ) ;
19101915 let read_options = ReadOptions :: with_password ( password) ;
19111916 run_process_archive (
19121917 std:: iter:: once ( reader) ,
@@ -1921,7 +1926,12 @@ pub(crate) fn transform_archive_entries<R: io::Read>(
19211926 if !time_filters. matches ( ctime, mtime) {
19221927 return Ok ( ( ) ) ;
19231928 }
1924- results. push ( transform_normal_entry ( entry, create_options) ) ;
1929+ results. push ( transform_normal_entry (
1930+ entry,
1931+ create_options,
1932+ password,
1933+ & mut reencrypt_options,
1934+ ) ) ;
19251935 Ok ( ( ) )
19261936 } ,
19271937 allow_concatenated_archives,
@@ -2052,6 +2062,8 @@ fn transform_normal_entry(
20522062 keep_options,
20532063 ..
20542064 } : & CreateOptions ,
2065+ password : Option < & [ u8 ] > ,
2066+ reencrypt_options : & mut ReencryptOptionsCache ,
20552067) -> io:: Result < Option < NormalEntry > > {
20562068 // Apply path transformation
20572069 let original_name = entry. header ( ) . path ( ) ;
@@ -2060,7 +2072,29 @@ fn transform_normal_entry(
20602072 return Ok ( None ) ;
20612073 } ;
20622074
2063- let mut result = entry. with_name ( new_name) ;
2075+ let mut result = if new_name == * entry. name ( ) {
2076+ // Keep the raw header bytes on a no-op rename: re-serializing the
2077+ // header would break cipher mode 2 (GCM) entries written by tools
2078+ // whose serialization differs from ours.
2079+ entry
2080+ } else if entry. encryption ( ) != pna:: Encryption :: No
2081+ && entry. cipher_mode ( ) == pna:: CipherMode :: GCM
2082+ {
2083+ // The GCM stream key is bound to the header bytes, so a renamed entry
2084+ // is only readable if it is decrypted and re-encrypted under the new
2085+ // name.
2086+ let Some ( password) = password else {
2087+ return Err ( io:: Error :: new (
2088+ io:: ErrorKind :: InvalidInput ,
2089+ format ! (
2090+ "cannot rename encrypted entry {original_name}: renaming a cipher mode 2 (GCM) entry requires the password to re-encrypt it"
2091+ ) ,
2092+ ) ) ;
2093+ } ;
2094+ re_encrypt_entry_with_name ( & entry, new_name, password, reencrypt_options) ?
2095+ } else {
2096+ entry. with_name ( new_name)
2097+ } ;
20642098
20652099 let mut metadata = result. metadata ( ) . clone ( ) ;
20662100 match keep_options. timestamp_strategy {
@@ -2136,6 +2170,47 @@ fn transform_normal_entry(
21362170 Ok ( Some ( result) )
21372171}
21382172
2173+ type ReencryptOptionsKey = ( pna:: Compression , pna:: Encryption , pna:: CipherMode ) ;
2174+ type ReencryptOptionsCache = HashMap < ReencryptOptionsKey , WriteOptions > ;
2175+
2176+ /// Decrypts `entry` and rebuilds it under `new_name`, re-encrypted with the
2177+ /// same encryption algorithm, cipher mode, and compression as the source
2178+ /// entry. The KDF parameters are not recoverable from a parsed entry, so the
2179+ /// rebuilt entry uses the default hash algorithm for its cipher mode.
2180+ fn re_encrypt_entry_with_name (
2181+ entry : & NormalEntry ,
2182+ new_name : pna:: EntryName ,
2183+ password : & [ u8 ] ,
2184+ reencrypt_options : & mut ReencryptOptionsCache ,
2185+ ) -> io:: Result < NormalEntry > {
2186+ let header = entry. header ( ) ;
2187+ let key = (
2188+ header. compression ( ) ,
2189+ header. encryption ( ) ,
2190+ header. cipher_mode ( ) ,
2191+ ) ;
2192+ let options = match reencrypt_options. entry ( key) {
2193+ std:: collections:: hash_map:: Entry :: Occupied ( entry) => entry. into_mut ( ) ,
2194+ std:: collections:: hash_map:: Entry :: Vacant ( entry) => {
2195+ let options = WriteOptions :: builder ( )
2196+ . compression ( header. compression ( ) )
2197+ . encryption ( header. encryption ( ) )
2198+ . cipher_mode ( header. cipher_mode ( ) )
2199+ . password ( Some ( password) )
2200+ . try_build ( ) ?;
2201+ entry. insert ( options)
2202+ }
2203+ } ;
2204+ let mut builder = EntryBuilder :: new_file ( new_name, & * options) ?;
2205+ let mut reader = entry. reader ( pna:: ReadOptions :: with_password ( Some ( password) ) ) ?;
2206+ io:: copy ( & mut reader, & mut builder) ?;
2207+ Ok ( builder
2208+ . build ( ) ?
2209+ . with_metadata ( entry. metadata ( ) . clone ( ) )
2210+ . with_xattrs ( entry. xattrs ( ) )
2211+ . with_extra_chunks ( entry. extra_chunks ( ) ) )
2212+ }
2213+
21392214#[ cfg( test) ]
21402215mod tests {
21412216 use super :: * ;
@@ -2161,6 +2236,35 @@ mod tests {
21612236 }
21622237 }
21632238
2239+ #[ test]
2240+ fn gcm_reencrypt_options_are_reused_for_matching_entries ( ) {
2241+ let source_options = WriteOptions :: builder ( )
2242+ . encryption ( pna:: Encryption :: Aes )
2243+ . cipher_mode ( pna:: CipherMode :: GCM )
2244+ . hash_algorithm ( pna:: HashAlgorithm :: pbkdf2_sha256_with ( Some ( 1 ) ) )
2245+ . password ( Some ( "password" ) )
2246+ . build ( ) ;
2247+ let mut source = EntryBuilder :: new_file ( "original" . into ( ) , & source_options) . unwrap ( ) ;
2248+ source. write_all ( b"secret payload" ) . unwrap ( ) ;
2249+ let source = source. build ( ) . unwrap ( ) ;
2250+ let mut cache = ReencryptOptionsCache :: new ( ) ;
2251+
2252+ let first =
2253+ re_encrypt_entry_with_name ( & source, "first" . into ( ) , b"password" , & mut cache) . unwrap ( ) ;
2254+ let second =
2255+ re_encrypt_entry_with_name ( & source, "second" . into ( ) , b"password" , & mut cache) . unwrap ( ) ;
2256+
2257+ assert_eq ! ( cache. len( ) , 1 ) ;
2258+ for entry in [ first, second] {
2259+ let mut reader = entry
2260+ . reader ( pna:: ReadOptions :: with_password ( Some ( "password" ) ) )
2261+ . unwrap ( ) ;
2262+ let mut out = Vec :: new ( ) ;
2263+ reader. read_to_end ( & mut out) . unwrap ( ) ;
2264+ assert_eq ! ( out, b"secret payload" ) ;
2265+ }
2266+ }
2267+
21642268 fn default_collect_options < ' a > (
21652269 filter : & ' a PathFilter < ' a > ,
21662270 time_filters : & ' a TimeFilters ,
0 commit comments