Skip to content

Commit b5a7c57

Browse files
committed
🐛 Re-encrypt or refuse renaming GCM entries in archive copy
Renaming a cipher mode 2 (GCM) entry during @archive copy silently produced an undecryptable entry because the stream key is bound to the header bytes. Renames now decrypt and re-encrypt with the source entry's algorithm and compression when the password is available, and fail otherwise. A no-op rename keeps the raw header bytes untouched. Re-encryption WriteOptions are cached per (compression, encryption, cipher mode) so the KDF runs once per combination instead of per entry.
1 parent a54fae2 commit b5a7c57

3 files changed

Lines changed: 313 additions & 3 deletions

File tree

cli/src/command/core.rs

Lines changed: 107 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ use pna::{
3232
};
3333
use 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)]
21402215
mod 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,

cli/tests/cli/stdio.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
mod archive_inclusion;
22
mod archive_source_uid_override;
3+
mod encrypted_rename;
34
mod files_from;
45
mod list_line_ending;
56
mod missing_file;
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
use crate::utils::{archive::for_each_entry_with_password, setup};
2+
use assert_cmd::cargo::cargo_bin_cmd;
3+
use std::fs::{self, File};
4+
use std::io::{Read, Write};
5+
use std::path::Path;
6+
7+
const PASSWORD: &str = "testpass";
8+
9+
fn create_encrypted_archive(
10+
path: impl AsRef<Path>,
11+
cipher_mode: pna::CipherMode,
12+
entries: &[(&str, &str)],
13+
) {
14+
let path = path.as_ref();
15+
if let Some(parent) = path.parent() {
16+
fs::create_dir_all(parent).unwrap();
17+
}
18+
let options = pna::WriteOptions::builder()
19+
.encryption(pna::Encryption::Aes)
20+
.cipher_mode(cipher_mode)
21+
.hash_algorithm(pna::HashAlgorithm::pbkdf2_sha256_with(Some(1)))
22+
.password(Some(PASSWORD))
23+
.build();
24+
let file = File::create(path).unwrap();
25+
let mut writer = pna::Archive::write_header(file).unwrap();
26+
for (name, contents) in entries {
27+
writer
28+
.add_entry({
29+
let mut builder = pna::EntryBuilder::new_file((*name).into(), &options).unwrap();
30+
builder.write_all(contents.as_bytes()).unwrap();
31+
builder.build().unwrap()
32+
})
33+
.unwrap();
34+
}
35+
writer.finalize().unwrap();
36+
}
37+
38+
fn read_entry_data(entry: &pna::NormalEntry, password: &str) -> Vec<u8> {
39+
let mut reader = entry
40+
.reader(pna::ReadOptions::with_password(Some(password)))
41+
.unwrap();
42+
let mut data = Vec::new();
43+
reader.read_to_end(&mut data).unwrap();
44+
data
45+
}
46+
47+
/// Precondition: A source archive contains an AES-256-GCM (cipher mode 2) encrypted entry.
48+
/// Action: Copy it into a new archive via stdio with a `-s` rename and the password.
49+
/// Expectation: The command succeeds and the renamed entry decrypts to the original content.
50+
#[test]
51+
fn stdio_rename_gcm_entry_reencrypts_with_password() {
52+
setup();
53+
let base = "stdio_rename_gcm_entry_reencrypts_with_password";
54+
fs::create_dir_all(base).unwrap();
55+
create_encrypted_archive(
56+
format!("{base}/source.pna"),
57+
pna::CipherMode::GCM,
58+
&[("dir/secret.txt", "secret content")],
59+
);
60+
61+
cargo_bin_cmd!("pna")
62+
.args([
63+
"--quiet",
64+
"experimental",
65+
"stdio",
66+
"--unstable",
67+
"-c",
68+
"--overwrite",
69+
"-f",
70+
&format!("{base}/output.pna"),
71+
"-C",
72+
base,
73+
"-s",
74+
",dir/,renamed/,",
75+
"--password",
76+
PASSWORD,
77+
"@source.pna",
78+
])
79+
.assert()
80+
.success();
81+
82+
let mut names = Vec::new();
83+
for_each_entry_with_password(format!("{base}/output.pna"), PASSWORD, |entry| {
84+
names.push(entry.header().path().to_string());
85+
assert_eq!(read_entry_data(&entry, PASSWORD), b"secret content");
86+
})
87+
.unwrap();
88+
assert_eq!(names, ["renamed/secret.txt"]);
89+
}
90+
91+
/// Precondition: A source archive contains an AES-256-GCM (cipher mode 2) encrypted entry.
92+
/// Action: Copy it into a new archive via stdio with a `-s` rename but without a password.
93+
/// Expectation: The command fails instead of silently emitting an undecryptable entry.
94+
#[test]
95+
fn stdio_rename_gcm_entry_without_password_fails() {
96+
setup();
97+
let base = "stdio_rename_gcm_entry_without_password_fails";
98+
fs::create_dir_all(base).unwrap();
99+
create_encrypted_archive(
100+
format!("{base}/source.pna"),
101+
pna::CipherMode::GCM,
102+
&[("dir/secret.txt", "secret content")],
103+
);
104+
105+
cargo_bin_cmd!("pna")
106+
.args([
107+
"--quiet",
108+
"experimental",
109+
"stdio",
110+
"--unstable",
111+
"-c",
112+
"--overwrite",
113+
"-f",
114+
&format!("{base}/output.pna"),
115+
"-C",
116+
base,
117+
"-s",
118+
",dir/,renamed/,",
119+
"@source.pna",
120+
])
121+
.assert()
122+
.failure();
123+
}
124+
125+
/// Precondition: A source archive contains an AES-256-GCM (cipher mode 2) encrypted entry.
126+
/// Action: Copy it into a new archive via stdio without any rename option and without a password.
127+
/// Expectation: The command succeeds and the entry is passed through still decryptable.
128+
#[test]
129+
fn stdio_copy_gcm_entry_without_rename_stays_readable() {
130+
setup();
131+
let base = "stdio_copy_gcm_entry_without_rename_stays_readable";
132+
fs::create_dir_all(base).unwrap();
133+
create_encrypted_archive(
134+
format!("{base}/source.pna"),
135+
pna::CipherMode::GCM,
136+
&[("dir/secret.txt", "secret content")],
137+
);
138+
139+
cargo_bin_cmd!("pna")
140+
.args([
141+
"--quiet",
142+
"experimental",
143+
"stdio",
144+
"--unstable",
145+
"-c",
146+
"--overwrite",
147+
"-f",
148+
&format!("{base}/output.pna"),
149+
"-C",
150+
base,
151+
"@source.pna",
152+
])
153+
.assert()
154+
.success();
155+
156+
let mut names = Vec::new();
157+
for_each_entry_with_password(format!("{base}/output.pna"), PASSWORD, |entry| {
158+
names.push(entry.header().path().to_string());
159+
assert_eq!(read_entry_data(&entry, PASSWORD), b"secret content");
160+
})
161+
.unwrap();
162+
assert_eq!(names, ["dir/secret.txt"]);
163+
}
164+
165+
/// Precondition: A source archive contains an AES-256-CBC encrypted entry.
166+
/// Action: Copy it into a new archive via stdio with a `-s` rename and without a password.
167+
/// Expectation: The command succeeds (CBC is not header-bound) and the renamed entry decrypts.
168+
#[test]
169+
fn stdio_rename_cbc_entry_stays_readable() {
170+
setup();
171+
let base = "stdio_rename_cbc_entry_stays_readable";
172+
fs::create_dir_all(base).unwrap();
173+
create_encrypted_archive(
174+
format!("{base}/source.pna"),
175+
pna::CipherMode::CBC,
176+
&[("dir/secret.txt", "secret content")],
177+
);
178+
179+
cargo_bin_cmd!("pna")
180+
.args([
181+
"--quiet",
182+
"experimental",
183+
"stdio",
184+
"--unstable",
185+
"-c",
186+
"--overwrite",
187+
"-f",
188+
&format!("{base}/output.pna"),
189+
"-C",
190+
base,
191+
"-s",
192+
",dir/,renamed/,",
193+
"@source.pna",
194+
])
195+
.assert()
196+
.success();
197+
198+
let mut names = Vec::new();
199+
for_each_entry_with_password(format!("{base}/output.pna"), PASSWORD, |entry| {
200+
names.push(entry.header().path().to_string());
201+
assert_eq!(read_entry_data(&entry, PASSWORD), b"secret content");
202+
})
203+
.unwrap();
204+
assert_eq!(names, ["renamed/secret.txt"]);
205+
}

0 commit comments

Comments
 (0)