Compare commits

...
Author SHA1 Message Date
link2xt a1675f46bc feat: merging and minimization of subkeys 2026-09-24 11:46:55 +00:00
link2xt 36384f977f feat: take key flags and expiration into account when selecting the key 2026-09-24 11:46:55 +00:00
link2xt 7fcc612e1e refactor: remove Context argument from secret_key_to_public_key() 2026-09-24 11:46:55 +00:00
link2xt 8ab98019a7 feat(deltachat-repl): remove "reset" command
This is not a correct way to reset the database, it does not even clear the transports table
so account stays configured. If someone needs a fresh database, then REPL should be restarted
with a new path.
2026-09-23 12:52:17 +00:00
6 changed files with 273 additions and 78 deletions
-59
View File
@@ -27,55 +27,6 @@ use deltachat::sql;
use deltachat::tools::*;
use tokio::fs;
/// Reset database tables.
/// Argument is a bitmask, executing single or multiple actions in one call.
/// e.g. bitmask 7 triggers actions defined with bits 1, 2 and 4.
async fn reset_tables(context: &Context, bits: i32) {
println!("Resetting tables ({bits})...");
if 0 != bits & 4 {
context
.sql()
.execute("DELETE FROM keypairs;", ())
.await
.unwrap();
println!("(4) Private keypairs reset.");
}
if 0 != bits & 8 {
context
.sql()
.execute("DELETE FROM contacts WHERE id>9;", ())
.await
.unwrap();
context
.sql()
.execute("DELETE FROM chats WHERE id>9;", ())
.await
.unwrap();
context
.sql()
.execute("DELETE FROM chats_contacts;", ())
.await
.unwrap();
context
.sql()
.execute("DELETE FROM msgs WHERE id>9;", ())
.await
.unwrap();
context
.sql()
.execute(
"DELETE FROM config WHERE keyname LIKE 'imap.%' OR keyname LIKE 'configured%';",
(),
)
.await
.unwrap();
context.sql().config_cache().write().await.clear();
println!("(8) Rest but server config reset.");
}
context.emit_msgs_changed_without_ids();
}
async fn poke_eml_file(context: &Context, filename: &Path) -> Result<()> {
let data = read_file(context, filename).await?;
@@ -304,7 +255,6 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
export-keys\n\
import-keys <key-file>\n\
poke [<eml-file>|<folder>|<addr> <key-file>]\n\
reset <flags>\n\
stop\n\
============================================="
),
@@ -444,15 +394,6 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
"poke" => {
ensure!(poke_spec(&context, Some(arg1)).await, "Poke failed");
}
"reset" => {
ensure!(
!arg1.is_empty(),
"Argument <bits> missing: 4=private keys, 8=rest but server config"
);
let bits: i32 = arg1.parse()?;
ensure!(bits < 16, "<bits> must be lower than 16.");
reset_tables(&context, bits).await;
}
"stop" => {
context.stop_ongoing().await;
}
+1 -2
View File
@@ -147,7 +147,7 @@ impl Completer for DcHelper {
}
}
const IMEX_COMMANDS: [&str; 10] = [
const IMEX_COMMANDS: [&str; 9] = [
"has-backup",
"export-backup",
"import-backup",
@@ -156,7 +156,6 @@ const IMEX_COMMANDS: [&str; 10] = [
"export-keys",
"import-keys",
"poke",
"reset",
"stop",
];
+1 -4
View File
@@ -124,14 +124,11 @@ pub trait DcKey: Serialize + Deserializable + Clone {
/// Converts secret key to public key.
pub(crate) fn secret_key_to_public_key(
context: &Context,
mut signed_secret_key: SignedSecretKey,
timestamp: u32,
addr: &str,
relay_addrs: &str,
) -> Result<SignedPublicKey> {
info!(context, "Converting secret key to public key.");
// Make sure timestamp of created signatures
// is not in the past compared to the primary key timestamp.
let timestamp = std::cmp::max(
@@ -304,7 +301,7 @@ pub(crate) async fn load_self_public_key_opt(context: &Context) -> Result<Option
let addr = context.get_primary_self_addr().await?;
let all_addrs = context.get_self_addrs().await?.join(",");
let signed_public_key =
secret_key_to_public_key(context, signed_secret_key, timestamp, &addr, &all_addrs)?;
secret_key_to_public_key(signed_secret_key, timestamp, &addr, &all_addrs)?;
*lock = Some(signed_public_key.clone());
Ok(Some(signed_public_key))
+5 -2
View File
@@ -331,9 +331,10 @@ pub(crate) fn render_queued_mail(
outer_headers.extend(b"MIME-Version: 1.0\r\n");
if should_attach_pubkey {
let public_key = crate::pgp::minimize_autocrypt_certificate(public_key);
let aheader = Aheader {
addr: from_addr,
public_key: public_key.clone(),
public_key,
prefer_encrypt: EncryptPreference::Mutual,
};
let autocrypt_header = mail_builder::headers::raw::Raw::new(aheader.to_string());
@@ -1472,9 +1473,11 @@ impl MimeFactory {
continue;
}
let public_key = crate::pgp::minimize_autocrypt_certificate(key);
let header = Aheader {
addr: addr.clone(),
public_key: key.clone(),
public_key,
// Autocrypt 1.1.0 specification says that
// `prefer-encrypt` attribute SHOULD NOT be included.
prefer_encrypt: EncryptPreference::NoPreference,
-1
View File
@@ -341,7 +341,6 @@ async fn test_mdn_sent_to_all_relays() -> Result<()> {
// Bob's key gets a second relay address and Alice merges the newer key.
let bob_secret_key = load_self_secret_key(bob).await?;
let bob_public_key = secret_key_to_public_key(
bob,
bob_secret_key,
u32::try_from(time())? + 100,
"bob@example.net",
+266 -10
View File
@@ -1,6 +1,8 @@
//! OpenPGP helper module using [rPGP facilities](https://github.com/rpgp/rpgp).
use std::collections::{HashMap, HashSet};
use std::cmp::Ordering;
use std::collections::btree_map::Entry as BTreeMapEntry;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::io::Cursor;
use anyhow::{Context as _, Result, ensure};
@@ -14,7 +16,7 @@ use pgp::crypto::aead::{AeadAlgorithm, ChunkSize};
use pgp::crypto::ecc_curve::ECCCurve;
use pgp::crypto::hash::HashAlgorithm;
use pgp::crypto::sym::SymmetricKeyAlgorithm;
use pgp::packet::{Signature, Subpacket, SubpacketData};
use pgp::packet::{Signature, SignatureType, Subpacket, SubpacketData};
use pgp::types::{
CompressionAlgorithm, Imprint, KeyDetails, KeyVersion, Password, SignedUser, SigningKey as _,
StringToKey,
@@ -83,13 +85,62 @@ pub(crate) fn create_keypair(addr: EmailAddress) -> Result<SignedSecretKey> {
/// Selects a subkey of the public key to use for encryption.
///
/// Returns `None` if the public key cannot be used for encryption.
/// The key is selected according to
/// <https://www.ietf.org/archive/id/draft-autocrypt-openpgp-v2-cert-03.html#section-4.3-4>.
/// If multiple keys are available, the one that will expire sooner is selected.
///
/// TODO: take key flags and expiration dates into account
fn select_pk_for_encryption(key: &SignedPublicKey) -> Option<&SignedPublicSubKey> {
/// Returns `None` if the public key cannot be used for encryption.
fn select_pk_for_encryption(now: u32, key: &SignedPublicKey) -> Option<&SignedPublicSubKey> {
key.public_subkeys
.iter()
.find(|subkey| subkey.algorithm().can_encrypt())
.filter(|subkey| subkey.algorithm().can_encrypt())
.filter_map(|subkey| {
let signature = subkey.signatures.first()?;
let key_flags = signature.key_flags();
if !key_flags.encrypt_comms() {
return None;
}
if let Some(expiration_duration) = signature
.key_expiration_time()
.filter(|duration| duration.as_secs() != 0)
&& now
> subkey
.created_at()
.as_secs()
.saturating_add(expiration_duration.as_secs())
{
// Key is expired.
return None;
}
Some((subkey, signature))
})
.min_by(|(subkey1, signature1), (subkey2, signature2)| {
match (
signature1
.key_expiration_time()
.filter(|duration| duration.as_secs() != 0),
signature2
.key_expiration_time()
.filter(|duration| duration.as_secs() != 0),
) {
(None, None) => Ordering::Equal,
(None, Some(_)) => Ordering::Greater,
(Some(_), None) => Ordering::Less,
(Some(expiration1), Some(expiration2)) => (subkey1
.created_at()
.as_secs()
.saturating_add(expiration1.as_secs()))
.cmp(
&(subkey2
.created_at()
.as_secs()
.saturating_add(expiration2.as_secs())),
),
}
})
.map(|(subkey, _signature)| subkey)
}
/// Version of SEIPD packet to use.
@@ -151,10 +202,11 @@ pub fn pk_encrypt(
) -> Result<String> {
tokio::task::block_in_place(|| {
let mut rng = thread_rng();
let now = pgp::types::Timestamp::now();
let pkeys = public_keys_for_encryption
.iter()
.filter_map(select_pk_for_encryption);
.filter_map(|key| select_pk_for_encryption(now.as_secs(), key));
let msg = MessageBuilder::from_bytes("", plain);
let encoded_msg = match seipd_version {
@@ -310,6 +362,161 @@ pub fn symm_encrypt_message(
})
}
/// Minimizes the signatures of a subkey.
///
/// Keeps at most one subkey binding signature
/// and at most one revocation signature,
/// preferring the newest signatures.
///
/// Subkey binding signature is kept
/// even if the revocation signature exists
/// because according to
/// <https://www.rfc-editor.org/rfc/rfc9580.html#name-openpgp-version-6-certifica>
/// "Every subkey MUST have at least one Subkey Binding signature."
/// Distributing subkey with only a revocation signature
/// is not allowed according to the standard,
/// so we keep a subkey binding signature next to it
/// for interoperability.
///
/// This function does not check if the signatures are valid.
/// Such properties should be validated when importing OpenPGP certificates.
fn minimize_subpacket_signatures(signatures: Vec<Signature>) -> Vec<Signature> {
let mut newest_revocation_signature: Option<Signature> = None;
let mut newest_binding_signature: Option<Signature> = None;
for signature in signatures {
let Some(config) = signature.config() else {
// Skip unknown signatures.
continue;
};
match config.typ {
SignatureType::SubkeyBinding => {
if newest_binding_signature
.as_ref()
.is_none_or(|s| s.created() < signature.created())
{
newest_binding_signature = Some(signature)
}
}
SignatureType::SubkeyRevocation => {
if newest_revocation_signature
.as_ref()
.is_none_or(|s| s.created() < signature.created())
{
newest_revocation_signature = Some(signature)
}
}
_ => continue,
}
}
newest_revocation_signature
.into_iter()
.chain(newest_binding_signature)
.collect()
}
/// Minimizes OpenPGP certificate for Autocrypt and Autocrypt-Gossip headers.
pub fn minimize_autocrypt_certificate(certificate: &SignedPublicKey) -> SignedPublicKey {
let primary_key = certificate.primary_key.clone();
let details = certificate.details.clone();
// Select the newest non-expiring subkey and the newest expiring subkey.
let fallback_subkey = certificate
.public_subkeys
.iter()
.filter(|subkey| {
subkey
.signatures
.iter()
.find(|signature| {
signature
.config()
.is_some_and(|config| config.typ == SignatureType::SubkeyBinding)
})
.is_some_and(|signature| signature.key_expiration_time().is_none())
})
.max_by_key(|subkey| {
subkey
.signatures
.iter()
.find(|signature| {
signature
.config()
.is_some_and(|config| config.typ == SignatureType::SubkeyBinding)
})
.map(|signature| signature.created().unwrap_or(subkey.created_at()))
});
let rotating_subkey = certificate
.public_subkeys
.iter()
.filter(|subkey| {
subkey
.signatures
.iter()
.find(|signature| {
signature
.config()
.is_some_and(|config| config.typ == SignatureType::SubkeyBinding)
})
.is_some_and(|signature| signature.key_expiration_time().is_some())
})
.max_by_key(|subkey| {
subkey
.signatures
.iter()
.find(|signature| {
signature
.config()
.is_some_and(|config| config.typ == SignatureType::SubkeyBinding)
})
.map(|signature| signature.created().unwrap_or(subkey.created_at()))
});
let public_subkeys: Vec<_> = fallback_subkey
.into_iter()
.chain(rotating_subkey)
.cloned()
.collect();
// We do not want to ever gossip more than two subkeys
// to save the traffic.
debug_assert!(public_subkeys.len() <= 2);
SignedPublicKey {
primary_key,
details,
public_subkeys,
}
}
/// Merges two OpenPGP subkeys.
fn merge_openpgp_subkey(old_subkey: &mut SignedPublicSubKey, new_subkey: SignedPublicSubKey) {
debug_assert_eq!(old_subkey.fingerprint(), new_subkey.fingerprint());
old_subkey.signatures = minimize_subpacket_signatures(
std::mem::take(&mut old_subkey.signatures)
.into_iter()
.chain(new_subkey.signatures)
.collect(),
);
}
/// Merges OpenPGP subkey vectors.
pub fn merge_openpgp_subkeys(
subkeys: impl IntoIterator<Item = SignedPublicSubKey>,
) -> Result<Vec<SignedPublicSubKey>> {
let mut merged_subkeys: BTreeMap<_, SignedPublicSubKey> = BTreeMap::new();
for subkey in subkeys {
let imprint = subkey.imprint::<Sha256>()?;
match merged_subkeys.entry(imprint) {
BTreeMapEntry::Vacant(entry) => {
entry.insert(subkey);
}
BTreeMapEntry::Occupied(entry) => {
merge_openpgp_subkey(entry.into_mut(), subkey);
}
}
}
Ok(merged_subkeys.into_values().collect())
}
/// Merges and minimizes OpenPGP certificates.
///
/// Keeps at most one direct key signature and
@@ -346,7 +553,7 @@ pub fn merge_openpgp_certificates(
let SignedPublicKey {
primary_key: new_primary_key,
details: new_details,
public_subkeys: _new_public_subkeys,
public_subkeys: new_public_subkeys,
} = new_certificate;
// Public keys may be serialized differently, e.g. using old and new packet type,
@@ -422,7 +629,55 @@ pub fn merge_openpgp_certificates(
});
let users: Vec<SignedUser> = best_user.into_iter().collect();
let public_subkeys = old_public_subkeys;
let (fallback_subkeys, mut rotating_subkeys): (Vec<_>, Vec<_>) =
merge_openpgp_subkeys(old_public_subkeys.into_iter().chain(new_public_subkeys))?
.into_iter()
.filter_map(|subkey| {
// Select the newest subkey binding signature.
//
// There is at most one subkey binding signature at this point
// because older subkey binding signatures are removed during merging.
let signature = subkey.signatures.iter().find(|signature| {
signature
.config()
.is_some_and(|config| config.typ == SignatureType::SubkeyBinding)
})?;
let created_at_secs = signature.created().unwrap_or(subkey.created_at()).as_secs();
let expires_at_secs: Option<u32> = signature
.key_expiration_time()
.map(|duration| duration.as_secs())
.filter(|duration_secs| *duration_secs != 0)
.map(|duration_secs| {
subkey.created_at().as_secs().saturating_add(duration_secs)
});
Some((subkey, created_at_secs, expires_at_secs))
})
.partition(|(_subkey, _created_at_secs, expires_at_secs)| expires_at_secs.is_none());
let fallback_subkey: Option<SignedPublicSubKey> = fallback_subkeys
.into_iter()
.max_by_key(|(_subkey, created_at_secs, _)| *created_at_secs)
.map(|(subkey, _, _)| subkey);
rotating_subkeys
.sort_by_key(|(_subkey, created_at_secs, _)| std::cmp::Reverse(*created_at_secs));
// Put the fallback subkey first so it is gossiped first.
//
// We want to always gossip non-expiring key first
// for older versions that always encrypted to the first subkey.
//
// Keep 10 newest rotating subkeys to avoid storing indefinitely growing number of subkeys locally.
let public_subkeys = fallback_subkey
.into_iter()
.chain(
rotating_subkeys
.into_iter()
.take(10)
.map(|(subkey, _, _)| subkey),
)
.collect();
Ok(SignedPublicKey {
primary_key: old_primary_key,
@@ -485,7 +740,8 @@ pub(crate) fn relay_addrs(public_key: &SignedPublicKey, addr: &str) -> Vec<Strin
/// Returns true if the key can be encrypted to, i.e. has an encryption subkey.
pub(crate) fn pubkey_can_encrypt(public_key: &SignedPublicKey) -> bool {
select_pk_for_encryption(public_key).is_some()
let now = pgp::types::Timestamp::now();
select_pk_for_encryption(now.as_secs(), public_key).is_some()
}
/// Returns true if public key advertises SEIPDv2 feature.