Skip to content
This repository was archived by the owner on Sep 10, 2024. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions crates/graphql/src/mutations/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.

use anyhow::Context as _;
use async_graphql::{Context, Description, Enum, InputObject, Object, ID};
use mas_storage::{
job::{DeactivateUserJob, JobRepositoryExt, ProvisionUserJob},
Expand All @@ -22,6 +23,7 @@ use tracing::info;
use crate::{
model::{NodeType, User},
state::ContextExt,
UserId,
};

#[derive(Default)]
Expand Down Expand Up @@ -157,6 +159,34 @@ impl SetCanRequestAdminPayload {
}
}

/// The input for the `allowUserCrossSigningReset` mutation.
#[derive(InputObject)]
struct AllowUserCrossSigningResetInput {
/// The ID of the user to update.
user_id: ID,
}

/// The payload for the `allowUserCrossSigningReset` mutation.
#[derive(Description)]
enum AllowUserCrossSigningResetPayload {
/// The user was updated.
Allowed(mas_data_model::User),

/// The user was not found.
NotFound,
}

#[Object(use_type_description)]
impl AllowUserCrossSigningResetPayload {
/// The user that was updated.
async fn user(&self) -> Option<User> {
match self {
Self::Allowed(user) => Some(User(user.clone())),
Self::NotFound => None,
}
}
}

fn valid_username_character(c: char) -> bool {
c.is_ascii_lowercase()
|| c.is_ascii_digit()
Expand Down Expand Up @@ -296,4 +326,36 @@ impl UserMutations {

Ok(SetCanRequestAdminPayload::Updated(user))
}

/// Temporarily allow user to reset their cross-signing keys.
async fn allow_user_cross_signing_reset(
&self,
ctx: &Context<'_>,
input: AllowUserCrossSigningResetInput,
) -> Result<AllowUserCrossSigningResetPayload, async_graphql::Error> {
let state = ctx.state();
let user_id = NodeType::User.extract_ulid(&input.user_id)?;
let requester = ctx.requester();

if !requester.is_owner_or_admin(&UserId(user_id)) {
return Err(async_graphql::Error::new("Unauthorized"));
}

let mut repo = state.repository().await?;
let user = repo.user().lookup(user_id).await?;
repo.cancel().await?;

let Some(user) = user else {
return Ok(AllowUserCrossSigningResetPayload::NotFound);
};

let conn = state.homeserver_connection();
let mxid = conn.mxid(&user.username);

conn.allow_cross_signing_reset(&mxid)
.await
.context("Failed to allow cross-signing reset")?;

Ok(AllowUserCrossSigningResetPayload::Allowed(user))
}
}
36 changes: 36 additions & 0 deletions crates/matrix-synapse/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,9 @@ struct SynapseDeactivateUserRequest {
erase: bool,
}

#[derive(Serialize)]
struct SynapseAllowCrossSigningResetRequest {}

#[async_trait::async_trait]
impl HomeserverConnection for SynapseConnection {
type Error = anyhow::Error;
Expand Down Expand Up @@ -366,4 +369,37 @@ impl HomeserverConnection for SynapseConnection {
async fn unset_displayname(&self, mxid: &str) -> Result<(), Self::Error> {
self.set_displayname(mxid, "").await
}

#[tracing::instrument(
name = "homeserver.allow_cross_signing_reset",
skip_all,
fields(
matrix.homeserver = self.homeserver,
matrix.mxid = mxid,
),
err(Display),
)]
async fn allow_cross_signing_reset(&self, mxid: &str) -> Result<(), Self::Error> {
let mut client = self
.http_client_factory
.client("homeserver.allow_cross_signing_reset")
.request_bytes_to_body()
.json_request();

let request = self
.post(&format!(
"_synapse/admin/v1/users/{mxid}/_allow_cross_signing_replacement_without_uia"
))
.body(SynapseAllowCrossSigningResetRequest {})?;

let response = client.ready().await?.call(request).await?;

if response.status() != StatusCode::OK {
return Err(anyhow::anyhow!(
"Failed to allow cross signing reset in Synapse"
));
}

Ok(())
}
}
16 changes: 16 additions & 0 deletions crates/matrix/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,18 @@ pub trait HomeserverConnection: Send + Sync {
/// Returns an error if the homeserver is unreachable or the displayname
/// could not be unset.
async fn unset_displayname(&self, mxid: &str) -> Result<(), Self::Error>;

/// Temporarily allow a user to reset their cross-signing keys.
///
/// # Parameters
///
/// * `mxid` - The Matrix ID of the user to allow cross-signing key reset
///
/// # Errors
///
/// Returns an error if the homeserver is unreachable or the cross-signing
/// reset could not be allowed.
async fn allow_cross_signing_reset(&self, mxid: &str) -> Result<(), Self::Error>;
}

#[async_trait::async_trait]
Expand Down Expand Up @@ -319,4 +331,8 @@ impl<T: HomeserverConnection + Send + Sync + ?Sized> HomeserverConnection for &T
async fn unset_displayname(&self, mxid: &str) -> Result<(), Self::Error> {
(**self).unset_displayname(mxid).await
}

async fn allow_cross_signing_reset(&self, mxid: &str) -> Result<(), Self::Error> {
(**self).allow_cross_signing_reset(mxid).await
}
}
9 changes: 9 additions & 0 deletions crates/matrix/src/mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ struct MockUser {
displayname: Option<String>,
devices: HashSet<String>,
emails: Option<Vec<String>>,
cross_signing_reset_allowed: bool,
}

/// A mock implementation of a [`HomeserverConnection`], which never fails and
Expand Down Expand Up @@ -74,6 +75,7 @@ impl crate::HomeserverConnection for HomeserverConnection {
displayname: None,
devices: HashSet::new(),
emails: None,
cross_signing_reset_allowed: false,
});

anyhow::ensure!(
Expand Down Expand Up @@ -136,6 +138,13 @@ impl crate::HomeserverConnection for HomeserverConnection {
user.displayname = None;
Ok(())
}

async fn allow_cross_signing_reset(&self, mxid: &str) -> Result<(), Self::Error> {
let mut users = self.users.write().await;
let user = users.get_mut(mxid).context("User not found")?;
user.cross_signing_reset_allowed = true;
Ok(())
}
}

#[cfg(test)]
Expand Down
13 changes: 13 additions & 0 deletions frontend/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,19 @@
"pagination_controls": {
"total": "Total: {{totalCount}}"
},
"reset_cross_signing": {
"button": "Allow crypto identity reset",
"description": "If you are not signed in anywhere else, and have forgotten or lost all recovery options you’ll need to reset your crypto identity. This means you will lose your existing message history, other users will see that you have reset your identity and you will need to verify your existing devices again.",
"failure": {
"description": "This might be a temporary problem, so please try again later. If the problem persists, please contact your server administrator.",
"title": "Failed to allow crypto identity"
},
"heading": "Reset crypto identity",
"success": {
"description": "A client can now temporarily reset your account crypto identity. Follow the instructions in your client to complete the process.",
"title": "Crypto identity reset temporarily allowed"
}
},
"selectable_session": {
"label": "Select session"
},
Expand Down
26 changes: 26 additions & 0 deletions frontend/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,26 @@ enum AddUserStatus {
INVALID
}

"""
The input for the `allowUserCrossSigningReset` mutation.
"""
input AllowUserCrossSigningResetInput {
"""
The ID of the user to update.
"""
userId: ID!
}

"""
The payload for the `allowUserCrossSigningReset` mutation.
"""
type AllowUserCrossSigningResetPayload {
"""
The user that was updated.
"""
user: User
}

type Anonymous implements Node {
id: ID!
}
Expand Down Expand Up @@ -650,6 +670,12 @@ type Mutation {
input: SetCanRequestAdminInput!
): SetCanRequestAdminPayload!
"""
Temporarily allow user to reset their cross-signing keys.
"""
allowUserCrossSigningReset(
input: AllowUserCrossSigningResetInput!
): AllowUserCrossSigningResetPayload!
"""
Create a new arbitrary OAuth 2.0 Session.

Only available for administrators.
Expand Down
95 changes: 95 additions & 0 deletions frontend/src/components/UserProfile/CrossSigningReset.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// Copyright 2023 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import { Alert, Button, H3, Text } from "@vector-im/compound-web";
import { atom, useAtom } from "jotai";
import { atomFamily } from "jotai/utils";
import { atomWithMutation } from "jotai-urql";
import { useState } from "react";
import { useTranslation } from "react-i18next";

import { graphql } from "../../gql";
import BlockList from "../BlockList";
import LoadingSpinner from "../LoadingSpinner";

const ALLOW_CROSS_SIGING_RESET_MUTATION = graphql(/* GraphQL */ `
mutation AllowCrossSigningReset($userId: ID!) {
allowUserCrossSigningReset(input: { userId: $userId }) {
user {
id
}
}
}
`);

const allowCrossSigningResetFamily = atomFamily((id: string) => {
const allowCrossSigingReset = atomWithMutation(
ALLOW_CROSS_SIGING_RESET_MUTATION,
);

// A proxy atom which pre-sets the id variable in the mutation
const allowCrossSigningResetAtom = atom(
(get) => get(allowCrossSigingReset),
(_get, set) => set(allowCrossSigingReset, { userId: id }),
);

return allowCrossSigningResetAtom;
});

const CrossSigningReset: React.FC<{ userId: string }> = ({ userId }) => {
const { t } = useTranslation();
const [result, allowReset] = useAtom(allowCrossSigningResetFamily(userId));
const [inProgress, setInProgress] = useState(false);

const onClick = (): void => {
if (inProgress) return;
setInProgress(true);
allowReset().finally(() => setInProgress(false));
};

return (
<BlockList>
<H3>{t("frontend.reset_cross_signing.heading")}</H3>
{!result.data && !result.error && (
<>
<Text className="text-justify">
{t("frontend.reset_cross_signing.description")}
</Text>
<Button kind="destructive" disabled={inProgress} onClick={onClick}>
{!!inProgress && <LoadingSpinner inline />}
{t("frontend.reset_cross_signing.button")}
</Button>
</>
)}
{result.data && (
<Alert
type="info"
title={t("frontend.reset_cross_signing.success.title")}
>
{t("frontend.reset_cross_signing.success.description")}
</Alert>
)}
{result.error && (
<Alert
type="critical"
title={t("frontend.reset_cross_signing.failure.title")}
>
{t("frontend.reset_cross_signing.failure.description")}
</Alert>
)}
</BlockList>
);
};

export default CrossSigningReset;
5 changes: 5 additions & 0 deletions frontend/src/components/UserProfile/UserProfile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import { Separator } from "@vector-im/compound-web";

import BlockList from "../BlockList/BlockList";

import CrossSigningReset from "./CrossSigningReset";
import UserEmailList from "./UserEmailList";
import UserName from "./UserName";

Expand All @@ -22,6 +25,8 @@ const UserProfile: React.FC<{ userId: string }> = ({ userId }) => {
<BlockList>
<UserName userId={userId} />
<UserEmailList userId={userId} />
<Separator />
<CrossSigningReset userId={userId} />
</BlockList>
);
};
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/gql/gql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ const documents = {
types.UserGreetingDocument,
"\n mutation AddEmail($userId: ID!, $email: String!) {\n addEmail(input: { userId: $userId, email: $email }) {\n status\n violations\n email {\n id\n ...UserEmail_email\n }\n }\n }\n":
types.AddEmailDocument,
"\n mutation AllowCrossSigningReset($userId: ID!) {\n allowUserCrossSigningReset(input: { userId: $userId }) {\n user {\n id\n }\n }\n }\n":
types.AllowCrossSigningResetDocument,
"\n query UserEmailListQuery(\n $userId: ID!\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\n\n emails(first: $first, after: $after, last: $last, before: $before) {\n edges {\n cursor\n node {\n id\n ...UserEmail_email\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n":
types.UserEmailListQueryDocument,
"\n query UserPrimaryEmail($userId: ID!) {\n user(id: $userId) {\n id\n primaryEmail {\n id\n }\n }\n }\n":
Expand Down Expand Up @@ -213,6 +215,12 @@ export function graphql(
export function graphql(
source: "\n mutation AddEmail($userId: ID!, $email: String!) {\n addEmail(input: { userId: $userId, email: $email }) {\n status\n violations\n email {\n id\n ...UserEmail_email\n }\n }\n }\n",
): (typeof documents)["\n mutation AddEmail($userId: ID!, $email: String!) {\n addEmail(input: { userId: $userId, email: $email }) {\n status\n violations\n email {\n id\n ...UserEmail_email\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: "\n mutation AllowCrossSigningReset($userId: ID!) {\n allowUserCrossSigningReset(input: { userId: $userId }) {\n user {\n id\n }\n }\n }\n",
): (typeof documents)["\n mutation AllowCrossSigningReset($userId: ID!) {\n allowUserCrossSigningReset(input: { userId: $userId }) {\n user {\n id\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
Expand Down
Loading