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
6 changes: 5 additions & 1 deletion app/desktop/src/pages/requests/MessageRequests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,14 @@ import type {
MessageRequestItem,
} from "@/lib/types";

// The core returns a preview only after checking that the Welcome's signing
// key is bound by the root key whose fingerprint is `author_user_id`. A
// request without one names no sender.
interface WelcomePreview {
conversation_id: string;
author_user_id: string;
author_device_id: string;
author_user_public_key: string;
identity_bundle_ref?: string;
}

Expand Down Expand Up @@ -232,7 +236,7 @@ export default function MessageRequests() {
</div>
<div>
<span className="text-primary-color">
{author ? `From ${author}` : "Unknown"}
{author ? `From ${author}` : "Unverified sender"}
</span>
{author && (
<span className="text-muted-color text-xs block truncate">
Expand Down
19 changes: 17 additions & 2 deletions src/contact_workflows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,14 @@ pub fn ensure_welcome_signed_by_bundle(
"fetched identity bundle user_id does not match the Welcome author",
));
}
// The root key the Welcome carried, which the preview already checked
// against the user id and the signing key, must be the bundle's own.
let carried_root = parse_verifying_key(&preview.author_user_public_key)?;
if parse_verifying_key(&bundle.user_public_key)?.as_bytes() != carried_root.as_bytes() {
return Err(CoreError::invalid_input(
"fetched identity bundle root key does not match the root key the Welcome carried",
));
}
let device = bundle
.devices
.iter()
Expand Down Expand Up @@ -190,7 +198,7 @@ mod tests {
use crate::contact_share::encode_contact_share_url;
use crate::ffi_api::WelcomePreview;
use crate::identity::{IdentityManager, LocalIdentityState};
use crate::mls_adapter::{MlsAdapter, PeerDeviceKeyPackage};
use crate::mls_adapter::{CreatorRootProof, MlsAdapter, PeerDeviceKeyPackage};
use crate::model::IdentityBundle;

const NAMED_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
Expand Down Expand Up @@ -324,14 +332,17 @@ mod tests {
let (receiver_adapter, receiver_package) =
MlsAdapter::bootstrap(&receiver).expect("receiver adapter");
let artifacts = author_adapter
.create_conversation(
.create_conversation_with_reply_lane(
&crate::model::random_opaque_id(),
&[PeerDeviceKeyPackage {
user_id: receiver.user_identity.user_id.clone(),
device_id: receiver.device_identity.device_id.clone(),
device_public_key: receiver.device_identity.device_public_key.clone(),
key_package_b64: receiver_package.key_package_b64,
}],
Some(&crate::model::random_opaque_id()),
None,
Some(&CreatorRootProof::of(author)),
)
.expect("welcome");
let inspection = receiver_adapter
Expand All @@ -342,6 +353,9 @@ mod tests {
author_user_id: inspection.author_user_id,
author_device_id: inspection.author_device_id,
author_device_public_key: inspection.author_signature_key,
author_user_public_key: inspection
.author_user_public_key
.expect("a first-contact Welcome carries a verified root key"),
identity_bundle_ref: inspection.identity_bundle_ref,
}
}
Expand All @@ -362,6 +376,7 @@ mod tests {
let mut preview = preview_of_welcome_from(&identity(IMPOSTOR_MNEMONIC));
preview.author_user_id = named.user_identity.user_id.clone();
preview.author_device_id = named.device_identity.device_id.clone();
preview.author_user_public_key = named.user_identity.user_public_key.clone();
let error = ensure_welcome_signed_by_bundle(&preview, &signed_bundle_of(&named))
.expect_err("a Welcome the named device did not sign must be refused");
assert!(
Expand Down
1 change: 1 addition & 0 deletions src/ffi_api/engine/direct.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1208,6 +1208,7 @@ impl CoreEngine {
.local_bundle
.as_ref()
.and_then(|bundle| bundle.identity_bundle_ref.as_deref()),
Some(&crate::mls_adapter::CreatorRootProof::of(&local_identity)),
)?;
let summary = self
.state
Expand Down
6 changes: 6 additions & 0 deletions src/ffi_api/engine/lanes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -836,6 +836,11 @@ impl CoreEngine {
let inspection = adapter
.inspect_welcome(&welcome_bytes)
.ok_or_else(|| CoreError::invalid_input("welcome could not be inspected"))?;
// A request names its sender only if the Welcome carried the
// creator's root key and that key bound the key that signed it.
let author_user_public_key = inspection
.author_user_public_key
.ok_or_else(|| CoreError::invalid_input("welcome does not prove who made it"))?;
Ok(CoreOutput {
state_update: CoreStateUpdate::default(),
effects: Vec::new(),
Expand All @@ -845,6 +850,7 @@ impl CoreEngine {
author_user_id: inspection.author_user_id,
author_device_id: inspection.author_device_id,
author_device_public_key: inspection.author_signature_key,
author_user_public_key,
identity_bundle_ref: inspection.identity_bundle_ref,
}),
..CoreViewModel::default()
Expand Down
64 changes: 64 additions & 0 deletions src/ffi_api/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3962,6 +3962,59 @@ pub(crate) mod tests {
);
}

#[test]
fn preview_welcome_names_no_sender_whose_root_key_did_not_bind_the_signer() {
// Carol signs a first-contact Welcome whose credential names Alice and
// which carries Alice's public root key and device binding.
let mut chat = unjoined_direct_chat();
let alice = chat
.alice
.state
.local_identity
.clone()
.expect("alice identity");
let attacker = local_engine(CAROL_MNEMONIC, "attacker");
let mut forged = attacker
.state
.local_identity
.clone()
.expect("carol identity");
forged.user_identity.user_id = alice.user_identity.user_id.clone();
forged.device_identity.user_id = alice.user_identity.user_id.clone();
forged.device_identity.device_id = alice.device_identity.device_id.clone();
let (mut attacker_mls, _) = MlsAdapter::bootstrap(&forged).expect("attacker adapter");
let key_package = chat
.bob
.state
.mls_adapter
.as_mut()
.expect("bob adapter")
.rotate_key_package(test_now_ms())
.expect("bob key package");
let bob = chat.bob.state.local_identity.clone().expect("bob identity");
let artifacts = attacker_mls
.create_conversation_with_reply_lane(
&crate::model::random_opaque_id(),
&[crate::mls_adapter::PeerDeviceKeyPackage {
user_id: bob.user_identity.user_id.clone(),
device_id: bob.device_identity.device_id.clone(),
device_public_key: bob.device_identity.device_public_key.clone(),
key_package_b64: key_package.key_package_b64,
}],
Some(&crate::model::random_opaque_id()),
None,
Some(&crate::mls_adapter::CreatorRootProof::of(&alice)),
)
.expect("forged welcome");
let result = chat.bob.handle_command(CoreCommand::PreviewWelcome {
welcome_bytes: artifacts.welcomes[0].payload_b64.clone(),
});
assert!(
result.is_err(),
"a Welcome Alice's root key does not vouch for must not be previewed"
);
}

#[test]
fn preview_welcome_reads_identity_bundle_ref_without_adopting() {
let mut chat = unjoined_direct_chat();
Expand Down Expand Up @@ -4011,6 +4064,16 @@ pub(crate) mod tests {
.user_id
);
assert_eq!(preview.identity_bundle_ref, expected_ref);
assert_eq!(
preview.author_user_public_key,
chat.alice
.state
.local_identity
.as_ref()
.expect("alice identity")
.user_identity
.user_public_key
);
assert_eq!(
chat.bob
.state
Expand Down Expand Up @@ -17880,6 +17943,7 @@ pub(crate) mod tests {
}],
Some(&crate::model::random_opaque_id()),
None,
Some(&crate::mls_adapter::CreatorRootProof::of(&fake_identity)),
)
.unwrap();
let envelope = Envelope::with_bytes(
Expand Down
5 changes: 5 additions & 0 deletions src/ffi_api/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1113,6 +1113,11 @@ pub struct WelcomePreview {
/// [`crate::contact_workflows::ensure_welcome_signed_by_bundle`].
#[serde(default)]
pub author_device_public_key: String,
/// Hex. The creator's root key the Welcome carried; the core returns a
/// preview only once it has checked that `author_user_id` is this key's
/// fingerprint and that this key bound `author_device_public_key`.
#[serde(default)]
pub author_user_public_key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identity_bundle_ref: Option<String>,
}
Expand Down
10 changes: 10 additions & 0 deletions src/identity/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,16 @@ fn user_identity_fingerprint(pubkey_bytes: &[u8]) -> String {
encode_hex(&digest)[..32].to_string()
}

/// The user id a root public key (hex) names: `user:` and its fingerprint.
/// The same rule [`IdentityManager::verify_identity_bundle`] checks.
pub fn user_id_for_root_key(user_public_key: &str) -> CoreResult<String> {
let verifying_key = parse_verifying_key(user_public_key)?;
Ok(format!(
"user:{}",
user_identity_fingerprint(verifying_key.as_bytes())
))
}

pub fn encode_hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
Expand Down
Loading
Loading