Skip to content

Commit fe488f4

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 130dcaa commit fe488f4

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},
@@ -1885,7 +1886,10 @@ pub(crate) fn apply_chroot(chroot: bool) -> anyhow::Result<()> {
18851886
/// if a password is provided; without a password they will cause an error.
18861887
///
18871888
/// The entry data (FDAT chunks) is preserved as-is, maintaining the original
1888-
/// compression and encryption. Only the entry headers (paths, ownership) are modified.
1889+
/// compression and encryption. Only the entry headers (paths, ownership) are
1890+
/// modified. The exception is renaming a cipher mode 2 (GCM) entry, whose data
1891+
/// is bound to the header bytes: it is decrypted and re-encrypted under the new
1892+
/// name using the provided password, and refused when no password is available.
18891893
///
18901894
/// Entries whose path matches the filter exclusion rules will be skipped.
18911895
/// Time filters are also applied to filter entries by timestamps.
@@ -1898,6 +1902,7 @@ pub(crate) fn transform_archive_entries<R: io::Read>(
18981902
allow_concatenated_archives: bool,
18991903
) -> io::Result<Vec<io::Result<Option<NormalEntry>>>> {
19001904
let mut results = Vec::new();
1905+
let mut reencrypt_options = HashMap::new();
19011906
run_process_archive(
19021907
std::iter::once(reader),
19031908
|| password,
@@ -1911,7 +1916,12 @@ pub(crate) fn transform_archive_entries<R: io::Read>(
19111916
if !time_filters.matches(ctime, mtime) {
19121917
return Ok(());
19131918
}
1914-
results.push(transform_normal_entry(entry, create_options));
1919+
results.push(transform_normal_entry(
1920+
entry,
1921+
create_options,
1922+
password,
1923+
&mut reencrypt_options,
1924+
));
19151925
Ok(())
19161926
},
19171927
allow_concatenated_archives,
@@ -2042,6 +2052,8 @@ fn transform_normal_entry(
20422052
keep_options,
20432053
..
20442054
}: &CreateOptions,
2055+
password: Option<&[u8]>,
2056+
reencrypt_options: &mut ReencryptOptionsCache,
20452057
) -> io::Result<Option<NormalEntry>> {
20462058
// Apply path transformation
20472059
let original_name = entry.header().path();
@@ -2050,7 +2062,29 @@ fn transform_normal_entry(
20502062
return Ok(None);
20512063
};
20522064

2053-
let mut result = entry.with_name(new_name);
2065+
let mut result = if new_name == *entry.name() {
2066+
// Keep the raw header bytes on a no-op rename: re-serializing the
2067+
// header would break cipher mode 2 (GCM) entries written by tools
2068+
// whose serialization differs from ours.
2069+
entry
2070+
} else if entry.encryption() != pna::Encryption::No
2071+
&& entry.cipher_mode() == pna::CipherMode::GCM
2072+
{
2073+
// The GCM stream key is bound to the header bytes, so a renamed entry
2074+
// is only readable if it is decrypted and re-encrypted under the new
2075+
// name.
2076+
let Some(password) = password else {
2077+
return Err(io::Error::new(
2078+
io::ErrorKind::InvalidInput,
2079+
format!(
2080+
"cannot rename encrypted entry {original_name}: renaming a cipher mode 2 (GCM) entry requires the password to re-encrypt it"
2081+
),
2082+
));
2083+
};
2084+
re_encrypt_entry_with_name(&entry, new_name, password, reencrypt_options)?
2085+
} else {
2086+
entry.with_name(new_name)
2087+
};
20542088

20552089
let mut metadata = result.metadata().clone();
20562090
match keep_options.timestamp_strategy {
@@ -2126,6 +2160,47 @@ fn transform_normal_entry(
21262160
Ok(Some(result))
21272161
}
21282162

2163+
type ReencryptOptionsKey = (pna::Compression, pna::Encryption, pna::CipherMode);
2164+
type ReencryptOptionsCache = HashMap<ReencryptOptionsKey, WriteOptions>;
2165+
2166+
/// Decrypts `entry` and rebuilds it under `new_name`, re-encrypted with the
2167+
/// same encryption algorithm, cipher mode, and compression as the source
2168+
/// entry. The KDF parameters are not recoverable from a parsed entry, so the
2169+
/// rebuilt entry uses the default hash algorithm for its cipher mode.
2170+
fn re_encrypt_entry_with_name(
2171+
entry: &NormalEntry,
2172+
new_name: pna::EntryName,
2173+
password: &[u8],
2174+
reencrypt_options: &mut ReencryptOptionsCache,
2175+
) -> io::Result<NormalEntry> {
2176+
let header = entry.header();
2177+
let key = (
2178+
header.compression(),
2179+
header.encryption(),
2180+
header.cipher_mode(),
2181+
);
2182+
let options = match reencrypt_options.entry(key) {
2183+
std::collections::hash_map::Entry::Occupied(entry) => entry.into_mut(),
2184+
std::collections::hash_map::Entry::Vacant(entry) => {
2185+
let options = WriteOptions::builder()
2186+
.compression(header.compression())
2187+
.encryption(header.encryption())
2188+
.cipher_mode(header.cipher_mode())
2189+
.password(Some(password))
2190+
.try_build()?;
2191+
entry.insert(options)
2192+
}
2193+
};
2194+
let mut builder = EntryBuilder::new_file(new_name, &*options)?;
2195+
let mut reader = entry.reader(pna::ReadOptions::with_password(Some(password)))?;
2196+
io::copy(&mut reader, &mut builder)?;
2197+
Ok(builder
2198+
.build()?
2199+
.with_metadata(entry.metadata().clone())
2200+
.with_xattrs(entry.xattrs())
2201+
.with_extra_chunks(entry.extra_chunks()))
2202+
}
2203+
21292204
#[cfg(test)]
21302205
mod tests {
21312206
use super::*;
@@ -2151,6 +2226,35 @@ mod tests {
21512226
}
21522227
}
21532228

2229+
#[test]
2230+
fn gcm_reencrypt_options_are_reused_for_matching_entries() {
2231+
let source_options = WriteOptions::builder()
2232+
.encryption(pna::Encryption::Aes)
2233+
.cipher_mode(pna::CipherMode::GCM)
2234+
.hash_algorithm(pna::HashAlgorithm::pbkdf2_sha256_with(Some(1)))
2235+
.password(Some("password"))
2236+
.build();
2237+
let mut source = EntryBuilder::new_file("original".into(), &source_options).unwrap();
2238+
source.write_all(b"secret payload").unwrap();
2239+
let source = source.build().unwrap();
2240+
let mut cache = ReencryptOptionsCache::new();
2241+
2242+
let first =
2243+
re_encrypt_entry_with_name(&source, "first".into(), b"password", &mut cache).unwrap();
2244+
let second =
2245+
re_encrypt_entry_with_name(&source, "second".into(), b"password", &mut cache).unwrap();
2246+
2247+
assert_eq!(cache.len(), 1);
2248+
for entry in [first, second] {
2249+
let mut reader = entry
2250+
.reader(pna::ReadOptions::with_password(Some("password")))
2251+
.unwrap();
2252+
let mut out = Vec::new();
2253+
reader.read_to_end(&mut out).unwrap();
2254+
assert_eq!(out, b"secret payload");
2255+
}
2256+
}
2257+
21542258
fn default_collect_options<'a>(
21552259
filter: &'a PathFilter<'a>,
21562260
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)