From e2c616ff21ef2e87a9a5257a6f2619d94cb0d08b Mon Sep 17 00:00:00 2001 From: otsmr Date: Tue, 25 Aug 2026 00:51:55 +0200 Subject: [PATCH] start with sealed sender --- dependencies | 2 +- .../client/generated/messages.pb.dart | 285 ++++++++++ .../client/generated/messages.pbjson.dart | 61 ++ lib/src/model/protobuf/client/messages.proto | 115 +++- rust/Cargo.lock | 39 +- rust/Cargo.toml | 1 + rust/build.rs | 6 + rust/src/context.rs | 16 + rust/src/lib.rs | 1 + rust/src/sealed_sender.rs | 526 ++++++++++++++++++ 10 files changed, 1022 insertions(+), 30 deletions(-) create mode 100644 rust/src/sealed_sender.rs diff --git a/dependencies b/dependencies index b874b9cc..76a36c4d 160000 --- a/dependencies +++ b/dependencies @@ -1 +1 @@ -Subproject commit b874b9cc309822d90b940fdfbc8fd1ad3afc2450 +Subproject commit 76a36c4d3da1fd0da04da3aa5a6c2b3b9c984925 diff --git a/lib/src/model/protobuf/client/generated/messages.pb.dart b/lib/src/model/protobuf/client/generated/messages.pb.dart index c81430ad..ee72b2fa 100644 --- a/lib/src/model/protobuf/client/generated/messages.pb.dart +++ b/lib/src/model/protobuf/client/generated/messages.pb.dart @@ -21,6 +21,291 @@ export 'package:protobuf/protobuf.dart' show GeneratedMessageGenericExtensions; 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(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(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(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(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 { factory Message({ Message_Type? type, diff --git a/lib/src/model/protobuf/client/generated/messages.pbjson.dart b/lib/src/model/protobuf/client/generated/messages.pbjson.dart index 57b06a13..4c4258f6 100644 --- a/lib/src/model/protobuf/client/generated/messages.pbjson.dart +++ b/lib/src/model/protobuf/client/generated/messages.pbjson.dart @@ -15,6 +15,67 @@ import 'dart:convert' as $convert; import 'dart:core' as $core; 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') const Message$json = { '1': 'Message', diff --git a/lib/src/model/protobuf/client/messages.proto b/lib/src/model/protobuf/client/messages.proto index 0b6276ef..1b48c29d 100644 --- a/lib/src/model/protobuf/client/messages.proto +++ b/lib/src/model/protobuf/client/messages.proto @@ -1,5 +1,64 @@ 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 { enum Type { 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 optional int64 sender_profile_counter = 4; 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 Media media = 6; - optional MediaUpdate media_update = 7; - optional ContactUpdate contact_update = 8; - optional ContactRequest contact_request = 9; - optional FlameSync flame_sync = 10; - optional PushKeys push_keys = 11; - optional Reaction reaction = 12; - optional TextMessage text_message = 13; - optional GroupCreate group_create = 14; - optional GroupJoin group_join = 15; - optional GroupUpdate group_update = 16; - optional ResendGroupPublicKey resend_group_public_key = 17; - optional ErrorMessages error_messages = 18; - optional AdditionalDataMessage additional_data_message = 19; - optional TypingIndicator typing_indicator = 20; - optional UserDiscoveryRequest user_discovery_request = 22; - optional UserDiscoveryUpdate user_discovery_update = 23; - optional KeyVerificationProof key_verification_proof = 24; - optional PasswordLessRecovery passwordless_recovery = 26; - optional PasswordLessRecoveryHeartbeat passwordless_recovery_heartbeat = 27; + optional MessageUpdate message_update = 5; + optional Media media = 6; + optional MediaUpdate media_update = 7; + optional ContactUpdate contact_update = 8; + optional ContactRequest contact_request = 9; + optional FlameSync flame_sync = 10; + optional PushKeys push_keys = 11; + optional Reaction reaction = 12; + optional TextMessage text_message = 13; + optional GroupCreate group_create = 14; + optional GroupJoin group_join = 15; + optional GroupUpdate group_update = 16; + optional ResendGroupPublicKey resend_group_public_key = 17; + optional ErrorMessages error_messages = 18; + optional AdditionalDataMessage additional_data_message = 19; + optional TypingIndicator typing_indicator = 20; + optional UserDiscoveryRequest user_discovery_request = 22; + optional UserDiscoveryUpdate user_discovery_update = 23; + optional KeyVerificationProof key_verification_proof = 24; + optional PasswordLessRecovery passwordless_recovery = 26; + optional PasswordLessRecoveryHeartbeat passwordless_recovery_heartbeat = 27; message ErrorMessages { enum Type { @@ -75,9 +134,9 @@ message EncryptedContent { message GroupCreate { // key for the state stored on the server - bytes state_key = 3; - bytes group_public_key = 4; - optional string group_name = 5; + bytes state_key = 3; + bytes group_public_key = 4; + optional string group_name = 5; } message GroupJoin { @@ -220,12 +279,12 @@ message EncryptedContent { message PasswordLessRecovery { optional bytes recoverySecretShare = 1; - bool delete = 2; - int64 threshold = 3; + bool delete = 2; + int64 threshold = 3; } message PasswordLessRecoveryHeartbeat { bytes hash = 1; } -} \ No newline at end of file +} diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 7b9f3537..2f407546 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -381,6 +381,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "chacha20" version = "0.10.1" @@ -392,6 +403,19 @@ dependencies = [ "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]] name = "chrono" version = "0.4.45" @@ -414,6 +438,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common 0.1.7", "inout 0.1.4", + "zeroize", ] [[package]] @@ -2196,6 +2221,17 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "polyval" version = "0.6.2" @@ -2422,7 +2458,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "chacha20", + "chacha20 0.10.1", "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -2539,6 +2575,7 @@ dependencies = [ "async-trait", "base64", "blahaj", + "chacha20poly1305", "chrono", "flutter_rust_bridge", "hex", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 76032fd2..a71b9565 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -31,6 +31,7 @@ hmac = "0.13.0" hkdf = "0.12.4" sha2 = "0.10.8" aes-gcm = "0.10.3" +chacha20poly1305 = "0.10.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-appender = "0.2.5" paste = "1.0.15" diff --git a/rust/build.rs b/rust/build.rs index 135361e9..7c7bd7e5 100644 --- a/rust/build.rs +++ b/rust/build.rs @@ -1,5 +1,11 @@ use std::io::Result; fn main() -> Result<()> { 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(()) } diff --git a/rust/src/context.rs b/rust/src/context.rs index 7147ea40..d7e7e365 100644 --- a/rust/src/context.rs +++ b/rust/src/context.rs @@ -10,6 +10,7 @@ use crate::{ log::init_tracing, utils::Shared, }; +use libsignal_protocol::IdentityKey; use std::{path::PathBuf, sync::Arc}; use tokio::sync::{Mutex, OnceCell, RwLock}; use zeroize::Zeroize; @@ -253,6 +254,21 @@ impl Context { } } + pub(crate) async fn get_identity(&self, user_id: i64) -> Result> { + let database = self.get_rust_database().await; + let identity_key: Option> = + 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( &self, database: Database, diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 034004a6..1c7fe41a 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -6,6 +6,7 @@ mod error; mod frb_generated; mod keys; mod log; +pub mod sealed_sender; mod secure_storage; pub mod signal; mod user_discovery; diff --git a/rust/src/sealed_sender.rs b/rust/src/sealed_sender.rs new file mode 100644 index 00000000..be6d23be --- /dev/null +++ b/rust/src/sealed_sender.rs @@ -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 = std::result::Result; + +#[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( + from_user_id: i64, + recipient_user_id: i64, + message: proto::Message, + sender_identity: &IdentityKeyPair, + recipient_identity_key: &PublicKey, + rng: &mut R, + ) -> Result> + 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( + 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> + 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 { + 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 { + 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::::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 { + 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)); + } +}