Compare commits

..
31 changed files with 928 additions and 216 deletions
Generated
+1
View File
@@ -1355,6 +1355,7 @@ dependencies = [
"futures",
"futures-lite",
"hex",
"hkdf",
"http-body-util",
"humansize",
"hyper",
+1
View File
@@ -61,6 +61,7 @@ fd-lock = "4"
futures-lite = { workspace = true }
futures = { workspace = true }
hex = "0.4.0"
hkdf = { version = "0.12", default-features = false }
http-body-util = "0.1.3"
humansize = "2"
hyper = "1"
+1 -1
View File
@@ -163,7 +163,7 @@ async fn maybe_add_additional_relays_inner(context: &Context, skip_network: bool
for _ in 0..NUM_TRANSPORTS_TARGET {
if context.count_transports().await? >= NUM_TRANSPORTS_TARGET {
context
.set_config_internal(Config::AutorelayFinished, Some(config::from_bool(true)))
.set_config_internal(Config::AutorelayFinished, config::from_bool(true))
.await?;
return Ok(relay_added);
+1 -1
View File
@@ -3639,7 +3639,7 @@ pub(crate) async fn create_out_broadcast_ext(
)?;
ensure!(cnt == 0, "{cnt} chats exist with grpid {grpid}");
let mut params: Params = Params::new();
params.update_timestamp(Param::GroupNameTimestamp, time());
params.update_timestamp(Param::GroupNameTimestamp, time())?;
t.execute(
"INSERT INTO chats
+7 -3
View File
@@ -467,6 +467,10 @@ pub enum Config {
/// and incoming unencrypted messages are not fetched and not processed.
#[strum(props(default = "1"))]
ForceEncryption,
/// Generate Autocrypt 2 instead of Autocrypt 1 key.
#[strum(props(default = "1"))]
Autocrypt2,
}
impl Config {
@@ -809,7 +813,7 @@ impl Context {
/// Set the given config to a boolean value.
pub async fn set_config_bool(&self, key: Config, value: bool) -> Result<()> {
self.set_config(key, Some(from_bool(value))).await?;
self.set_config(key, from_bool(value)).await?;
Ok(())
}
@@ -830,8 +834,8 @@ impl Context {
}
/// Returns a value for use in `Context::set_config_*()` for the given `bool`.
pub(crate) fn from_bool(val: bool) -> &'static str {
if val { "1" } else { "0" }
pub(crate) fn from_bool(val: bool) -> Option<&'static str> {
Some(if val { "1" } else { "0" })
}
pub(crate) fn bool_from_config(config: Option<&str>) -> bool {
+1 -1
View File
@@ -29,7 +29,7 @@ impl ServerParams {
if self.username.is_empty() {
vec![Self {
username: addr.to_string(),
..self
..self.clone()
}]
} else {
vec![self]
+4
View File
@@ -1030,6 +1030,10 @@ impl Context {
.await?
.to_string(),
);
res.insert(
"autocrypt2",
self.get_config_bool(Config::Autocrypt2).await?.to_string(),
);
let elapsed = time_elapsed(&self.creation_time);
res.insert("uptime", duration_to_str(elapsed));
+2 -2
View File
@@ -1454,7 +1454,7 @@ impl Session {
/// or flags have been changed.
/// In this case we may want to skip next IDLE and do a round
/// of fetching new messages and synchronizing seen flags.
fn drain_unsolicited_responses(&self, context: &Context) -> bool {
fn drain_unsolicited_responses(&self, context: &Context) -> Result<bool> {
use UnsolicitedResponse::*;
use async_imap::imap_proto::Response;
use async_imap::imap_proto::ResponseCode;
@@ -1499,7 +1499,7 @@ impl Session {
}
}
}
should_refetch
Ok(should_refetch)
}
}
+1 -1
View File
@@ -31,7 +31,7 @@ impl Session {
self.select_with_uidvalidity(context, folder).await?;
if self.drain_unsolicited_responses(context) {
if self.drain_unsolicited_responses(context)? {
self.new_mail = true;
}
+13 -4
View File
@@ -17,10 +17,12 @@ use pgp::packet::{
SubpacketData,
};
use pgp::ser::Serialize;
use pgp::types::Timestamp as PgpTimestamp;
use pgp::types::{CompressionAlgorithm, KeyDetails, KeyVersion};
use rand_old::thread_rng;
use tokio::runtime::Handle;
use crate::config::Config;
use crate::context::Context;
use crate::events::EventType;
use crate::log::LogExt;
@@ -152,7 +154,7 @@ pub(crate) fn secret_key_to_public_key(
};
Ok(vec![
Subpacket::regular(SubpacketData::SignatureCreationTime(timestamp))?,
Subpacket::critical(SubpacketData::SignatureCreationTime(timestamp))?,
Subpacket::regular(SubpacketData::IssuerFingerprint(
signed_secret_key.fingerprint(),
))?,
@@ -477,9 +479,16 @@ async fn generate_keypair(context: &Context) -> Result<SignedSecretKey> {
None => {
let start = tools::Time::now();
info!(context, "Generating keypair.");
let keypair = Handle::current()
.spawn_blocking(move || crate::pgp::create_keypair(addr))
.await??;
let keypair = if context.get_config_bool(Config::Autocrypt2).await? {
let now = PgpTimestamp::now();
Handle::current()
.spawn_blocking(move || crate::pgp::autocrypt2::create_autocrypt2_keypair(now))
.await??
} else {
Handle::current()
.spawn_blocking(move || crate::pgp::create_keypair(addr))
.await??
};
store_self_keypair(context, &keypair).await?;
info!(
+1 -2
View File
@@ -15,8 +15,7 @@
clippy::explicit_iter_loop,
clippy::explicit_into_iter_loop,
clippy::cloned_instead_of_copied,
clippy::manual_is_variant_and,
clippy::unnecessary_wraps
clippy::manual_is_variant_and
)]
#![cfg_attr(not(test), warn(clippy::arithmetic_side_effects))]
#![cfg_attr(not(test), forbid(clippy::indexing_slicing))]
+5 -5
View File
@@ -580,7 +580,7 @@ impl Message {
if let Some(msg) = &mut msg {
msg.additional_text =
Self::get_additional_text(context, msg.download_state, &msg.param);
Self::get_additional_text(context, msg.download_state, &msg.param)?;
}
Ok(msg)
@@ -618,7 +618,7 @@ impl Message {
context: &Context,
download_state: DownloadState,
param: &Params,
) -> String {
) -> Result<String> {
if download_state != DownloadState::Done {
let file_size = param
.get(Param::PostMessageFileBytes)
@@ -635,14 +635,14 @@ impl Message {
.unwrap_or("?".to_owned());
return match viewtype {
Viewtype::File => format!(" [{file_name} – {file_size}]"),
Viewtype::File => Ok(format!(" [{file_name} – {file_size}]")),
_ => {
let translated_viewtype = viewtype.to_locale_string(context);
format!(" [{translated_viewtype} – {file_size}]")
Ok(format!(" [{translated_viewtype} – {file_size}]"))
}
};
}
String::new()
Ok(String::new())
}
/// Returns the MIME type of an attached file if it exists.
+3 -1
View File
@@ -784,7 +784,7 @@ async fn test_get_existing_msg_ids() -> Result<()> {
}
#[test]
fn test_can_fail() {
fn test_can_fail() -> Result<()> {
use MessageState::*;
// states that are not allowed to transition to OutFailed
@@ -799,4 +799,6 @@ fn test_can_fail() {
assert!(OutPending.can_fail());
assert!(OutDelivered.can_fail());
assert!(OutFailed.can_fail());
Ok(())
}
+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,
+2 -2
View File
@@ -794,7 +794,7 @@ async fn test_protected_headers_directive() -> Result<()> {
// Long messages are truncated and MimeMessage::decoded_data is set for them. We need
// decoded_data to check presence of the necessary headers.
msg.set_text("a".repeat(constants::DC_DESIRED_TEXT_LEN + 1));
msg.set_file_from_bytes(&bob, "foo.bar", b"content", None)?;
msg.set_file_from_bytes(&bob, "foo.bar", "content".as_bytes(), None)?;
let sent = bob.send_msg(chat, &mut msg).await;
assert!(msg.get_showpadlock());
assert!(sent.payload.contains("\r\nSubject: [...]\r\n"));
@@ -1126,7 +1126,7 @@ async fn test_render_unencrypted_msg_with_attachment() -> Result<()> {
.await;
let mut msg = Message::new(Viewtype::File);
msg.set_text("Hello!".to_string());
msg.set_file_from_bytes(alice, "foo.bar", b"content", None)?;
msg.set_file_from_bytes(alice, "foo.bar", "content".as_bytes(), None)?;
let sent = alice.send_msg(chat.id, &mut msg).await;
let unencrypted = normalized_payload(sent).await;
+6 -2
View File
@@ -294,7 +294,11 @@ impl MimeMessage {
&mut wants_mdn,
&mail,
);
headers_removed.extend(headers.extract_if(|k, _v| is_hidden(k)).map(|(k, _v)| k));
headers_removed.extend(
headers
.extract_if(|k, _v| is_hidden(k))
.map(|(k, _v)| k.to_string()),
);
// Parse hidden headers.
let mimetype = mail.ctype.mimetype.parse::<Mime>()?;
@@ -1747,7 +1751,7 @@ impl MimeMessage {
headers_removed.extend(
headers
.extract_if(|k, _v| has_header_protection || is_protected(k))
.map(|(k, _v)| k),
.map(|(k, _v)| k.to_string()),
);
if has_header_protection {
+1 -1
View File
@@ -46,7 +46,7 @@ use crate::mimeparser::SystemMessage;
/// The length of an ed25519 `PublicKey`, in bytes.
const PUBLIC_KEY_LENGTH: usize = 32;
const PUBLIC_KEY_STUB: &[u8] = b"static_string";
const PUBLIC_KEY_STUB: &[u8] = "static_string".as_bytes();
/// Store Iroh peer channels for the context.
#[derive(Debug)]
+10 -10
View File
@@ -86,14 +86,14 @@ async fn test_can_communicate() {
.get_or_try_init_peer_channel()
.await
.unwrap()
.send_webxdc_realtime_data(alice, alice_webxdc.id, b"alice -> bob".to_vec())
.send_webxdc_realtime_data(alice, alice_webxdc.id, "alice -> bob".as_bytes().to_vec())
.await
.unwrap();
loop {
let event = bob.evtracker.recv().await.unwrap();
if let EventType::WebxdcRealtimeData { data, .. } = event.typ {
if data == b"alice -> bob" {
if data == "alice -> bob".as_bytes() {
break;
} else {
panic!(
@@ -107,14 +107,14 @@ async fn test_can_communicate() {
bob.get_or_try_init_peer_channel()
.await
.unwrap()
.send_webxdc_realtime_data(bob, bob_webxdc.id, b"bob -> alice".to_vec())
.send_webxdc_realtime_data(bob, bob_webxdc.id, "bob -> alice".as_bytes().to_vec())
.await
.unwrap();
loop {
let event = alice.evtracker.recv().await.unwrap();
if let EventType::WebxdcRealtimeData { data, .. } = event.typ {
if data == b"bob -> alice" {
if data == "bob -> alice".as_bytes() {
break;
} else {
panic!(
@@ -149,14 +149,14 @@ async fn test_can_communicate() {
bob.get_or_try_init_peer_channel()
.await
.unwrap()
.send_webxdc_realtime_data(bob, bob_webxdc.id, b"bob -> alice 2".to_vec())
.send_webxdc_realtime_data(bob, bob_webxdc.id, "bob -> alice 2".as_bytes().to_vec())
.await
.unwrap();
loop {
let event = alice.evtracker.recv().await.unwrap();
if let EventType::WebxdcRealtimeData { data, .. } = event.typ {
if data == b"bob -> alice 2" {
if data == "bob -> alice 2".as_bytes() {
break;
} else {
panic!(
@@ -314,14 +314,14 @@ async fn test_can_reconnect() {
.get_or_try_init_peer_channel()
.await
.unwrap()
.send_webxdc_realtime_data(alice, alice_webxdc.id, b"alice -> bob".to_vec())
.send_webxdc_realtime_data(alice, alice_webxdc.id, "alice -> bob".as_bytes().to_vec())
.await
.unwrap();
loop {
let event = bob.evtracker.recv().await.unwrap();
if let EventType::WebxdcRealtimeData { data, .. } = event.typ {
if data == b"alice -> bob" {
if data == "alice -> bob".as_bytes() {
break;
} else {
panic!(
@@ -373,14 +373,14 @@ async fn test_can_reconnect() {
bob.get_or_try_init_peer_channel()
.await
.unwrap()
.send_webxdc_realtime_data(bob, bob_webxdc.id, b"bob -> alice".to_vec())
.send_webxdc_realtime_data(bob, bob_webxdc.id, "bob -> alice".as_bytes().to_vec())
.await
.unwrap();
loop {
let event = alice.evtracker.recv().await.unwrap();
if let EventType::WebxdcRealtimeData { data, .. } = event.typ {
if data == b"bob -> alice" {
if data == "bob -> alice".as_bytes() {
break;
} else {
panic!(
+211 -5
View File
@@ -1,7 +1,8 @@
//! OpenPGP helper module using [rPGP facilities](https://github.com/rpgp/rpgp).
use std::cmp::Ordering;
use std::collections::{HashMap, HashSet};
use std::collections::btree_map::Entry as BTreeMapEntry;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::io::Cursor;
use anyhow::{Context as _, Result, ensure};
@@ -15,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,
@@ -26,6 +27,8 @@ use sha2::Sha256;
use crate::configure::MAX_RELAYS;
use crate::key::{DcKey, Fingerprint};
pub(crate) mod autocrypt2;
/// Preferred symmetric encryption algorithm.
const SYMMETRIC_KEY_ALGORITHM: SymmetricKeyAlgorithm = SymmetricKeyAlgorithm::AES128;
@@ -361,6 +364,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
@@ -397,7 +555,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,
@@ -452,7 +610,7 @@ pub fn merge_openpgp_certificates(
// such as Alice's key in `test-data/key/alice-secret.asc`.
let best_user: Option<SignedUser> = old_users
.into_iter()
.chain(new_users)
.chain(new_users.clone())
.filter_map(|SignedUser { id, signatures }| {
// Select the best signature for each User ID.
// If User ID has no valid signatures, it is filtered out.
@@ -473,7 +631,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,
+588
View File
@@ -0,0 +1,588 @@
//! Autocrypt2 implementation.
use anyhow::Context as _;
use anyhow::Result;
use anyhow::bail;
use anyhow::ensure;
use anyhow::format_err;
use hkdf::Hkdf;
use pgp::composed::SignedKeyDetails;
use pgp::composed::SignedSecretKey;
use pgp::composed::SignedSecretSubKey;
use pgp::crypto::aead::AeadAlgorithm;
use pgp::crypto::ed25519;
use pgp::crypto::hash::HashAlgorithm;
use pgp::crypto::ml_kem768_x25519;
use pgp::crypto::public_key::PublicKeyAlgorithm;
use pgp::crypto::sym::SymmetricKeyAlgorithm;
use pgp::packet::Features;
use pgp::packet::KeyFlags;
use pgp::packet::PacketTrait as _;
use pgp::packet::PubKeyInner;
use pgp::packet::PublicKey;
use pgp::packet::PublicSubkey;
use pgp::packet::SecretKey;
use pgp::packet::SecretSubkey;
use pgp::packet::SignatureConfig;
use pgp::packet::SignatureType;
use pgp::packet::Subpacket;
use pgp::packet::SubpacketData;
use pgp::ser::Serialize as _;
use pgp::types::Duration as PgpDuration;
use pgp::types::Ed25519PublicParams;
use pgp::types::KeyDetails;
use pgp::types::KeyVersion;
use pgp::types::MlKem768X25519PublicParams;
use pgp::types::Password;
use pgp::types::PlainSecretParams;
use pgp::types::PublicParams;
use pgp::types::SecretParams;
use pgp::types::Timestamp;
use rand_old::thread_rng;
use sha2::Digest;
use sha2::Sha512;
/// Creates an Autocrypt 2 TSK.
///
/// <https://datatracker.ietf.org/doc/draft-autocrypt-openpgp-v2-cert/>
pub(crate) fn create_autocrypt2_keypair(now: Timestamp) -> Result<SignedSecretKey> {
let mut rng = thread_rng();
// Fake zero timestamp for primary key and fallback key creation.
// We do not want to leak the key creation date to contacts.
// This is not to be used for rotating subkey timestamps.
let zero_timestamp = Timestamp::from_secs(0);
let public_key_algorithm = PublicKeyAlgorithm::Ed25519;
let primary_key_packet = {
let ed25519_secret = ed25519::SecretKey::generate(&mut rng, ed25519::Mode::Ed25519);
let public_params = PublicParams::Ed25519(Ed25519PublicParams::from(&ed25519_secret));
let secret_params = SecretParams::Plain(PlainSecretParams::Ed25519(ed25519_secret));
let pubkey_inner = PubKeyInner::new(
KeyVersion::V6,
public_key_algorithm,
zero_timestamp,
None,
public_params,
)?;
let pubkey = PublicKey::from_inner(pubkey_inner)?;
SecretKey::new(pubkey, secret_params)?
};
let details = {
let mut signature_config =
SignatureConfig::from_key(&mut rng, &primary_key_packet, SignatureType::Key)?;
let mut keyflags = KeyFlags::default();
keyflags.set_certify(true);
keyflags.set_sign(true);
let mut features = Features::default();
features.set_seipd_v1(true);
features.set_seipd_v2(true);
signature_config.hashed_subpackets = vec![
Subpacket::critical(SubpacketData::SignatureCreationTime(now))?,
Subpacket::regular(SubpacketData::KeyFlags(keyflags))?,
Subpacket::regular(SubpacketData::Features(features))?,
Subpacket::regular(SubpacketData::IssuerFingerprint(
primary_key_packet.fingerprint(),
))?,
Subpacket::regular(SubpacketData::PreferredAeadAlgorithms(smallvec![(
SymmetricKeyAlgorithm::AES256,
AeadAlgorithm::Ocb
)]))?,
];
let signature = signature_config.sign_key(
&primary_key_packet,
&Password::empty(),
&primary_key_packet.public_key(),
)?;
SignedKeyDetails {
revocation_signatures: vec![],
direct_signatures: vec![signature],
users: vec![],
user_attributes: vec![],
}
};
let fallback_subkey_packet = {
let ml_kem_secret = ml_kem768_x25519::SecretKey::generate(&mut rng);
let public_params =
PublicParams::MlKem768X25519(MlKem768X25519PublicParams::from(&ml_kem_secret));
let secret_params = SecretParams::Plain(PlainSecretParams::MlKem768X25519(ml_kem_secret));
let pubkey_inner = PubKeyInner::new(
KeyVersion::V6,
PublicKeyAlgorithm::MlKem768X25519,
zero_timestamp,
None,
public_params,
)?;
let public_subkey = PublicSubkey::from_inner(pubkey_inner)?;
SecretSubkey::new(public_subkey, secret_params)?
};
let signed_fallback_subkey = {
let mut keyflags = KeyFlags::default();
keyflags.set_encrypt_storage(true);
keyflags.set_encrypt_comms(true);
let mut signature_config = SignatureConfig::v6(
&mut rng,
SignatureType::SubkeyBinding,
public_key_algorithm,
HashAlgorithm::Sha256,
)?;
signature_config.hashed_subpackets = vec![
Subpacket::critical(SubpacketData::SignatureCreationTime(zero_timestamp))?,
Subpacket::critical(SubpacketData::KeyFlags(keyflags))?,
Subpacket::regular(SubpacketData::IssuerFingerprint(
primary_key_packet.fingerprint(),
))?,
];
let signature = signature_config.sign_subkey_binding(
&primary_key_packet,
primary_key_packet.public_key(),
&Password::empty(),
fallback_subkey_packet.public_key(),
)?;
SignedSecretSubKey {
key: fallback_subkey_packet,
signatures: vec![signature],
}
};
let rotating_subkey_packet = {
let ml_kem_secret = ml_kem768_x25519::SecretKey::generate(&mut rng);
let public_params =
PublicParams::MlKem768X25519(MlKem768X25519PublicParams::from(&ml_kem_secret));
let secret_params = SecretParams::Plain(PlainSecretParams::MlKem768X25519(ml_kem_secret));
let mut keyflags = KeyFlags::default();
keyflags.set_encrypt_comms(true);
let pubkey_inner = PubKeyInner::new(
KeyVersion::V6,
PublicKeyAlgorithm::MlKem768X25519,
now,
None,
public_params,
)?;
let public_subkey = PublicSubkey::from_inner(pubkey_inner)?;
SecretSubkey::new(public_subkey, secret_params)?
};
let signed_rotating_subkey = {
let mut keyflags = KeyFlags::default();
keyflags.set_encrypt_comms(true);
let mut signature_config = SignatureConfig::v6(
&mut rng,
SignatureType::SubkeyBinding,
public_key_algorithm,
HashAlgorithm::Sha256,
)?;
// Expiration duration is 10 days according to
// <https://www.ietf.org/archive/id/draft-autocrypt-openpgp-v2-cert-02.html#section-2.2-2.6.2.2.1>
let expiration_duration = PgpDuration::from_secs(864000);
signature_config.hashed_subpackets = vec![
Subpacket::critical(SubpacketData::SignatureCreationTime(now))?,
Subpacket::critical(SubpacketData::KeyFlags(keyflags))?,
// XXX: marking expiration as critical
// even though reference implementation does not:
// <https://codeberg.org/autocrypt2/autocrypt-v2-cert/issues/53>
Subpacket::critical(SubpacketData::KeyExpirationTime(expiration_duration))?,
Subpacket::regular(SubpacketData::IssuerFingerprint(
primary_key_packet.fingerprint(),
))?,
];
let signature = signature_config.sign_subkey_binding(
&primary_key_packet,
primary_key_packet.public_key(),
&Password::empty(),
rotating_subkey_packet.public_key(),
)?;
SignedSecretSubKey {
key: rotating_subkey_packet,
signatures: vec![signature],
}
};
let secret_key = SignedSecretKey {
primary_key: primary_key_packet,
details,
public_subkeys: Vec::new(),
secret_subkeys: vec![signed_fallback_subkey, signed_rotating_subkey],
};
secret_key
.verify_bindings()
.context("Invalid Autocrypt2 key generated")?;
Ok(secret_key)
}
/// Returns true if TSK is an Autocrypt 2 TSK.
///
/// <https://www.ietf.org/archive/id/draft-autocrypt-openpgp-v2-cert-02.html#name-identification-by-tsk-struc>
fn is_autocrypt2_tsk(tsk: &SignedSecretKey) -> bool {
if tsk.primary_key.version() != KeyVersion::V6
|| tsk.primary_key.algorithm() != PublicKeyAlgorithm::Ed25519
{
return false;
}
// Direct key signature.
let [direct_key_signature] = &tsk.details.direct_signatures[..] else {
return false;
};
let Some(features) = direct_key_signature.features() else {
return false;
};
// SEIPDv2 feature is required according to
// <https://www.ietf.org/archive/id/draft-autocrypt-openpgp-v2-cert-02.html#section-2.2-2.2.2.4.1>
if !features.seipd_v2() {
return false;
}
// Primary key must have certification (0x01) and signing (0x02) flags.
let dks_key_flags = direct_key_signature.key_flags();
if !dks_key_flags.certify() || !dks_key_flags.sign() {
return false;
}
// No expiration:
// <https://www.ietf.org/archive/id/draft-autocrypt-openpgp-v2-cert-02.html#section-2.2-2.2.2.6.1>
// No key expiration (<https://www.rfc-editor.org/rfc/rfc9580.html#name-key-expiration-time>)
// and no signature expiration (<https://docs.rs/pgp/latest/pgp/packet/struct.Signature.html#method.signature_expiration_time>).
//
// XXX: spec should say explicitly that both key expiration and signature expiration should not be there
if direct_key_signature
.key_expiration_time()
.is_some_and(|duration| duration.as_secs() != 0)
|| direct_key_signature
.signature_expiration_time()
.is_some_and(|duration| duration.as_secs() != 0)
{
return false;
}
if !(tsk.details.revocation_signatures.is_empty()
&& tsk.details.users.is_empty()
&& tsk.details.user_attributes.is_empty())
{
return false;
}
if !tsk.public_subkeys.is_empty() {
return false;
}
// TODO: check all rotating subkeys
// Subkeys may overlap, as long as subkey is not expired, it does not need to be deleted.
let [ref fallback_subkey, .., ref rotating_subkey] = tsk.secret_subkeys[..] else {
return false;
};
let [ref fallback_subkey_signature] = fallback_subkey.signatures[..] else {
return false;
};
let fallback_subkey_flags = fallback_subkey_signature.key_flags();
if !fallback_subkey_flags.encrypt_comms() || !fallback_subkey_flags.encrypt_storage() {
return false;
}
if fallback_subkey_signature
.key_expiration_time()
.is_some_and(|duration| duration.as_secs() != 0)
{
return false;
}
let [ref rotating_subkey_signature] = rotating_subkey.signatures[..] else {
return false;
};
let rotating_subkey_flags = rotating_subkey_signature.key_flags();
// Rotating subkey can be used to encrypt communications, but not storage:
// <https://www.ietf.org/archive/id/draft-autocrypt-openpgp-v2-cert-02.html#section-2.2-2.6.2.3.1>
if !rotating_subkey_flags.encrypt_comms() || rotating_subkey_flags.encrypt_storage() {
return false;
}
if rotating_subkey_signature
.key_expiration_time()
.is_none_or(|duration| duration.as_secs() == 0)
{
return false;
}
true
}
fn normalize_x25519_scalar(m: &mut [u8]) {
// From decodeScalar25519 in <https://www.rfc-editor.org/info/rfc7748/#section-5>
m[0] &= 248;
m[31] &= 127;
m[31] |= 64;
}
/// Generates new rotating subkey from a previous one.
///
/// <https://www.ietf.org/archive/id/draft-autocrypt-openpgp-v2-cert-02.html#section-4.1.1>
fn ratchet(mut tsk: SignedSecretKey) -> Result<SignedSecretKey> {
// Extract the last rotating subkey.
// Other rotating subkeys do not matter.
// This corresponds to
// <https://www.ietf.org/archive/id/draft-autocrypt-openpgp-v2-cert-02.html#section-4.1.1-6.2.1>
let [ref _fallback_subkey, .., ref rotating_subkey] = tsk.secret_subkeys[..] else {
bail!("Cannot extract last rotating subkey");
};
let [ref rotating_subkey_signature] = rotating_subkey.signatures[..] else {
bail!("Rotating subkey must have exactly one signature");
};
let rotating_subkey_flags = rotating_subkey_signature.key_flags();
// We do not search for the latest-expiring subkey
// with the ability to encrypt communications.
// It must be the last one by convention.
// TODO: write TSK structure explicitly in the specification.
let max_rd: u32 = rotating_subkey_signature
.key_expiration_time()
.context("Last subkey is not expiring")?
.as_secs();
let min_rd: u32 = max_rd / 2;
ensure!(
rotating_subkey_flags.encrypt_comms(),
"Last rotating subkey cannot be used to encrypt communications"
);
let start: u32 = rotating_subkey
.created_at()
.as_secs()
.checked_add(min_rd)
.context("Overflow while adding min_rd")?;
let mut salt = Vec::from(start.to_be_bytes());
rotating_subkey
.public_key()
.to_writer_with_header(&mut salt)
.context("Failed to serialize rotating subkey")?;
debug_assert_eq!(
salt.len(),
4 + rotating_subkey.public_key().write_len_with_header()
);
let SecretParams::Plain(PlainSecretParams::MlKem768X25519(old_ml_kem768_x25519_secret_key)) =
rotating_subkey.secret_params()
else {
bail!("Cannot extract ML-KEM-768 + X25519 secret key");
};
let mut ikm = Vec::with_capacity(old_ml_kem768_x25519_secret_key.write_len());
old_ml_kem768_x25519_secret_key
.to_writer(&mut ikm)
.context("Failed to serialize IKM")?;
// <https://www.ietf.org/archive/id/draft-autocrypt-openpgp-v2-cert-02.html#section-4.1.1-6.6.1>
normalize_x25519_scalar(&mut ikm);
debug_assert_eq!(ikm.len(), 96);
let info = {
let mut info = b"Autocrypt_v2_ratchet".to_vec();
tsk.primary_key
.public_key()
.to_writer_with_header(&mut info)
.context("Failed to serialize primary key")?;
info.extend_from_slice(&max_rd.to_be_bytes());
info
};
let hkdf = Hkdf::<Sha512>::new(Some(&salt), &ikm);
let mut ks = [0u8; 160];
hkdf.expand(&info, &mut ks)
.map_err(|_err: hkdf::InvalidLength| {
format_err!("HKDF-Expand failed because of invalid output length")
})?;
let new_ml_kem768_x25519_secret_key = {
let mut new_x25519 = [0u8; 32];
let mut new_ml_kem = [0u8; 64];
new_x25519.copy_from_slice(&ks[64..96]);
new_ml_kem.copy_from_slice(&ks[96..160]);
normalize_x25519_scalar(&mut new_x25519[..]);
ml_kem768_x25519::SecretKey::try_from_bytes(new_x25519, new_ml_kem)?
};
let new_rotating_subkey = {
let public_params = PublicParams::MlKem768X25519(MlKem768X25519PublicParams::from(
&new_ml_kem768_x25519_secret_key,
));
let secret_params = SecretParams::Plain(PlainSecretParams::MlKem768X25519(
new_ml_kem768_x25519_secret_key,
));
let pubkey_inner = PubKeyInner::new(
KeyVersion::V6,
PublicKeyAlgorithm::MlKem768X25519,
Timestamp::from_secs(start),
None,
public_params,
)?;
let public_subkey = PublicSubkey::from_inner(pubkey_inner)?;
SecretSubkey::new(public_subkey, secret_params)?
};
let new_signed_rotating_subkey = {
let mut keyflags = KeyFlags::default();
keyflags.set_encrypt_comms(true);
let digest = Sha512::digest(&ks[0..64]);
let bssalt = digest[0..16].to_vec();
let mut signature_config = SignatureConfig::v6_with_salt(
SignatureType::SubkeyBinding,
tsk.primary_key.algorithm(),
HashAlgorithm::Sha256,
bssalt,
);
// FIXME
let expiration_duration = PgpDuration::from_secs(864000);
signature_config.hashed_subpackets = vec![
Subpacket::critical(SubpacketData::SignatureCreationTime(Timestamp::from_secs(
start,
)))?,
Subpacket::critical(SubpacketData::KeyFlags(keyflags))?,
// XXX: marking expiration as critical
// even though reference implementation does not:
// <https://codeberg.org/autocrypt2/autocrypt-v2-cert/issues/53>
Subpacket::critical(SubpacketData::KeyExpirationTime(expiration_duration))?,
Subpacket::regular(SubpacketData::IssuerFingerprint(
tsk.primary_key.public_key().fingerprint(),
))?,
];
let signature = signature_config.sign_subkey_binding(
&tsk.primary_key,
tsk.primary_key.public_key(),
&Password::empty(),
new_rotating_subkey.public_key(),
)?;
SignedSecretSubKey {
key: new_rotating_subkey,
signatures: vec![signature],
}
};
tsk.secret_subkeys.push(new_signed_rotating_subkey);
Ok(tsk)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::key;
use crate::pgp::DcKey;
use crate::test_utils;
/// Tests creating Autocrypt 2 TSK and detecting it.
#[test]
fn test_create_autocrypt2_keypair() {
let now = Timestamp::now();
let keypair = create_autocrypt2_keypair(now).unwrap();
assert!(is_autocrypt2_tsk(&keypair));
// Test that Autocrypt 2 TSK can be serialized and deserialized.
let secret_key_bytes = DcKey::to_bytes(&keypair);
let signed_secret_key = SignedSecretKey::from_slice(&secret_key_bytes)
.expect("Cannot deserialize Autocrypt2 TSK");
assert!(is_autocrypt2_tsk(&signed_secret_key));
}
/// Tests that the key does not leak creation timestamp.
#[test]
fn test_tsk_timestamps() {
let now = Timestamp::now();
let tsk = create_autocrypt2_keypair(now).unwrap();
// Primary key creation timestamp is zero.
assert_eq!(tsk.primary_key.created_at().as_secs(), 0);
// Primary key direct key signature timestamp is zero.
let [ref direct_signature] = tsk.details.direct_signatures[..] else {
panic!("Autocrypt 2 TSK must have exactly one direct key signature");
};
// Direct key signature is a real key creation timestamp
// and should not be zero.
// <https://www.ietf.org/archive/id/draft-autocrypt-openpgp-v2-cert-02.html#section-2.2-2.2.2.1.1>
// This timestamp from TSK should not leak into the public key however
// as we recreate the signature every time relay list is changed:
let created_timestamp = direct_signature.created().unwrap();
assert_ne!(created_timestamp.as_secs(), 0);
let fallback_subkey = tsk
.secret_subkeys
.first()
.expect("Fallback subkey not found");
// Fallback subkey creation timestamp should be zero.
// We will not be able to change this timestamp and it should not leak
// the profile creation timestamp.
assert_eq!(fallback_subkey.key.created_at().as_secs(), 0);
// Fallback subkey binding signature timestamp must match
// the direct key signature timestamp.
// TODO: it should be recreated each time Direct Key Signature is recreated.
let [ref fallback_subkey_signature] = fallback_subkey.signatures[..] else {
panic!("Fallback subkey does not have exactly one binding signature");
};
}
/// Tests that Autocrypt 2 TSK detection is not triggered for existing non-AC2 test keys.
#[test]
fn test_is_autocrypt2_tsk_no_false_positives() {
assert!(!is_autocrypt2_tsk(&test_utils::alice_keypair()));
assert!(!is_autocrypt2_tsk(&test_utils::bob_keypair()));
assert!(!is_autocrypt2_tsk(&test_utils::charlie_keypair()));
assert!(!is_autocrypt2_tsk(&test_utils::dom_keypair()));
assert!(!is_autocrypt2_tsk(&test_utils::elena_keypair()));
assert!(!is_autocrypt2_tsk(&test_utils::pqc_keypair()));
}
#[test]
fn test_ratchet() {
let now = Timestamp::now();
let tsk = create_autocrypt2_keypair(now).unwrap();
assert!(is_autocrypt2_tsk(&tsk));
let new_tsk = ratchet(tsk).expect("Ratchet failed");
assert!(is_autocrypt2_tsk(&new_tsk));
}
#[test]
fn test_autocrypt2_key_selection() {
let now = Timestamp::now();
let tsk = create_autocrypt2_keypair(now).unwrap();
let public_key = key::secret_key_to_public_key(
tsk.clone(),
now.as_secs(),
"alice@example.org",
"alice@example.org",
)
.expect("Failed to convert secret key to public key");
// For Autocrypt 2 certificate rotating key should be selected for encryption.
let pk_for_encryption =
crate::pgp::select_pk_for_encryption(now.as_secs(), &public_key).unwrap();
let [ref pk_for_encryption_signature] = pk_for_encryption.signatures[..] else {
panic!("Selected public key has multiple signatures");
};
let key_flags = pk_for_encryption_signature.key_flags();
assert!(key_flags.encrypt_comms());
assert!(!key_flags.encrypt_storage());
}
}
+1 -83
View File
@@ -384,7 +384,7 @@ fn test_merge_openpgp_certificates() {
// Cannot merge certificates with different primary key.
assert!(merge_openpgp_certificates(alice.clone(), bob.clone()).is_err());
assert!(merge_openpgp_certificates(bob, alice).is_err());
assert!(merge_openpgp_certificates(bob.clone(), alice.clone()).is_err());
}
/// Test PQC support.
@@ -423,85 +423,3 @@ async fn test_securejoin_pqc_joiner() {
tcm.execute_securejoin(bob, pqc).await;
}
/// Tests that public subkey selection for encryption follows Autocrypt 2 rules.
///
/// If there is an expiring subkey, it is preferred, otherwise non-expiring subkey is selected.
/// Non-encryption subkeys such as RSA subkey for authentication are ignored.
#[test]
fn test_select_pk_for_encryption() {
// Public key generated with GnuPG 2.4.9 with the following subkeys:
// 1. Auth-only RSA subkey (92E762B9084CA740).
// 2. Expired Curve25519 encryption subkey with 1-day expiration (C8F382BD0F35C49E)
// 3. Ed25519 signing subkey (F177AC3118F923CC).
// 4. Curve25519 encryption subkey with fingerprint (36188C6FFC8E267B)
// 5. Curve25519 encryption subkey with 1 year expiration, valid in the beginning of 2008, with key ID 9223FCEE7546CDE7
// 6. Curve25519 encryption subkey with no expiration (FD2C0567967223D8).
// Primary key is an Ed25519 not expiring key.
// Key 4 is the one that should be selected.
// Subkey 4 fingerprint.
let expected_fallback_fingerprint = "cdeb3ba3999bf7880f0ee1f536188c6ffc8e267b";
// Subkey 5 fingerprint, should be preferred to fallback when not expired.
let expected_expiring_fingerprint = "5133fab157c4a46ca41f6dc39223fcee7546cde7";
let alice_tpk_asc = "
-----BEGIN PGP PUBLIC KEY BLOCK-----
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
=AXAi
-----END PGP PUBLIC KEY BLOCK-----
";
let alice_tpk = SignedPublicKey::from_asc(alice_tpk_asc).unwrap();
let now = pgp::types::Timestamp::now();
let encryption_subkey = select_pk_for_encryption(now.as_secs(), &alice_tpk).unwrap();
assert_eq!(
encryption_subkey.fingerprint().to_string().as_str(),
expected_fallback_fingerprint
);
// In the beginning of 2008 expiring subkey is not expired yet and should be used.
let encryption_subkey = select_pk_for_encryption(1199149200, &alice_tpk).unwrap();
assert_eq!(
encryption_subkey.fingerprint().to_string().as_str(),
expected_expiring_fingerprint
);
}
+2 -2
View File
@@ -764,7 +764,7 @@ fn decode_tg_socks_proxy(_context: &Context, qr: &str) -> Result<Qr> {
fn decode_shadowsocks_proxy(qr: &str) -> Result<Qr> {
let server_config = shadowsocks::config::ServerConfig::from_url(qr)?;
let addr = server_config.addr();
let host = addr.host();
let host = addr.host().to_string();
let port = addr.port();
Ok(Qr::Proxy {
url: qr.to_string(),
@@ -1124,7 +1124,7 @@ fn normalize_address(addr: &str) -> Result<String> {
ensure!(may_be_valid_addr(&new_addr), "Bad e-mail address");
Ok(new_addr)
Ok(new_addr.to_string())
}
#[cfg(test)]
+1 -1
View File
@@ -120,7 +120,7 @@ pub(super) fn decode_login(qr: &str) -> Result<Qr> {
};
Ok(Qr::Login {
address: addr,
address: addr.to_owned(),
options,
})
} else {
+1 -1
View File
@@ -165,7 +165,7 @@ async fn set_msg_id_reaction(
.await?;
if chat
.param
.update_timestamp(Param::LastReactionTimestamp, timestamp)
.update_timestamp(Param::LastReactionTimestamp, timestamp)?
{
chat.param
.set_i64(Param::LastReactionMsgId, i64::from(msg_id.to_u32()));
+2 -2
View File
@@ -2311,7 +2311,7 @@ INSERT INTO msgs
// This way, `LastSubject` actually refers to the most recent message _shown_ in the chat.
if chat
.param
.update_timestamp(Param::SubjectTimestamp, sort_timestamp)
.update_timestamp(Param::SubjectTimestamp, sort_timestamp)?
{
// write the last subject even if empty -
// otherwise a reply may get an outdated subject.
@@ -3414,7 +3414,7 @@ async fn apply_chat_name_avatar_and_description_changes(
&& is_from_in_chat
&& chat
.param
.update_timestamp(Param::AvatarTimestamp, mime_parser.timestamp_sent)
.update_timestamp(Param::AvatarTimestamp, mime_parser.timestamp_sent)?
{
info!(context, "Group-avatar change for {}.", chat.id);
match avatar_action {
+15 -14
View File
@@ -33,14 +33,14 @@ pub(crate) use qrinvite::QrInvite;
use crate::token::Namespace;
const DISALLOWED_CHARACTERS: &AsciiSet = &NON_ALPHANUMERIC_WITHOUT_DOT.remove(b'_').remove(b'@');
const DISALLOWED_CHARACTERS: &AsciiSet = &NON_ALPHANUMERIC_WITHOUT_DOT.remove(b'_');
fn inviter_progress(
context: &Context,
contact_id: ContactId,
chat_id: ChatId,
chat_type: Chattype,
) {
) -> Result<()> {
// No other values are used.
let progress = 1000;
context.emit_event(EventType::SecurejoinInviterProgress {
@@ -49,6 +49,8 @@ fn inviter_progress(
chat_type,
progress,
});
Ok(())
}
/// Shorten name to max. `length` characters.
@@ -122,19 +124,18 @@ pub async fn get_securejoin_qr(context: &Context, chat: Option<ChatId>) -> Resul
let self_addr = context.get_primary_self_addr().await?;
let self_addr_urlencoded = utf8_percent_encode(&self_addr, DISALLOWED_CHARACTERS).to_string();
let encoded_extra_relays: Vec<String> = context
let r_param = context
.get_self_addrs()
.await?
.into_iter()
.filter(|addr| *addr != self_addr)
.map(|addr| utf8_percent_encode(&addr, DISALLOWED_CHARACTERS).to_string())
.collect();
let r_param = if encoded_extra_relays.is_empty() {
"".to_string()
} else {
format!("&r={}", encoded_extra_relays.join(","))
};
.reduce(|acc, addr| {
format!(
"{acc},{}",
utf8_percent_encode(&addr, DISALLOWED_CHARACTERS)
)
})
.map_or(String::default(), |addrs| format!("&r={addrs}"));
let self_name = context
.get_config(Config::Displayname)
@@ -658,7 +659,7 @@ pub(crate) async fn handle_securejoin_handshake(
context.emit_event(EventType::ContactsChanged(Some(contact_id)));
}
inviter_progress(context, contact_id, joining_chat_id, chat.typ);
inviter_progress(context, contact_id, joining_chat_id, chat.typ)?;
// IMAP-delete the message to avoid handling it by another device and adding the
// member twice. Another device will know the member's key from Autocrypt-Gossip.
Ok(HandshakeMessage::Done)
@@ -669,7 +670,7 @@ pub(crate) async fn handle_securejoin_handshake(
.await
.context("failed sending vc-contact-confirm message")?;
inviter_progress(context, contact_id, chat_id, Chattype::Single);
inviter_progress(context, contact_id, chat_id, Chattype::Single)?;
Ok(HandshakeMessage::Ignore) // "Done" would delete the message and break multi-device (the key from Autocrypt-header is needed)
}
}
@@ -816,7 +817,7 @@ pub(crate) async fn observe_securejoin_on_other_device(
// and tests which don't care about the chat ID,
// so we pass invalid chat ID here.
let chat_id = ChatId::new(0);
inviter_progress(context, contact_id, chat_id, chat_type);
inviter_progress(context, contact_id, chat_id, chat_type)?;
}
if matches!(step, SecureJoinStep::MemberAdded) {
-38
View File
@@ -13,7 +13,6 @@ use crate::test_utils::{
AVATAR_64x64_BYTES, AVATAR_64x64_DEDUPLICATED, TestContext, TestContextManager,
TimeShiftFalsePositiveNote, get_chat_msg, sync,
};
use crate::transport::add_pseudo_transport;
#[derive(PartialEq)]
enum SetupContactCase {
@@ -1143,43 +1142,6 @@ async fn test_get_securejoin_qr_name_is_last() -> Result<()> {
Ok(())
}
/// Test that addresses in QR codes are percent-encoded.
/// `@` should not be encoded unnecessarily,
/// since this would just make the QR code longer.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_securejoin_qr_encoding() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
// `@` in email addresses must not be percent-encoded:
add_pseudo_transport(alice, "asdf@example.org").await?;
// But `%` does need percent-encoding:
add_pseudo_transport(alice, "jk%l@example.net").await?;
let qr = get_securejoin_qr(alice, None).await?;
assert!(
qr.contains("a=alice@example.org"),
"{qr} doesn't contain 'a=alice@example.org'"
);
assert!(
qr.contains("r=jk%25l@example.net,asdf@example.org"),
"{qr} doesn't contain 'r=jk%25l@example.net,asdf@example.org'"
);
let qr = check_qr(bob, &qr).await?;
let Qr::AskVerifyContact { mut addrs, .. } = qr else {
unreachable!()
};
addrs.sort();
assert_eq!(
addrs,
vec!["alice@example.org", "asdf@example.org", "jk%l@example.net",]
);
Ok(())
}
/// QR codes should not get arbitrary big because of long names.
/// The truncation, however, should not let the url end with a `.`, which is a call for trouble in linkfiers.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+12 -4
View File
@@ -32,6 +32,8 @@ fn migrate_key_contacts(
context: &Context,
transaction: &mut rusqlite::Transaction<'_>,
) -> std::result::Result<(), anyhow::Error> {
info!(context, "Starting key-contact transition.");
// =============================== Step 1: ===============================
// Alter tables
transaction.execute_batch(
@@ -77,12 +79,13 @@ fn migrate_key_contacts(
.optional()
.context("Step 0")?
else {
// Not yet configured, no need to migrate key-contacts.
info!(
context,
"Not yet configured, no need to migrate key-contacts"
);
return Ok(());
};
info!(context, "Starting key-contact transition.");
// =============================== Step 2: ===============================
// Create up to 3 new contacts for every contact that has a peerstate:
// one from the Autocrypt key fingerprint, one from the verified key fingerprint,
@@ -1933,9 +1936,14 @@ CREATE INDEX gossip_timestamp_index ON gossip_timestamp (chat_id, fingerprint);
inc_and_check(&mut migration_version, 132)?;
if dbversion < migration_version {
let start = Time::now();
sql.execute_migration_transaction(|t| migrate_key_contacts(context, t), migration_version)
.await?;
info!(
context,
"key-contacts migration took {:?} in total.",
time_elapsed(&start),
);
// Schedule `msgs_to_key_contacts()`.
context
.set_config_internal(Config::LastHousekeeping, None)
+13 -13
View File
@@ -27,7 +27,7 @@ impl Context {
Ok(param.parse().unwrap_or_default())
},
)?;
let update = param.update_timestamp(scope, new_timestamp);
let update = param.update_timestamp(scope, new_timestamp)?;
if update {
transaction.execute(
"UPDATE contacts SET param=? WHERE id=?",
@@ -57,7 +57,7 @@ impl ChatId {
let param: String = row.get(0)?;
Ok(param.parse().unwrap_or_default())
})?;
let update = param.update_timestamp(scope, new_timestamp);
let update = param.update_timestamp(scope, new_timestamp)?;
if update {
transaction.execute(
"UPDATE chats SET param=? WHERE id=?",
@@ -73,13 +73,13 @@ impl ChatId {
impl Params {
/// Updates a param's timestamp in memory, if reasonable.
/// Returns true if the caller shall update the settings belonging to the scope.
pub(crate) fn update_timestamp(&mut self, scope: Param, new_timestamp: i64) -> bool {
pub(crate) fn update_timestamp(&mut self, scope: Param, new_timestamp: i64) -> Result<bool> {
let old_timestamp = self.get_i64(scope).unwrap_or_default();
if new_timestamp >= old_timestamp {
self.set_i64(scope, new_timestamp);
return true;
return Ok(true);
}
false
Ok(false)
}
}
@@ -96,18 +96,18 @@ mod tests {
let mut params = Params::new();
let ts = time();
assert!(params.update_timestamp(Param::LastSubject, ts));
assert!(params.update_timestamp(Param::LastSubject, ts)); // same timestamp -> update
assert!(params.update_timestamp(Param::LastSubject, ts + 10));
assert!(!params.update_timestamp(Param::LastSubject, ts)); // `ts` is now too old
assert!(!params.update_timestamp(Param::LastSubject, 0));
assert!(params.update_timestamp(Param::LastSubject, ts)?);
assert!(params.update_timestamp(Param::LastSubject, ts)?); // same timestamp -> update
assert!(params.update_timestamp(Param::LastSubject, ts + 10)?);
assert!(!params.update_timestamp(Param::LastSubject, ts)?); // `ts` is now too old
assert!(!params.update_timestamp(Param::LastSubject, 0)?);
assert_eq!(params.get_i64(Param::LastSubject).unwrap(), ts + 10);
assert!(params.update_timestamp(Param::GroupNameTimestamp, 0)); // stay unset -> update ...
assert!(params.update_timestamp(Param::GroupNameTimestamp, 0)); // ... also on multiple calls
assert!(params.update_timestamp(Param::GroupNameTimestamp, 0)?); // stay unset -> update ...
assert!(params.update_timestamp(Param::GroupNameTimestamp, 0)?); // ... also on multiple calls
assert_eq!(params.get_i64(Param::GroupNameTimestamp).unwrap(), 0);
assert!(!params.update_timestamp(Param::AvatarTimestamp, -1));
assert!(!params.update_timestamp(Param::AvatarTimestamp, -1)?);
assert_eq!(params.get_i64(Param::AvatarTimestamp), None);
Ok(())
+3 -3
View File
@@ -350,7 +350,7 @@ impl Context {
if let Some(ref document) = status_update_item.document
&& instance
.param
.update_timestamp(Param::WebxdcDocumentTimestamp, timestamp)
.update_timestamp(Param::WebxdcDocumentTimestamp, timestamp)?
{
instance.param.set(Param::WebxdcDocument, document);
param_changed = true;
@@ -359,10 +359,10 @@ impl Context {
if let Some(ref summary) = status_update_item.summary
&& instance
.param
.update_timestamp(Param::WebxdcSummaryTimestamp, timestamp)
.update_timestamp(Param::WebxdcSummaryTimestamp, timestamp)?
{
let summary = sanitize_bidi_characters(summary);
instance.param.set(Param::WebxdcSummary, summary);
instance.param.set(Param::WebxdcSummary, summary.clone());
param_changed = true;
}
+14 -12
View File
@@ -1201,28 +1201,30 @@ async fn test_get_webxdc_blob_with_subdirs() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_parse_webxdc_manifest() -> Result<()> {
let result = parse_webxdc_manifest(br#"key = syntax error"#);
let result = parse_webxdc_manifest(r#"key = syntax error"#.as_bytes());
assert!(result.is_err());
let manifest = parse_webxdc_manifest(br#"no_name = "no name, no icon""#)?;
let manifest = parse_webxdc_manifest(r#"no_name = "no name, no icon""#.as_bytes())?;
assert_eq!(manifest.name, None);
let manifest = parse_webxdc_manifest(br#"name = "name, no icon""#)?;
let manifest = parse_webxdc_manifest(r#"name = "name, no icon""#.as_bytes())?;
assert_eq!(manifest.name, Some("name, no icon".to_string()));
let manifest = parse_webxdc_manifest(
br#"name = "foo"
icon = "bar""#,
r#"name = "foo"
icon = "bar""#
.as_bytes(),
)?;
assert_eq!(manifest.name, Some("foo".to_string()));
let manifest = parse_webxdc_manifest(
br#"name = "foz"
r#"name = "foz"
icon = "baz"
add_item = "that should be just ignored"
[section]
sth_for_the = "future""#,
sth_for_the = "future""#
.as_bytes(),
)?;
assert_eq!(manifest.name, Some("foz".to_string()));
Ok(())
@@ -1230,13 +1232,13 @@ sth_for_the = "future""#,
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_parse_webxdc_manifest_min_api() -> Result<()> {
let manifest = parse_webxdc_manifest(br#"min_api = 3"#)?;
let manifest = parse_webxdc_manifest(r#"min_api = 3"#.as_bytes())?;
assert_eq!(manifest.min_api, Some(3));
let result = parse_webxdc_manifest(br#"min_api = "1""#);
let result = parse_webxdc_manifest(r#"min_api = "1""#.as_bytes());
assert!(result.is_err());
let result = parse_webxdc_manifest(br#"min_api = 1.2"#);
let result = parse_webxdc_manifest(r#"min_api = 1.2"#.as_bytes());
assert!(result.is_err());
Ok(())
@@ -1244,10 +1246,10 @@ async fn test_parse_webxdc_manifest_min_api() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_parse_webxdc_manifest_source_code_url() -> Result<()> {
let result = parse_webxdc_manifest(br#"source_code_url = 3"#);
let result = parse_webxdc_manifest(r#"source_code_url = 3"#.as_bytes());
assert!(result.is_err());
let manifest = parse_webxdc_manifest(br#"source_code_url = "https://foo.bar""#)?;
let manifest = parse_webxdc_manifest(r#"source_code_url = "https://foo.bar""#.as_bytes())?;
assert_eq!(
manifest.source_code_url,
Some("https://foo.bar".to_string())