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
13 changes: 13 additions & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ derive_more = { version = "2.1", features = [
"deref_mut",
"into_iterator",
] }
enum-as-inner = "0.7"
futures-util = "0.3"
hex = "0.4"
# temporary, until https://github.com/devashishdxt/idb/pull/42 is merged / released
Expand Down
3 changes: 3 additions & 0 deletions cc-book/src/release_notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Unreleased

- `DecryptedMessage` is now an enum with `Text`, `Commit`, and `Proposal` variants. For migration, see the migration
guide.

## CoreCrypto 10

### v10.2.0 - 2026-07-28
Expand Down
50 changes: 50 additions & 0 deletions cc-book/src/unreleased/migration_guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,53 @@
1. Deprecated the `key` parameter from in-memory Database constructor. In-memory databases are never encrypted.

1. `Database.getLocation` now returns the absolute path to the database file for non-web platforms.

## DecryptedMessage

`DecryptedMessage` is now an enum with `Text`, `Commit`, and `Proposal` variants. Data that was previously exposed
through optional properties is now carried by the corresponding variant. Match on the variant before accessing its data:

<!-- langtabs-start -->

```typescript
if (DecryptedMessage.Text.instanceOf(decryptedMessage)) {
const { plaintext, senderClientId, identity } = decryptedMessage.inner;
// Handle the application message.
} else if (DecryptedMessage.Commit.instanceOf(decryptedMessage)) {
// Handle the commit.
const { isActive, bufferedMessages, identity } = decryptedMessage.inner;
} else if (DecryptedMessage.Proposal.instanceOf(decryptedMessage)) {
const { delay, identity } = decryptedMessage.inner;
// Handle the proposal.
}
```

```swift
switch decryptedMessage {
case let .text(plaintext, senderClientId, identity):
// Handle the application message.
case let .commit(isActive, bufferedMessages, identity):
// Handle the commit.
case let .proposal(delay, identity):
// Handle the proposal.
}
```

```kotlin
when (decryptedMessage) {
is DecryptedMessage.Text -> {
val (plaintext, senderClientId, identity) = decryptedMessage
// Handle the application message.
}
is DecryptedMessage.Commit -> {
val (isActive, bufferedMessages, identity) = decryptedMessage
// Handle the commit.
}
is DecryptedMessage.Proposal -> {
val (delay, identity) = decryptedMessage
// Handle the proposal.
}
}
```

<!-- langtabs-end -->
25 changes: 19 additions & 6 deletions crypto-ffi/bindings/js/shared/shared/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {
DatabaseKey,
type DatabaseKey,
type CipherSuite,
type ClientId,
type CommitBundle,
Expand Down Expand Up @@ -422,11 +422,24 @@ async function setHelpers() {
}
);

const decoder = new TextDecoder();
const result1 = decoder.decode(decryptedByClient1.message);
const result2 = decoder.decode(decryptedByClient2.message);

return [result1, result2];
if (
ccModule.DecryptedMessage.Text.instanceOf(
decryptedByClient1
) &&
ccModule.DecryptedMessage.Text.instanceOf(
decryptedByClient2
)
) {
const decoder = new TextDecoder();
const result1 = decoder.decode(
decryptedByClient1.inner.plaintext
);
const result2 = decoder.decode(
decryptedByClient2.inner.plaintext
);
return [result1, result2];
}
return ["", ""];
}

/**
Expand Down
2 changes: 1 addition & 1 deletion crypto-ffi/bindings/js/shared/src/CoreCrypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export {
type GroupInfoBundle,
type HistorySecret,
CredentialRef,
type DecryptedMessage,
DecryptedMessage,
CoreCryptoLogLevel,
ProteusAutoPrekeyBundle,
BufferedDecryptedMessage,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -194,9 +194,7 @@ class MLSTest {
val commit = MockMlsTransportSuccessProvider.getInstance().getLatestCommit()

val decrypted = alice.transaction { ctx -> ctx.decryptMessage(groupId, commit) }
assertThat(decrypted.message).isNull()
assertThat(decrypted.commitDelay).isNull()
assertThat(decrypted.senderClientId).isNull()
assertIs<DecryptedMessage.Commit>(decrypted)
}

@Test
Expand All @@ -222,7 +220,10 @@ class MLSTest {
val ciphertextMsg = alice.transaction { ctx -> ctx.encryptMessage(groupId, msg) }
assertThat(ciphertextMsg).isNotEqualTo(msg)

val plaintextMsg = bob.transaction { ctx -> ctx.decryptMessage(groupId, ciphertextMsg).message!! }
val plaintextMsg =
bob.transaction { ctx ->
assertIs<DecryptedMessage.Text>(ctx.decryptMessage(groupId, ciphertextMsg)).plaintext
}
assertThat(plaintextMsg).isNotEmpty().isEqualTo(msg)

val expectedException =
Expand All @@ -248,7 +249,7 @@ class MLSTest {
val commit = MockMlsTransportSuccessProvider.getInstance().getLatestCommit()

val decrypted = alice.transaction { ctx -> ctx.decryptMessage(conversationId, commit) }
assertThat(decrypted.message).isNull()
assertIs<DecryptedMessage.Commit>(decrypted)

val members = alice.transaction { ctx -> ctx.getClientIds(conversationId) }
assertThat(members).containsAll(listOf(aliceId, bobId, carolId))
Expand Down Expand Up @@ -283,7 +284,7 @@ class MLSTest {
val commit = MockMlsTransportSuccessProvider.getInstance().getLatestCommit()

val decrypted = alice.transaction { ctx -> ctx.decryptMessage(conversationId, commit) }
assertThat(decrypted.message).isNull()
assertIs<DecryptedMessage.Commit>(decrypted)
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -556,9 +556,10 @@ final class WireCoreCryptoTests: XCTestCase {
let decrypted = try await alice.transaction { ctx in
try await ctx.decryptMessage(conversationId: conversationId, payload: commit!)
}
XCTAssertNil(decrypted.message)
XCTAssertNil(decrypted.commitDelay)
XCTAssertNil(decrypted.senderClientId)
guard case .commit = decrypted else {
XCTFail("Expected a decrypted commit")
return
}
}

func testEncryptMessageCanBeDecryptedByReceiver() async throws {
Expand All @@ -577,9 +578,12 @@ final class WireCoreCryptoTests: XCTestCase {
}
XCTAssertNotEqual(ciphertext, message)

let plaintext = try await bob.transaction { ctx in
let decrypted = try await bob.transaction { ctx in
try await ctx.decryptMessage(conversationId: conversationId, payload: ciphertext)
.message
}
guard case .text(let plaintext, _, _) = decrypted else {
XCTFail("Expected decrypted text")
return
}
XCTAssertEqual(plaintext, message)

Expand Down
154 changes: 95 additions & 59 deletions crypto-ffi/src/decrypted_message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,77 +4,113 @@ use core_crypto::{BufferedDecryptedMessage as CcBufferedDecryptedMessage, Decryp

use crate::{ClientId, WireIdentity};

/// A decrypted message and various associated metadata.
#[derive(Debug, uniffi::Record)]
pub struct DecryptedMessage {
/// Decrypted plaintext
pub message: Option<Vec<u8>>,
/// False if processing this message caused the client to be removed from the group, i.e. due to a Remove commit.
pub is_active: bool,
/// Commit delay in seconds.
///
/// When set, clients must delay this long before processing a commit.
/// This reduces load on the backend, which otherwise would receive epoch change notifications from all clients
/// simultaneously.
pub commit_delay: Option<u64>,
/// `ClientId` of the sender of the message being decrypted. Only present for application messages.
pub sender_client_id: Option<Arc<ClientId>>,
/// Identity claims present in the sender credential.
pub identity: WireIdentity,
/// Only set when the decrypted message is a commit.
///
/// Contains buffered messages for next epoch which were received before the commit creating the epoch
/// because the DS did not fan them out in order.
pub buffered_messages: Option<Vec<BufferedDecryptedMessage>>,
/// Represents the items a consumer might require after decrypting a message.
#[derive(Debug, uniffi::Enum)]
pub enum DecryptedMessage {
/// The decrypted message is a text message.
Text {
/// Decrypted text message.
plaintext: Vec<u8>,
/// The sender's `ClientId`.
sender_client_id: Arc<ClientId>,
/// Identity claims present in the sender credential.
identity: WireIdentity,
},
/// The decrypted message is a commit.
Commit {
/// False if processing this message caused the client to be removed from the group, i.e. due to a Remove
/// commit.
is_active: bool,
/// Contains buffered messages for next epoch which were received before the commit creating the epoch
/// because the DS did not fan them out in order.
buffered_messages: Option<Vec<BufferedDecryptedMessage>>,
/// Identity claims present in the sender credential.
identity: WireIdentity,
},
/// The decrypted message is a proposal.
Proposal {
/// Commit delay in seconds.
///
/// When set, clients must delay by this time interval before processing a commit. This reduces load on the
/// backend, which otherwise would receive epoch change notifications from all clients simultaneously.
delay: Option<u64>,
/// Identity claims present in the sender credential.
identity: WireIdentity,
},
}

impl From<CcDecryptedMessage> for DecryptedMessage {
fn from(from: CcDecryptedMessage) -> Self {
let buffered_messages = from
.buffered_messages
.map(|bm| bm.into_iter().map(Into::into).collect::<Vec<_>>());

Self {
message: from.app_msg,
is_active: from.is_active,
commit_delay: from.delay,
sender_client_id: from.sender_client_id.map(Into::into).map(Arc::new),
identity: from.identity.into(),
buffered_messages,
match from {
CcDecryptedMessage::Text(text) => Self::Text {
plaintext: text.plaintext,
sender_client_id: Arc::new(text.sender_client_id.into()),
identity: text.identity.into(),
},
CcDecryptedMessage::Commit(commit) => Self::Commit {
is_active: commit.is_active,
buffered_messages: commit
.buffered_messages
.map(|messages| messages.into_iter().map(Into::into).collect()),
identity: commit.identity.into(),
},
CcDecryptedMessage::Proposal(proposal) => Self::Proposal {
delay: proposal.delay,
identity: proposal.identity.into(),
},
}
}
}

/// A decrypted message that was buffered due to out-of-order delivery by the distribution service.
///
/// These are returned in the `buffered_messages` field of a `DecryptedMessage` when a commit is
/// processed. They represent messages for the new epoch that arrived before the commit that created it.
#[derive(Debug, Clone, uniffi::Record)]
pub struct BufferedDecryptedMessage {
/// Decrypted plaintext
pub message: Option<Vec<u8>>,
/// False if processing this message caused the client to be removed from the group, i.e. due to a Remove commit.
pub is_active: bool,
/// Commit delay in seconds.
///
/// When set, clients must delay this long before processing a commit.
/// This reduces load on the backend, which otherwise would receive epoch change notifications from all clients
/// simultaneously.
pub commit_delay: Option<u64>,
/// `ClientId` of the sender of the message being decrypted. Only present for application messages.
pub sender_client_id: Option<Arc<ClientId>>,
/// Identity claims present in the sender credential.
pub identity: WireIdentity,
/// A decrypted message that was buffered due to out-of-order delivery by the delivery service.
/// It represents messages for the new epoch that arrived before the commit that created it.
#[derive(Debug, uniffi::Enum)]
pub enum BufferedDecryptedMessage {
/// The decrypted message is a text message.
Text {
/// Decrypted text message.
plaintext: Vec<u8>,
/// The sender's `ClientId`.
sender_client_id: Arc<ClientId>,
/// Identity claims present in the sender credential.
identity: WireIdentity,
},
/// The decrypted message is a commit.
Commit {
/// False if processing this message caused the client to be removed from the group, i.e. due to a Remove
/// commit.
is_active: bool,
/// Identity claims present in the sender credential.
identity: WireIdentity,
},
/// The decrypted message is a proposal.
Proposal {
///Commit delay in seconds.
///
/// When set, clients must delay by this time interval before processing a commit. This reduces load on the
/// backend, which otherwise would receive epoch change notifications from all clients simultaneously.
delay: Option<u64>,
/// Identity claims present in the sender credential.
identity: WireIdentity,
},
}

impl From<CcBufferedDecryptedMessage> for BufferedDecryptedMessage {
fn from(from: CcBufferedDecryptedMessage) -> Self {
Self {
message: from.app_msg,
is_active: from.is_active,
commit_delay: from.delay,
sender_client_id: from.sender_client_id.map(Into::into).map(Arc::new),
identity: from.identity.into(),
match from {
CcBufferedDecryptedMessage::Text(text) => Self::Text {
plaintext: text.plaintext,
sender_client_id: Arc::new(text.sender_client_id.into()),
identity: text.identity.into(),
},
CcBufferedDecryptedMessage::Commit(commit) => Self::Commit {
is_active: commit.is_active,
identity: commit.identity.into(),
},
CcBufferedDecryptedMessage::Proposal(proposal) => Self::Proposal {
delay: proposal.delay,
identity: proposal.identity.into(),
},
}
}
}
1 change: 1 addition & 0 deletions crypto/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ test-all-cipher = []

[dependencies]
derive_more.workspace = true
enum-as-inner.workspace = true
thiserror.workspace = true
strum.workspace = true
hex.workspace = true
Expand Down
Loading
Loading