start with sealed sender
Some checks failed
Flutter analyze & test / flutter_analyze_and_test (push) Has been cancelled

This commit is contained in:
otsmr 2026-08-25 00:51:55 +02:00
parent b98a434a8e
commit e2c616ff21
10 changed files with 1022 additions and 30 deletions

@ -1 +1 @@
Subproject commit b874b9cc309822d90b940fdfbc8fd1ad3afc2450 Subproject commit 76a36c4d3da1fd0da04da3aa5a6c2b3b9c984925

View file

@ -21,6 +21,291 @@ export 'package:protobuf/protobuf.dart' show GeneratedMessageGenericExtensions;
export 'messages.pbenum.dart'; export 'messages.pbenum.dart';
class EncryptedMessageEnvelope extends $pb.GeneratedMessage {
factory EncryptedMessageEnvelope({
$core.List<$core.int>? ephemeralPublicKey,
$core.List<$core.int>? nonce,
$core.List<$core.int>? ciphertext,
}) {
final result = create();
if (ephemeralPublicKey != null)
result.ephemeralPublicKey = ephemeralPublicKey;
if (nonce != null) result.nonce = nonce;
if (ciphertext != null) result.ciphertext = ciphertext;
return result;
}
EncryptedMessageEnvelope._();
factory EncryptedMessageEnvelope.fromBuffer($core.List<$core.int> data,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromBuffer(data, registry);
factory EncryptedMessageEnvelope.fromJson($core.String json,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromJson(json, registry);
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
_omitMessageNames ? '' : 'EncryptedMessageEnvelope',
createEmptyInstance: create)
..a<$core.List<$core.int>>(
1, _omitFieldNames ? '' : 'ephemeralPublicKey', $pb.PbFieldType.OY)
..a<$core.List<$core.int>>(
2, _omitFieldNames ? '' : 'nonce', $pb.PbFieldType.OY)
..a<$core.List<$core.int>>(
3, _omitFieldNames ? '' : 'ciphertext', $pb.PbFieldType.OY)
..hasRequiredFields = false;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
EncryptedMessageEnvelope clone() => deepCopy();
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
EncryptedMessageEnvelope copyWith(
void Function(EncryptedMessageEnvelope) updates) =>
super.copyWith((message) => updates(message as EncryptedMessageEnvelope))
as EncryptedMessageEnvelope;
@$core.override
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static EncryptedMessageEnvelope create() => EncryptedMessageEnvelope._();
@$core.override
EncryptedMessageEnvelope createEmptyInstance() => create();
@$core.pragma('dart2js:noInline')
static EncryptedMessageEnvelope getDefault() => _defaultInstance ??=
$pb.GeneratedMessage.$_defaultFor<EncryptedMessageEnvelope>(create);
static EncryptedMessageEnvelope? _defaultInstance;
/// Fresh 32-byte X25519 public key generated for this envelope. The sender
/// derives the encryption key from this ephemeral key pair and the recipient's
/// public identity key using HKDF-SHA-256 with an empty salt and a 32-byte
/// output. HKDF info must be the UTF-8 bytes of
/// "twonly-message-envelope-encryption-v1" followed by ephemeral_public_key
/// and the recipient's serialized public identity key.
@$pb.TagNumber(1)
$core.List<$core.int> get ephemeralPublicKey => $_getN(0);
@$pb.TagNumber(1)
set ephemeralPublicKey($core.List<$core.int> value) => $_setBytes(0, value);
@$pb.TagNumber(1)
$core.bool hasEphemeralPublicKey() => $_has(0);
@$pb.TagNumber(1)
void clearEphemeralPublicKey() => $_clearField(1);
/// Fresh 24-byte XChaCha20-Poly1305 nonce.
@$pb.TagNumber(2)
$core.List<$core.int> get nonce => $_getN(1);
@$pb.TagNumber(2)
set nonce($core.List<$core.int> value) => $_setBytes(1, value);
@$pb.TagNumber(2)
$core.bool hasNonce() => $_has(1);
@$pb.TagNumber(2)
void clearNonce() => $_clearField(2);
/// Serialized MessageEnvelope encrypted with XChaCha20-Poly1305. This includes
/// the 16-byte Poly1305 authentication tag. The AEAD associated data is exactly
/// the UTF-8 bytes of "twonly-message-envelope-encryption-v1".
@$pb.TagNumber(3)
$core.List<$core.int> get ciphertext => $_getN(2);
@$pb.TagNumber(3)
set ciphertext($core.List<$core.int> value) => $_setBytes(2, value);
@$pb.TagNumber(3)
$core.bool hasCiphertext() => $_has(2);
@$pb.TagNumber(3)
void clearCiphertext() => $_clearField(3);
}
class MessageEnvelope extends $pb.GeneratedMessage {
factory MessageEnvelope({
$core.List<$core.int>? signedPayload,
$core.List<$core.int>? signature,
}) {
final result = create();
if (signedPayload != null) result.signedPayload = signedPayload;
if (signature != null) result.signature = signature;
return result;
}
MessageEnvelope._();
factory MessageEnvelope.fromBuffer($core.List<$core.int> data,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromBuffer(data, registry);
factory MessageEnvelope.fromJson($core.String json,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromJson(json, registry);
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
_omitMessageNames ? '' : 'MessageEnvelope',
createEmptyInstance: create)
..a<$core.List<$core.int>>(
1, _omitFieldNames ? '' : 'signedPayload', $pb.PbFieldType.OY)
..a<$core.List<$core.int>>(
2, _omitFieldNames ? '' : 'signature', $pb.PbFieldType.OY)
..hasRequiredFields = false;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
MessageEnvelope clone() => deepCopy();
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
MessageEnvelope copyWith(void Function(MessageEnvelope) updates) =>
super.copyWith((message) => updates(message as MessageEnvelope))
as MessageEnvelope;
@$core.override
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static MessageEnvelope create() => MessageEnvelope._();
@$core.override
MessageEnvelope createEmptyInstance() => create();
@$core.pragma('dart2js:noInline')
static MessageEnvelope getDefault() => _defaultInstance ??=
$pb.GeneratedMessage.$_defaultFor<MessageEnvelope>(create);
static MessageEnvelope? _defaultInstance;
/// Exact serialized MessageEnvelopePayload bytes. The signature is calculated
/// directly over these bytes. Keeping the serialized payload allows verification
/// without relying on canonical protobuf serialization.
@$pb.TagNumber(1)
$core.List<$core.int> get signedPayload => $_getN(0);
@$pb.TagNumber(1)
set signedPayload($core.List<$core.int> value) => $_setBytes(0, value);
@$pb.TagNumber(1)
$core.bool hasSignedPayload() => $_has(0);
@$pb.TagNumber(1)
void clearSignedPayload() => $_clearField(1);
/// Signature created with the sender's identity key. For a known contact, use
/// the pinned public identity key. For an unknown sender, fetch the public key
/// from the trusted server, verify this signature, and only then pin the key and
/// process the raw message.
@$pb.TagNumber(2)
$core.List<$core.int> get signature => $_getN(1);
@$pb.TagNumber(2)
set signature($core.List<$core.int> value) => $_setBytes(1, value);
@$pb.TagNumber(2)
$core.bool hasSignature() => $_has(1);
@$pb.TagNumber(2)
void clearSignature() => $_clearField(2);
}
class MessageEnvelopePayload extends $pb.GeneratedMessage {
factory MessageEnvelopePayload({
$core.String? magic,
$fixnum.Int64? fromUserId,
$fixnum.Int64? recipientUserId,
Message? message,
$fixnum.Int64? createdAtUnixSeconds,
}) {
final result = create();
if (magic != null) result.magic = magic;
if (fromUserId != null) result.fromUserId = fromUserId;
if (recipientUserId != null) result.recipientUserId = recipientUserId;
if (message != null) result.message = message;
if (createdAtUnixSeconds != null)
result.createdAtUnixSeconds = createdAtUnixSeconds;
return result;
}
MessageEnvelopePayload._();
factory MessageEnvelopePayload.fromBuffer($core.List<$core.int> data,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromBuffer(data, registry);
factory MessageEnvelopePayload.fromJson($core.String json,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromJson(json, registry);
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
_omitMessageNames ? '' : 'MessageEnvelopePayload',
createEmptyInstance: create)
..aOS(1, _omitFieldNames ? '' : 'magic')
..aInt64(2, _omitFieldNames ? '' : 'fromUserId')
..aInt64(3, _omitFieldNames ? '' : 'recipientUserId')
..aOM<Message>(4, _omitFieldNames ? '' : 'message',
subBuilder: Message.create)
..aInt64(5, _omitFieldNames ? '' : 'createdAtUnixSeconds')
..hasRequiredFields = false;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
MessageEnvelopePayload clone() => deepCopy();
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
MessageEnvelopePayload copyWith(
void Function(MessageEnvelopePayload) updates) =>
super.copyWith((message) => updates(message as MessageEnvelopePayload))
as MessageEnvelopePayload;
@$core.override
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static MessageEnvelopePayload create() => MessageEnvelopePayload._();
@$core.override
MessageEnvelopePayload createEmptyInstance() => create();
@$core.pragma('dart2js:noInline')
static MessageEnvelopePayload getDefault() => _defaultInstance ??=
$pb.GeneratedMessage.$_defaultFor<MessageEnvelopePayload>(create);
static MessageEnvelopePayload? _defaultInstance;
/// Domain-separation and protocol-version marker. The receiver must reject the
/// payload unless this is exactly "twonly-message-envelope-v1".
@$pb.TagNumber(1)
$core.String get magic => $_getSZ(0);
@$pb.TagNumber(1)
set magic($core.String value) => $_setString(0, value);
@$pb.TagNumber(1)
$core.bool hasMagic() => $_has(0);
@$pb.TagNumber(1)
void clearMagic() => $_clearField(1);
/// The sender is protected by the encryption of the outer MessageEnvelope.
@$pb.TagNumber(2)
$fixnum.Int64 get fromUserId => $_getI64(1);
@$pb.TagNumber(2)
set fromUserId($fixnum.Int64 value) => $_setInt64(1, value);
@$pb.TagNumber(2)
$core.bool hasFromUserId() => $_has(1);
@$pb.TagNumber(2)
void clearFromUserId() => $_clearField(2);
/// Bind the sender's signature to the intended recipient. This prevents a
/// recipient from re-encrypting a valid signed envelope for another user. The
/// binding is required because raw_message is not always a Signal ciphertext
/// and therefore is not always independently bound to a Signal session. The
/// receiver must reject an envelope when this ID does not match its own user ID.
@$pb.TagNumber(3)
$fixnum.Int64 get recipientUserId => $_getI64(2);
@$pb.TagNumber(3)
set recipientUserId($fixnum.Int64 value) => $_setInt64(2, value);
@$pb.TagNumber(3)
$core.bool hasRecipientUserId() => $_has(2);
@$pb.TagNumber(3)
void clearRecipientUserId() => $_clearField(3);
/// The mandatory UUIDv4 receipt_id is covered by the envelope signature and is
/// used by the receiver to reject replayed messages before processing them.
@$pb.TagNumber(4)
Message get message => $_getN(3);
@$pb.TagNumber(4)
set message(Message value) => $_setField(4, value);
@$pb.TagNumber(4)
$core.bool hasMessage() => $_has(3);
@$pb.TagNumber(4)
void clearMessage() => $_clearField(4);
@$pb.TagNumber(4)
Message ensureMessage() => $_ensure(3);
/// UTC Unix timestamp in seconds when the envelope was created. The receiver
/// must reject envelopes older than 45 days or more than five minutes in the
/// future. This field is covered by the envelope signature.
@$pb.TagNumber(5)
$fixnum.Int64 get createdAtUnixSeconds => $_getI64(4);
@$pb.TagNumber(5)
set createdAtUnixSeconds($fixnum.Int64 value) => $_setInt64(4, value);
@$pb.TagNumber(5)
$core.bool hasCreatedAtUnixSeconds() => $_has(4);
@$pb.TagNumber(5)
void clearCreatedAtUnixSeconds() => $_clearField(5);
}
class Message extends $pb.GeneratedMessage { class Message extends $pb.GeneratedMessage {
factory Message({ factory Message({
Message_Type? type, Message_Type? type,

View file

@ -15,6 +15,67 @@ import 'dart:convert' as $convert;
import 'dart:core' as $core; import 'dart:core' as $core;
import 'dart:typed_data' as $typed_data; import 'dart:typed_data' as $typed_data;
@$core.Deprecated('Use encryptedMessageEnvelopeDescriptor instead')
const EncryptedMessageEnvelope$json = {
'1': 'EncryptedMessageEnvelope',
'2': [
{
'1': 'ephemeral_public_key',
'3': 1,
'4': 1,
'5': 12,
'10': 'ephemeralPublicKey'
},
{'1': 'nonce', '3': 2, '4': 1, '5': 12, '10': 'nonce'},
{'1': 'ciphertext', '3': 3, '4': 1, '5': 12, '10': 'ciphertext'},
],
};
/// Descriptor for `EncryptedMessageEnvelope`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List encryptedMessageEnvelopeDescriptor = $convert.base64Decode(
'ChhFbmNyeXB0ZWRNZXNzYWdlRW52ZWxvcGUSMAoUZXBoZW1lcmFsX3B1YmxpY19rZXkYASABKA'
'xSEmVwaGVtZXJhbFB1YmxpY0tleRIUCgVub25jZRgCIAEoDFIFbm9uY2USHgoKY2lwaGVydGV4'
'dBgDIAEoDFIKY2lwaGVydGV4dA==');
@$core.Deprecated('Use messageEnvelopeDescriptor instead')
const MessageEnvelope$json = {
'1': 'MessageEnvelope',
'2': [
{'1': 'signed_payload', '3': 1, '4': 1, '5': 12, '10': 'signedPayload'},
{'1': 'signature', '3': 2, '4': 1, '5': 12, '10': 'signature'},
],
};
/// Descriptor for `MessageEnvelope`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List messageEnvelopeDescriptor = $convert.base64Decode(
'Cg9NZXNzYWdlRW52ZWxvcGUSJQoOc2lnbmVkX3BheWxvYWQYASABKAxSDXNpZ25lZFBheWxvYW'
'QSHAoJc2lnbmF0dXJlGAIgASgMUglzaWduYXR1cmU=');
@$core.Deprecated('Use messageEnvelopePayloadDescriptor instead')
const MessageEnvelopePayload$json = {
'1': 'MessageEnvelopePayload',
'2': [
{'1': 'magic', '3': 1, '4': 1, '5': 9, '10': 'magic'},
{'1': 'from_user_id', '3': 2, '4': 1, '5': 3, '10': 'fromUserId'},
{'1': 'recipient_user_id', '3': 3, '4': 1, '5': 3, '10': 'recipientUserId'},
{'1': 'message', '3': 4, '4': 1, '5': 11, '6': '.Message', '10': 'message'},
{
'1': 'created_at_unix_seconds',
'3': 5,
'4': 1,
'5': 3,
'10': 'createdAtUnixSeconds'
},
],
};
/// Descriptor for `MessageEnvelopePayload`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List messageEnvelopePayloadDescriptor = $convert.base64Decode(
'ChZNZXNzYWdlRW52ZWxvcGVQYXlsb2FkEhQKBW1hZ2ljGAEgASgJUgVtYWdpYxIgCgxmcm9tX3'
'VzZXJfaWQYAiABKANSCmZyb21Vc2VySWQSKgoRcmVjaXBpZW50X3VzZXJfaWQYAyABKANSD3Jl'
'Y2lwaWVudFVzZXJJZBIiCgdtZXNzYWdlGAQgASgLMgguTWVzc2FnZVIHbWVzc2FnZRI1Chdjcm'
'VhdGVkX2F0X3VuaXhfc2Vjb25kcxgFIAEoA1IUY3JlYXRlZEF0VW5peFNlY29uZHM=');
@$core.Deprecated('Use messageDescriptor instead') @$core.Deprecated('Use messageDescriptor instead')
const Message$json = { const Message$json = {
'1': 'Message', '1': 'Message',

View file

@ -1,5 +1,64 @@
syntax = "proto3"; syntax = "proto3";
message EncryptedMessageEnvelope {
// Fresh 32-byte X25519 public key generated for this envelope. The sender
// derives the encryption key from this ephemeral key pair and the recipient's
// public identity key using HKDF-SHA-256 with an empty salt and a 32-byte
// output. HKDF info must be the UTF-8 bytes of
// "twonly-message-envelope-encryption-v1" followed by ephemeral_public_key
// and the recipient's serialized public identity key.
bytes ephemeral_public_key = 1;
// Fresh 24-byte XChaCha20-Poly1305 nonce.
bytes nonce = 2;
// Serialized MessageEnvelope encrypted with XChaCha20-Poly1305. This includes
// the 16-byte Poly1305 authentication tag. The AEAD associated data is exactly
// the UTF-8 bytes of "twonly-message-envelope-encryption-v1".
bytes ciphertext = 3;
}
message MessageEnvelope {
// The serialized MessageEnvelope is encrypted into EncryptedMessageEnvelope
// before it is sent to the server.
// Exact serialized MessageEnvelopePayload bytes. The signature is calculated
// directly over these bytes. Keeping the serialized payload allows verification
// without relying on canonical protobuf serialization.
bytes signed_payload = 1;
// Signature created with the sender's identity key. For a known contact, use
// the pinned public identity key. For an unknown sender, fetch the public key
// from the trusted server, verify this signature, and only then pin the key and
// process the raw message.
bytes signature = 2;
}
message MessageEnvelopePayload {
// Domain-separation and protocol-version marker. The receiver must reject the
// payload unless this is exactly "twonly-message-envelope-v1".
string magic = 1;
// The sender is protected by the encryption of the outer MessageEnvelope.
int64 from_user_id = 2;
// Bind the sender's signature to the intended recipient. This prevents a
// recipient from re-encrypting a valid signed envelope for another user. The
// binding is required because raw_message is not always a Signal ciphertext
// and therefore is not always independently bound to a Signal session. The
// receiver must reject an envelope when this ID does not match its own user ID.
int64 recipient_user_id = 3;
// The mandatory UUIDv4 receipt_id is covered by the envelope signature and is
// used by the receiver to reject replayed messages before processing them.
Message message = 4;
// UTC Unix timestamp in seconds when the envelope was created. The receiver
// must reject envelopes older than 45 days or more than five minutes in the
// future. This field is covered by the envelope signature.
int64 created_at_unix_seconds = 5;
}
message Message { message Message {
enum Type { enum Type {
SENDER_DELIVERY_RECEIPT = 0; SENDER_DELIVERY_RECEIPT = 0;
@ -38,29 +97,29 @@ message EncryptedContent {
/// This can be added, so the receiver can check weather he is up to date with the current profile /// This can be added, so the receiver can check weather he is up to date with the current profile
optional int64 sender_profile_counter = 4; optional int64 sender_profile_counter = 4;
optional bytes sender_user_discovery_version = 21; optional bytes sender_user_discovery_version = 21;
optional bool ask_for_friend_promotions = 25; optional bool ask_for_friend_promotions = 25;
optional MessageUpdate message_update = 5; optional MessageUpdate message_update = 5;
optional Media media = 6; optional Media media = 6;
optional MediaUpdate media_update = 7; optional MediaUpdate media_update = 7;
optional ContactUpdate contact_update = 8; optional ContactUpdate contact_update = 8;
optional ContactRequest contact_request = 9; optional ContactRequest contact_request = 9;
optional FlameSync flame_sync = 10; optional FlameSync flame_sync = 10;
optional PushKeys push_keys = 11; optional PushKeys push_keys = 11;
optional Reaction reaction = 12; optional Reaction reaction = 12;
optional TextMessage text_message = 13; optional TextMessage text_message = 13;
optional GroupCreate group_create = 14; optional GroupCreate group_create = 14;
optional GroupJoin group_join = 15; optional GroupJoin group_join = 15;
optional GroupUpdate group_update = 16; optional GroupUpdate group_update = 16;
optional ResendGroupPublicKey resend_group_public_key = 17; optional ResendGroupPublicKey resend_group_public_key = 17;
optional ErrorMessages error_messages = 18; optional ErrorMessages error_messages = 18;
optional AdditionalDataMessage additional_data_message = 19; optional AdditionalDataMessage additional_data_message = 19;
optional TypingIndicator typing_indicator = 20; optional TypingIndicator typing_indicator = 20;
optional UserDiscoveryRequest user_discovery_request = 22; optional UserDiscoveryRequest user_discovery_request = 22;
optional UserDiscoveryUpdate user_discovery_update = 23; optional UserDiscoveryUpdate user_discovery_update = 23;
optional KeyVerificationProof key_verification_proof = 24; optional KeyVerificationProof key_verification_proof = 24;
optional PasswordLessRecovery passwordless_recovery = 26; optional PasswordLessRecovery passwordless_recovery = 26;
optional PasswordLessRecoveryHeartbeat passwordless_recovery_heartbeat = 27; optional PasswordLessRecoveryHeartbeat passwordless_recovery_heartbeat = 27;
message ErrorMessages { message ErrorMessages {
enum Type { enum Type {
@ -75,9 +134,9 @@ message EncryptedContent {
message GroupCreate { message GroupCreate {
// key for the state stored on the server // key for the state stored on the server
bytes state_key = 3; bytes state_key = 3;
bytes group_public_key = 4; bytes group_public_key = 4;
optional string group_name = 5; optional string group_name = 5;
} }
message GroupJoin { message GroupJoin {
@ -220,8 +279,8 @@ message EncryptedContent {
message PasswordLessRecovery { message PasswordLessRecovery {
optional bytes recoverySecretShare = 1; optional bytes recoverySecretShare = 1;
bool delete = 2; bool delete = 2;
int64 threshold = 3; int64 threshold = 3;
} }
message PasswordLessRecoveryHeartbeat { message PasswordLessRecoveryHeartbeat {

39
rust/Cargo.lock generated
View file

@ -381,6 +381,17 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "chacha20"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
dependencies = [
"cfg-if",
"cipher 0.4.4",
"cpufeatures 0.2.17",
]
[[package]] [[package]]
name = "chacha20" name = "chacha20"
version = "0.10.1" version = "0.10.1"
@ -392,6 +403,19 @@ dependencies = [
"rand_core 0.10.1", "rand_core 0.10.1",
] ]
[[package]]
name = "chacha20poly1305"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
dependencies = [
"aead",
"chacha20 0.9.1",
"cipher 0.4.4",
"poly1305",
"zeroize",
]
[[package]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.45" version = "0.4.45"
@ -414,6 +438,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [ dependencies = [
"crypto-common 0.1.7", "crypto-common 0.1.7",
"inout 0.1.4", "inout 0.1.4",
"zeroize",
] ]
[[package]] [[package]]
@ -2196,6 +2221,17 @@ version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "poly1305"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
dependencies = [
"cpufeatures 0.2.17",
"opaque-debug",
"universal-hash 0.5.1",
]
[[package]] [[package]]
name = "polyval" name = "polyval"
version = "0.6.2" version = "0.6.2"
@ -2422,7 +2458,7 @@ version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [ dependencies = [
"chacha20", "chacha20 0.10.1",
"getrandom 0.4.3", "getrandom 0.4.3",
"rand_core 0.10.1", "rand_core 0.10.1",
] ]
@ -2539,6 +2575,7 @@ dependencies = [
"async-trait", "async-trait",
"base64", "base64",
"blahaj", "blahaj",
"chacha20poly1305",
"chrono", "chrono",
"flutter_rust_bridge", "flutter_rust_bridge",
"hex", "hex",

View file

@ -31,6 +31,7 @@ hmac = "0.13.0"
hkdf = "0.12.4" hkdf = "0.12.4"
sha2 = "0.10.8" sha2 = "0.10.8"
aes-gcm = "0.10.3" aes-gcm = "0.10.3"
chacha20poly1305 = "0.10.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing-appender = "0.2.5" tracing-appender = "0.2.5"
paste = "1.0.15" paste = "1.0.15"

View file

@ -1,5 +1,11 @@
use std::io::Result; use std::io::Result;
fn main() -> Result<()> { fn main() -> Result<()> {
prost_build::compile_protos(&["src/user_discovery/types.proto"], &["src/"])?; prost_build::compile_protos(&["src/user_discovery/types.proto"], &["src/"])?;
prost_build::Config::new()
.include_file("client_messages.rs")
.compile_protos(
&["../lib/src/model/protobuf/client/messages.proto"],
&["../lib/src/model/protobuf/client/"],
)?;
Ok(()) Ok(())
} }

View file

@ -10,6 +10,7 @@ use crate::{
log::init_tracing, log::init_tracing,
utils::Shared, utils::Shared,
}; };
use libsignal_protocol::IdentityKey;
use std::{path::PathBuf, sync::Arc}; use std::{path::PathBuf, sync::Arc};
use tokio::sync::{Mutex, OnceCell, RwLock}; use tokio::sync::{Mutex, OnceCell, RwLock};
use zeroize::Zeroize; use zeroize::Zeroize;
@ -253,6 +254,21 @@ impl Context {
} }
} }
pub(crate) async fn get_identity(&self, user_id: i64) -> Result<Option<IdentityKey>> {
let database = self.get_rust_database().await;
let identity_key: Option<Vec<u8>> =
sqlx::query_scalar("SELECT identity_key FROM signal_identities WHERE name = ?")
.bind(user_id.to_string())
.fetch_optional(&database.pool)
.await?;
identity_key
.map(|bytes| {
IdentityKey::decode(&bytes).map_err(|error| TwonlyError::Signal(error.to_string()))
})
.transpose()
}
pub(crate) async fn replace_rust_database( pub(crate) async fn replace_rust_database(
&self, &self,
database: Database, database: Database,

View file

@ -6,6 +6,7 @@ mod error;
mod frb_generated; mod frb_generated;
mod keys; mod keys;
mod log; mod log;
pub mod sealed_sender;
mod secure_storage; mod secure_storage;
pub mod signal; pub mod signal;
mod user_discovery; mod user_discovery;

526
rust/src/sealed_sender.rs Normal file
View file

@ -0,0 +1,526 @@
use chacha20poly1305::{
aead::{Aead, Payload},
KeyInit, XChaCha20Poly1305, XNonce,
};
use hkdf::Hkdf;
use libsignal_protocol::{IdentityKeyPair, KeyPair, PublicKey};
use prost::Message as ProstMessage;
use rand::{CryptoRng, Rng};
use sha2::Sha256;
use std::time::{SystemTime, UNIX_EPOCH};
use thiserror::Error;
use crate::context::Context;
pub mod proto {
include!(concat!(env!("OUT_DIR"), "/client_messages.rs"));
}
const PAYLOAD_MAGIC: &str = "twonly-message-envelope-v1";
const ENCRYPTION_CONTEXT: &[u8] = b"twonly-message-envelope-encryption-v1";
const X25519_PUBLIC_KEY_SIZE: usize = 32;
const XCHACHA20_NONCE_SIZE: usize = 24;
const POLY1305_TAG_SIZE: usize = 16;
const ENVELOPE_VALIDITY_SECONDS: i64 = 45 * 24 * 60 * 60;
const MAX_FUTURE_CLOCK_SKEW_SECONDS: i64 = 5 * 60;
pub type Result<T> = std::result::Result<T, SealedSenderError>;
#[derive(Debug, Error)]
pub enum SealedSenderError {
#[error("invalid protobuf: {0}")]
InvalidProtobuf(#[from] prost::DecodeError),
#[error("invalid ephemeral public key")]
InvalidEphemeralPublicKey,
#[error("invalid XChaCha20-Poly1305 nonce")]
InvalidNonce,
#[error("invalid XChaCha20-Poly1305 ciphertext")]
InvalidCiphertext,
#[error("X25519 key agreement failed")]
KeyAgreement,
#[error("sender signature generation failed")]
SignatureGeneration,
#[error("HKDF key derivation failed")]
KeyDerivation,
#[error("XChaCha20-Poly1305 encryption failed")]
EncryptionFailed,
#[error("XChaCha20-Poly1305 authentication failed")]
AuthenticationFailed,
#[error("invalid sealed-sender payload magic")]
InvalidMagic,
#[error("message was addressed to user {actual}, not user {expected}")]
WrongRecipient { expected: i64, actual: i64 },
#[error("sealed-sender payload does not contain a message")]
MissingMessage,
#[error("sealed-sender message has an empty receipt ID")]
MissingReceiptId,
#[error("no identity key is available for sender {0}")]
UnknownSender(i64),
#[error("invalid sender signature")]
InvalidSignature,
#[error("sealed-sender context error: {0}")]
Context(String),
#[error("the local user ID is unavailable")]
MissingLocalUserId,
#[error("the local Signal identity is unavailable")]
MissingLocalIdentity,
#[error("invalid local Signal identity")]
InvalidLocalIdentity,
#[error("system clock is before the Unix epoch")]
InvalidSystemTime,
#[error("sealed-sender envelope has expired")]
ExpiredEnvelope,
#[error("sealed-sender envelope timestamp is too far in the future")]
FutureEnvelope,
}
/// Encrypts and decrypts twonly sealed-sender envelopes.
///
/// The outer encryption authenticates the ciphertext but intentionally does not
/// authenticate the sender. Sender authentication is provided by the XEdDSA
/// identity-key signature inside the encrypted envelope.
pub(crate) struct SealedSender;
impl SealedSender {
pub(crate) fn encrypt<R>(
from_user_id: i64,
recipient_user_id: i64,
message: proto::Message,
sender_identity: &IdentityKeyPair,
recipient_identity_key: &PublicKey,
rng: &mut R,
) -> Result<Vec<u8>>
where
R: Rng + CryptoRng + ?Sized,
{
Self::encrypt_at(
from_user_id,
recipient_user_id,
message,
unix_time_seconds()?,
sender_identity,
recipient_identity_key,
rng,
)
}
fn encrypt_at<R>(
from_user_id: i64,
recipient_user_id: i64,
message: proto::Message,
created_at_unix_seconds: i64,
sender_identity: &IdentityKeyPair,
recipient_identity_key: &PublicKey,
rng: &mut R,
) -> Result<Vec<u8>>
where
R: Rng + CryptoRng + ?Sized,
{
if message.receipt_id.is_empty() {
return Err(SealedSenderError::MissingReceiptId);
}
let payload = proto::MessageEnvelopePayload {
magic: PAYLOAD_MAGIC.to_owned(),
from_user_id,
recipient_user_id,
message: Some(message),
created_at_unix_seconds,
};
let signed_payload = payload.encode_to_vec();
let signature = sender_identity
.private_key()
.calculate_signature(&signed_payload, rng)
.map_err(|_| SealedSenderError::SignatureGeneration)?;
let envelope = proto::MessageEnvelope {
signed_payload,
signature: signature.into_vec(),
}
.encode_to_vec();
let ephemeral_key_pair = KeyPair::generate(rng);
let ephemeral_public_key = ephemeral_key_pair.public_key.public_key_bytes().to_vec();
let shared_secret = ephemeral_key_pair
.private_key
.calculate_agreement(recipient_identity_key)
.map_err(|_| SealedSenderError::KeyAgreement)?;
let key = derive_encryption_key(
&shared_secret,
&ephemeral_public_key,
recipient_identity_key,
)?;
let mut nonce = [0_u8; XCHACHA20_NONCE_SIZE];
rng.fill_bytes(&mut nonce);
let ciphertext = XChaCha20Poly1305::new((&key).into())
.encrypt(
XNonce::from_slice(&nonce),
Payload {
msg: &envelope,
aad: ENCRYPTION_CONTEXT,
},
)
.map_err(|_| SealedSenderError::EncryptionFailed)?;
Ok(proto::EncryptedMessageEnvelope {
ephemeral_public_key,
nonce: nonce.to_vec(),
ciphertext,
}
.encode_to_vec())
}
/// Decrypts an envelope with the local identity from `context` and verifies
/// the signature using the sender identity stored by Signal.
pub(crate) async fn decrypt(
encrypted_envelope: &[u8],
context: &Context,
) -> Result<proto::MessageEnvelopePayload> {
let (recipient_user_id, recipient_identity) = {
let key_manager = context.get_key_manager().await.map_err(context_error)?;
let recipient_user_id = key_manager
.user_id
.ok_or(SealedSenderError::MissingLocalUserId)?;
let serialized_identity = &key_manager
.signal_identity
.as_ref()
.ok_or(SealedSenderError::MissingLocalIdentity)?
.identity_key_pair_structure;
let recipient_identity = IdentityKeyPair::try_from(serialized_identity.as_slice())
.map_err(|_| SealedSenderError::InvalidLocalIdentity)?;
(recipient_user_id, recipient_identity)
};
let (envelope, payload) =
Self::decrypt_envelope(encrypted_envelope, recipient_user_id, &recipient_identity)?;
let sender_identity = context
.get_identity(payload.from_user_id)
.await
.map_err(context_error)?
.ok_or(SealedSenderError::UnknownSender(payload.from_user_id))?;
verify_sender_and_message(&envelope, &payload, sender_identity.public_key())?;
Ok(payload)
}
fn decrypt_envelope(
encrypted_envelope: &[u8],
recipient_user_id: i64,
recipient_identity: &IdentityKeyPair,
) -> Result<(proto::MessageEnvelope, proto::MessageEnvelopePayload)> {
let encrypted = proto::EncryptedMessageEnvelope::decode(encrypted_envelope)?;
if encrypted.ephemeral_public_key.len() != X25519_PUBLIC_KEY_SIZE {
return Err(SealedSenderError::InvalidEphemeralPublicKey);
}
if encrypted.nonce.len() != XCHACHA20_NONCE_SIZE {
return Err(SealedSenderError::InvalidNonce);
}
if encrypted.ciphertext.len() < POLY1305_TAG_SIZE {
return Err(SealedSenderError::InvalidCiphertext);
}
let ephemeral_public_key =
PublicKey::from_djb_public_key_bytes(&encrypted.ephemeral_public_key)
.map_err(|_| SealedSenderError::InvalidEphemeralPublicKey)?;
let shared_secret = recipient_identity
.private_key()
.calculate_agreement(&ephemeral_public_key)
.map_err(|_| SealedSenderError::KeyAgreement)?;
let key = derive_encryption_key(
&shared_secret,
&encrypted.ephemeral_public_key,
recipient_identity.identity_key().public_key(),
)?;
let plaintext = XChaCha20Poly1305::new((&key).into())
.decrypt(
XNonce::from_slice(&encrypted.nonce),
Payload {
msg: &encrypted.ciphertext,
aad: ENCRYPTION_CONTEXT,
},
)
.map_err(|_| SealedSenderError::AuthenticationFailed)?;
let envelope = proto::MessageEnvelope::decode(plaintext.as_slice())?;
let payload = proto::MessageEnvelopePayload::decode(envelope.signed_payload.as_slice())?;
if payload.magic != PAYLOAD_MAGIC {
return Err(SealedSenderError::InvalidMagic);
}
if payload.recipient_user_id != recipient_user_id {
return Err(SealedSenderError::WrongRecipient {
expected: recipient_user_id,
actual: payload.recipient_user_id,
});
}
Ok((envelope, payload))
}
}
fn verify_sender_and_message(
envelope: &proto::MessageEnvelope,
payload: &proto::MessageEnvelopePayload,
sender_key: &PublicKey,
) -> Result<()> {
if !sender_key.verify_signature(&envelope.signed_payload, &envelope.signature) {
return Err(SealedSenderError::InvalidSignature);
}
let message = payload
.message
.as_ref()
.ok_or(SealedSenderError::MissingMessage)?;
if message.receipt_id.is_empty() {
return Err(SealedSenderError::MissingReceiptId);
}
let now = unix_time_seconds()?;
if payload.created_at_unix_seconds < now.saturating_sub(ENVELOPE_VALIDITY_SECONDS) {
return Err(SealedSenderError::ExpiredEnvelope);
}
if payload.created_at_unix_seconds > now.saturating_add(MAX_FUTURE_CLOCK_SKEW_SECONDS) {
return Err(SealedSenderError::FutureEnvelope);
}
Ok(())
}
fn context_error(error: impl std::fmt::Display) -> SealedSenderError {
SealedSenderError::Context(error.to_string())
}
fn unix_time_seconds() -> Result<i64> {
let seconds = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| SealedSenderError::InvalidSystemTime)?
.as_secs();
i64::try_from(seconds).map_err(|_| SealedSenderError::InvalidSystemTime)
}
fn derive_encryption_key(
shared_secret: &[u8],
ephemeral_public_key: &[u8],
recipient_identity_key: &PublicKey,
) -> Result<[u8; 32]> {
let mut info = Vec::with_capacity(
ENCRYPTION_CONTEXT.len()
+ ephemeral_public_key.len()
+ recipient_identity_key.serialize().len(),
);
info.extend_from_slice(ENCRYPTION_CONTEXT);
info.extend_from_slice(ephemeral_public_key);
info.extend_from_slice(&recipient_identity_key.serialize());
let mut key = [0_u8; 32];
Hkdf::<Sha256>::new(None, shared_secret)
.expand(&info, &mut key)
.map_err(|_| SealedSenderError::KeyDerivation)?;
Ok(key)
}
#[cfg(test)]
mod tests {
use super::*;
use rand::{rngs::StdRng, SeedableRng};
fn test_message() -> proto::Message {
proto::Message {
r#type: proto::message::Type::PlaintextContent as i32,
receipt_id: "1f58417a-5cd4-4bb6-9f38-2f499d9cfa8e".to_owned(),
..Default::default()
}
}
fn decrypt_with_identity(
encrypted: &[u8],
recipient_user_id: i64,
recipient: &IdentityKeyPair,
sender_key: &PublicKey,
) -> Result<proto::MessageEnvelopePayload> {
let (envelope, payload) =
SealedSender::decrypt_envelope(encrypted, recipient_user_id, recipient)?;
verify_sender_and_message(&envelope, &payload, sender_key)?;
Ok(payload)
}
#[tokio::test]
async fn encrypts_decrypts_and_authenticates_a_sealed_sender_message() {
let mut rng = StdRng::seed_from_u64(7);
let sender = IdentityKeyPair::generate(&mut rng);
let recipient = IdentityKeyPair::generate(&mut rng);
let message = test_message();
let temporary_directory = tempfile::tempdir().unwrap();
let context = Context::init_for_testing(
temporary_directory.path().join("database"),
temporary_directory.path().join("data"),
)
.await
.unwrap();
{
let mut key_manager = context.get_key_manager().await.unwrap();
key_manager.user_id = Some(42);
key_manager.signal_identity = Some(crate::keys::SignalIdentityKey {
identity_key_pair_structure: recipient.serialize().to_vec(),
registration_id: 1,
pre_key_store: Default::default(),
});
}
let database = context.get_rust_database().await;
sqlx::query(
"INSERT INTO signal_identities (name, identity_key, timestamp) VALUES (?, ?, 0)",
)
.bind("41")
.bind(sender.identity_key().serialize().as_ref())
.execute(&database.pool)
.await
.unwrap();
let encrypted = SealedSender::encrypt(
41,
42,
message.clone(),
&sender,
recipient.identity_key().public_key(),
&mut rng,
)
.unwrap();
let decrypted = SealedSender::decrypt(&encrypted, &context).await.unwrap();
assert_eq!(decrypted.magic, PAYLOAD_MAGIC);
assert_eq!(decrypted.from_user_id, 41);
assert_eq!(decrypted.recipient_user_id, 42);
assert_eq!(decrypted.message, Some(message));
assert!(decrypted.created_at_unix_seconds > 0);
}
#[test]
fn rejects_tampered_ciphertext() {
let mut rng = StdRng::seed_from_u64(8);
let sender = IdentityKeyPair::generate(&mut rng);
let recipient = IdentityKeyPair::generate(&mut rng);
let encrypted = SealedSender::encrypt(
41,
42,
test_message(),
&sender,
recipient.identity_key().public_key(),
&mut rng,
)
.unwrap();
let mut wrapper = proto::EncryptedMessageEnvelope::decode(encrypted.as_slice()).unwrap();
wrapper.ciphertext[0] ^= 1;
let error = decrypt_with_identity(
&wrapper.encode_to_vec(),
42,
&recipient,
sender.identity_key().public_key(),
)
.unwrap_err();
assert!(matches!(error, SealedSenderError::AuthenticationFailed));
}
#[test]
fn rejects_a_signature_from_another_identity() {
let mut rng = StdRng::seed_from_u64(9);
let sender = IdentityKeyPair::generate(&mut rng);
let impostor = IdentityKeyPair::generate(&mut rng);
let recipient = IdentityKeyPair::generate(&mut rng);
let encrypted = SealedSender::encrypt(
41,
42,
test_message(),
&sender,
recipient.identity_key().public_key(),
&mut rng,
)
.unwrap();
let error = decrypt_with_identity(
&encrypted,
42,
&recipient,
impostor.identity_key().public_key(),
)
.unwrap_err();
assert!(matches!(error, SealedSenderError::InvalidSignature));
}
#[test]
fn rejects_an_envelope_for_another_recipient_before_resolving_sender() {
let mut rng = StdRng::seed_from_u64(10);
let sender = IdentityKeyPair::generate(&mut rng);
let recipient = IdentityKeyPair::generate(&mut rng);
let encrypted = SealedSender::encrypt(
41,
42,
test_message(),
&sender,
recipient.identity_key().public_key(),
&mut rng,
)
.unwrap();
let error = SealedSender::decrypt_envelope(&encrypted, 43, &recipient).unwrap_err();
assert!(matches!(
error,
SealedSenderError::WrongRecipient {
expected: 43,
actual: 42
}
));
}
#[test]
fn rejects_an_expired_envelope() {
let mut rng = StdRng::seed_from_u64(11);
let sender = IdentityKeyPair::generate(&mut rng);
let recipient = IdentityKeyPair::generate(&mut rng);
let expired_at = unix_time_seconds().unwrap() - ENVELOPE_VALIDITY_SECONDS - 1;
let encrypted = SealedSender::encrypt_at(
41,
42,
test_message(),
expired_at,
&sender,
recipient.identity_key().public_key(),
&mut rng,
)
.unwrap();
let error = decrypt_with_identity(
&encrypted,
42,
&recipient,
sender.identity_key().public_key(),
)
.unwrap_err();
assert!(matches!(error, SealedSenderError::ExpiredEnvelope));
}
#[test]
fn rejects_an_envelope_too_far_in_the_future() {
let mut rng = StdRng::seed_from_u64(12);
let sender = IdentityKeyPair::generate(&mut rng);
let recipient = IdentityKeyPair::generate(&mut rng);
let future_at = unix_time_seconds().unwrap() + MAX_FUTURE_CLOCK_SKEW_SECONDS + 1;
let encrypted = SealedSender::encrypt_at(
41,
42,
test_message(),
future_at,
&sender,
recipient.identity_key().public_key(),
&mut rng,
)
.unwrap();
let error = decrypt_with_identity(
&encrypted,
42,
&recipient,
sender.identity_key().public_key(),
)
.unwrap_err();
assert!(matches!(error, SealedSenderError::FutureEnvelope));
}
}