Skip to content

Conversation

@kkohbrok
Copy link
Member

@kkohbrok kkohbrok commented Apr 10, 2025

This PR introduces user profiles that can be encrypted and uploaded to the AS, where other users can download it when seeing the user in a group. Keys are distributed via the group state on the DS.

@kkohbrok kkohbrok changed the title Konrad/as user profiles User profiles Apr 10, 2025
@kkohbrok kkohbrok mentioned this pull request Apr 11, 2025
5 tasks
@kkohbrok kkohbrok requested a review from Copilot April 11, 2025 10:00
@kkohbrok kkohbrok marked this pull request as ready for review April 11, 2025 10:00
@kkohbrok kkohbrok changed the title User profiles User profiles phase 1 Apr 11, 2025
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot reviewed 55 out of 64 changed files in this pull request and generated no comments.

Files not reviewed (9)
  • backend/.sqlx/query-28b419939a08f57c86836f0fc1f8a9735debc1f68d7e46004a03328c103bc3a6.json: Language not supported
  • backend/.sqlx/query-75767ac0c4fd0d80b05374023441177850ad84c37d647ae99f623f6f0438ebf6.json: Language not supported
  • backend/.sqlx/query-973891122d0736c69c439a140d5cab6a2e59e00f0e52d14a214f5c48b8cbc0d8.json: Language not supported
  • backend/.sqlx/query-a040c4f8d827be5ef6aa82143e9e110ae041e31f34b04ffa24a71a4254bda015.json: Language not supported
  • backend/.sqlx/query-fb077ef59b81b3659e0bc280679606b0b6d620387abd433d96c7acec525f8bd6.json: Language not supported
  • backend/migrations/20240927070412_create_initial_as_tables.sql: Language not supported
  • backend/migrations/20240927094408_create_initial_qs_tables.sql: Language not supported
  • coreclient/.sqlx/query-0c3b957fd18fd409d563d75db91f4ef129aef5222a028487218c9dcc08e146b2.json: Language not supported
  • coreclient/.sqlx/query-2c5823929ace6b56806ca3e023bee1e32cf9dff0f1009f3b923a90bd402f32e4.json: Language not supported
Comments suppressed due to low confidence (2)

backend/src/ds/group_operation.rs:367

  • [nitpick] Consider renaming the tuple type 'AddedUserInfo' to more explicitly reflect that it now includes an encrypted user profile key (e.g., 'AddedUserInfoWithProfile') to improve clarity for future maintainers.
type AddedUserInfo = (KeyPackage, EncryptedIdentityLinkKey, EncryptedUserProfileKey);

backend/src/ds/group_operation.rs:419

  • [nitpick] Consider adding an inline comment explaining the association between the new encrypted user profile keys and the existing identity link keys to aid clarity in this multi-zip chain.
.zip(aad_payload.new_encrypted_user_profile_keys.clone())

Copy link
Collaborator

@boxdot boxdot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice. I added minor comments and a small comment about storage to initiate a discussion.

friendship_token BLOB NOT NULL,
key_package_ear_key BLOB NOT NULL,
connection_key BLOB NOT NULL,
user_profile_key BLOB NOT NULL,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change is not needed since contacts is recreated in the next migration.


impl CoreUser {
pub async fn update_user_profile(&self, user_profile: &UserProfile) -> anyhow::Result<()> {
let mut notifier = self.store_notifier();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

notifier.notify() has to be called at some point, otherwise the notification is dropped. Here, it seems to make sense to call it after phase 1.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, thanks!

group_info,
ratchet_tree,
encrypted_identity_link_keys,
encrypted_user_profile_keys,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NB: Looks like I overlooked versioning of responses of DS

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I created an issue: #415.

pub(super) client_queue_config: QsReference,
pub(super) activity_time: TimeStamp,
pub(super) activity_epoch: GroupEpoch,
pub(super) encrypted_user_profile_key: EncryptedUserProfileKey,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will break all groups deserialization on the server, right? Asking just to make sure that we are aware of it.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that's true.

pub(crate) fn decrypt(
wrapper_key: &IdentityLinkWrapperKey,
encrypted_key: &EncryptedUserProfileKey,
user_name: QualifiedUserName,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Technically, there is no need to own this value, because key derivation only needs access by ref to it.

I attached a small exercise in lifetime below. Feel free to apply it or not.

diff --git a/coreclient/src/clients/create_user.rs b/coreclient/src/clients/create_user.rs
index 0073f1f..7438fa0 100644
--- a/coreclient/src/clients/create_user.rs
+++ b/coreclient/src/clients/create_user.rs
@@ -288,7 +288,7 @@ impl PostRegistrationInitState {
             qs_client_id_encryption_key: qs_encryption_key,
         };

-        let user_profile_key = UserProfileKey::random(user_name.clone())?;
+        let user_profile_key = UserProfileKey::random(user_name)?;
         user_profile_key
             .store_own(pool.acquire().await?.as_mut())
             .await?;
diff --git a/coreclient/src/clients/process/process_qs.rs b/coreclient/src/clients/process/process_qs.rs
index 9e33953..b990326 100644
--- a/coreclient/src/clients/process/process_qs.rs
+++ b/coreclient/src/clients/process/process_qs.rs
@@ -315,7 +315,7 @@ impl CoreUser {

             let user_profile_key = UserProfileKey::from_base_secret(
                 friendship_package.user_profile_base_secret.clone(),
-                user_name,
+                &user_name,
             )?;

             // UnconfirmedConnection Phase 2: Fetch the user profile.
diff --git a/coreclient/src/contacts/mod.rs b/coreclient/src/contacts/mod.rs
index da89424..683b99e 100644
--- a/coreclient/src/contacts/mod.rs
+++ b/coreclient/src/contacts/mod.rs
@@ -58,7 +58,7 @@ impl Contact {
     ) -> Result<(Self, UserProfileKey), LibraryError> {
         let user_profile_key = UserProfileKey::from_base_secret(
             friendship_package.user_profile_base_secret,
-            client_id.user_name().clone(),
+            client_id.user_name(),
         )?;
         let contact = Self {
             user_name: client_id.user_name().clone(),
diff --git a/coreclient/src/groups/mod.rs b/coreclient/src/groups/mod.rs
index 4557adc..4bba393 100644
--- a/coreclient/src/groups/mod.rs
+++ b/coreclient/src/groups/mod.rs
@@ -412,7 +412,7 @@ impl Group {
                 UserProfileKey::decrypt(
                     welcome_attribution_info.identity_link_wrapper_key(),
                     &eupk,
-                    ci.user_name().clone(),
+                    ci.user_name(),
                 )
                 .map(|user_profile_key| ProfileInfo {
                     user_profile_key,
@@ -530,11 +530,12 @@ impl Group {
                     .map(|client_auth_info| client_auth_info.client_credential().identity()),
             )
             .map(|(eupk, ci)| {
-                UserProfileKey::decrypt(&identity_link_wrapper_key, &eupk, ci.user_name().clone())
-                    .map(|user_profile_key| ProfileInfo {
+                UserProfileKey::decrypt(&identity_link_wrapper_key, &eupk, ci.user_name()).map(
+                    |user_profile_key| ProfileInfo {
                         user_profile_key,
                         member_id: ci.clone(),
-                    })
+                    },
+                )
             })
             .collect::<Result<Vec<_>, _>>()?;

diff --git a/coreclient/src/key_stores/indexed_keys.rs b/coreclient/src/key_stores/indexed_keys.rs
index a5fba24..50c417a 100644
--- a/coreclient/src/key_stores/indexed_keys.rs
+++ b/coreclient/src/key_stores/indexed_keys.rs
@@ -27,7 +27,7 @@ use tracing::error;

 // The `LABEL` constant is used to identify the key type in the database.
 pub(crate) trait KeyType {
-    type DerivationContext: tls_codec::Serialize + Clone;
+    type DerivationContext<'a>: tls_codec::Serialize + Clone;

     const LABEL: &'static str;
 }
@@ -169,16 +169,20 @@ impl<KT, ST, const LENGTH: usize> From<Secret<LENGTH>> for TypedSecret<KT, ST, L
 impl<KT, ST, const LENGTH: usize> TypedSecret<KT, ST, LENGTH> {}

 #[derive(TlsSerialize, TlsSize)]
-struct DerivationContext<KT: KeyType> {
-    context: KT::DerivationContext,
+struct DerivationContext<'a, KT: KeyType> {
+    context: KT::DerivationContext<'a>,
     key_type_instance: KeyTypeInstance<KT>,
 }

-impl<KT: KeyType> KdfDerivable<BaseSecret<KT>, DerivationContext<KT>, AEAD_KEY_SIZE> for Key<KT> {
+impl<KT: KeyType> KdfDerivable<BaseSecret<KT>, DerivationContext<'_, KT>, AEAD_KEY_SIZE>
+    for Key<KT>
+{
     const LABEL: &'static str = "key";
 }

-impl<KT: KeyType> KdfDerivable<BaseSecret<KT>, DerivationContext<KT>, AEAD_KEY_SIZE> for Index<KT> {
+impl<KT: KeyType> KdfDerivable<BaseSecret<KT>, DerivationContext<'_, KT>, AEAD_KEY_SIZE>
+    for Index<KT>
+{
     const LABEL: &'static str = "index";
 }

@@ -194,7 +198,7 @@ pub(crate) struct IndexedAeadKey<KT> {
 impl<KT: KeyType> IndexedAeadKey<KT> {
     pub(crate) fn from_base_secret(
         base_secret: BaseSecret<KT>,
-        context: KT::DerivationContext,
+        context: KT::DerivationContext<'_>,
     ) -> Result<Self, LibraryError> {
         let derive_context = DerivationContext {
             context,
@@ -234,7 +238,7 @@ impl<KT> EarKey for IndexedAeadKey<KT> {}
 pub(crate) struct UserProfileKeyType;

 impl KeyType for UserProfileKeyType {
-    type DerivationContext = QualifiedUserName;
+    type DerivationContext<'a> = &'a QualifiedUserName;

     const LABEL: &'static str = "user_profile_key";
 }
@@ -246,7 +250,7 @@ pub(crate) type UserProfileBaseSecret = BaseSecret<UserProfileKeyType>;
 pub(crate) type UserProfileKey = IndexedAeadKey<UserProfileKeyType>;

 impl UserProfileKey {
-    pub(crate) fn random(user_name: QualifiedUserName) -> Result<Self, RandomnessError> {
+    pub(crate) fn random(user_name: &QualifiedUserName) -> Result<Self, RandomnessError> {
         let base_secret = BaseSecret::random()?;
         Self::from_base_secret(base_secret, user_name).map_err(|e| {
             error!(error = %e, "Key derivation error");
@@ -264,7 +268,7 @@ impl UserProfileKey {
     pub(crate) fn decrypt(
         wrapper_key: &IdentityLinkWrapperKey,
         encrypted_key: &EncryptedUserProfileKey,
-        user_name: QualifiedUserName,
+        user_name: &QualifiedUserName,
     ) -> Result<Self, DecryptionError> {
         let base_secret = BaseSecret::decrypt(wrapper_key, encrypted_key)?;
         Self::from_base_secret(base_secret, user_name).map_err(|e| {

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I spent about half an hour trying to do that, but got tangled up in lifetime bound issues. You approach works, thanks!

) -> Result<(PseudonymousCredentialPlaintext, IdentityLinkKey), IdentityLinkVerificationError>
{
let identity_link_key = IdentityLinkKey::derive(connection_key, self.tbs.clone())?;
let identity_link_key = IdentityLinkKey::derive(connection_key, &self.tbs)?;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! (here and in other places)

voprf_server BYTEA NOT NULL
);

CREATE TYPE aead_ciphertext AS (
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NB: This change will require wiping the server db.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. I'll try to remember that when merging.

) -> Result<GetUserProfileResponse, GetUserProfileError> {
let GetUserProfileParams { client_id } = params;

let user_record = UserRecord::load(&self.db_pool, client_id.user_name())
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a note: We could also store the encrypted user profile in S3-like storage. However, the question under which key. This could be the hashed client_id, but then only a single profile per user can be stored. Otherwise, it should be possible to use the encrypted profile key for that. Maybe we could discuss it as a possible future change.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the plan is to compromise here and store larger things like the profile picture on S3 and then store on the AS DB only a struct containing things like the S3 URL, key, nonce, etc. We can do that when we also do attachments/assets.

) -> Result<(), StorageError> {
let password_file = BlobEncoded(&self.password_file);
query!(
"UPDATE as_user_records SET password_file = $1, encrypted_user_profile = $2 WHERE user_name = $3",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Line too long.

let added_users = staged_commit
.add_proposals()
.map(|ap| ap.add_proposal().key_package().clone())
.zip(aad_payload.new_encrypted_identity_link_keys.clone())
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is possible to take aad_payload by value to avoid cloning both vectors.

Copy link
Contributor

@raphaelrobert raphaelrobert left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks good at a conceptual level.

@kkohbrok kkohbrok enabled auto-merge (squash) April 14, 2025 15:01
@kkohbrok kkohbrok merged commit 77e7a5a into main Apr 14, 2025
15 of 16 checks passed
@kkohbrok kkohbrok deleted the konrad/as_user_profiles branch April 14, 2025 15:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants