Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions apps/fluux/src-tauri/src/openpgp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1335,6 +1335,7 @@ impl DecryptionHelper for DecryptHelper<'_> {
sym_algo: Option<SymmetricAlgorithm>,
decrypt: &mut dyn FnMut(Option<SymmetricAlgorithm>, &SessionKey) -> bool,
) -> openpgp::Result<Option<Cert>> {
// First pass: try currently-valid encryption subkeys (common case).
for ka in self
.secret
.keys()
Expand All @@ -1353,6 +1354,27 @@ impl DecryptionHelper for DecryptHelper<'_> {
}
}
}

// Second pass: try ALL secret subkeys regardless of policy/expiry.
// Historical MAM messages carry PKESK packets targeted at the
// encryption subkey that was current at send time. After subkey
// rotation the retired subkey is expired but its secret material
// is still in the local cert — we must try it. Also covers
// cross-implementation edge cases where the sender's library
// targeted a subkey that our policy filter doesn't surface.
for key in self.secret.keys().secret() {
let mut pair = key.key().clone().into_keypair()?;
for pkesk in pkesks {
if pkesk
.decrypt(&mut pair, sym_algo)
.map(|(algo, sk)| decrypt(algo, &sk))
.unwrap_or(false)
{
return Ok(Some(self.secret.clone()));
}
}
}

Ok(None)
}
}
Expand Down Expand Up @@ -2047,6 +2069,39 @@ mod tests {
);
}

#[test]
fn ciphertext_encrypted_to_expired_subkey_still_decrypts() {
// The rotation expires the old subkey with a 1-second validity
// window. After that window closes, the policy-filtered first
// pass in DecryptionHelper won't find the old subkey. The
// second pass (all secret keys, no policy) must still succeed.
let (state, _alice, bob) = setup_two_accounts();

let pre_rotation_ciphertext = state
.encrypt("bob@example.com", &_alice.public_armored, "historical message")
.unwrap();

let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let _rotated = runtime
.block_on(state.rotate_encryption_subkey("alice@example.com".into()))
.unwrap();

// Wait for the 1-second validity window to close.
std::thread::sleep(std::time::Duration::from_secs(2));

let out = state
.decrypt(
"alice@example.com",
&pre_rotation_ciphertext,
Some(&bob.public_armored),
)
.unwrap();
assert_eq!(out.plaintext, "historical message");
}

#[test]
fn new_ciphertext_targets_only_the_current_subkey_after_rotation() {
// Encryption filters recipients with `.alive()`, so senders must
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading