crypto: Allow secrets to be requested and imported
parent
e57d70b089
commit
68df9b6ed2
|
@ -48,7 +48,7 @@ use crate::{
|
||||||
olm::{InboundGroupSession, Session, ShareState},
|
olm::{InboundGroupSession, Session, ShareState},
|
||||||
requests::{OutgoingRequest, ToDeviceRequest},
|
requests::{OutgoingRequest, ToDeviceRequest},
|
||||||
session_manager::GroupSessionCache,
|
session_manager::GroupSessionCache,
|
||||||
store::{Changes, CryptoStoreError, Store},
|
store::{Changes, CryptoStoreError, SecretImportError, Store},
|
||||||
Device,
|
Device,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -230,6 +230,16 @@ impl From<SecretName> for SecretInfo {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OutgoingKeyRequest {
|
impl OutgoingKeyRequest {
|
||||||
|
/// Create an ougoing secret request for the given secret.
|
||||||
|
pub(crate) fn from_secret_name(own_user_id: UserId, secret_name: SecretName) -> Self {
|
||||||
|
Self {
|
||||||
|
request_recipient: own_user_id,
|
||||||
|
request_id: Uuid::new_v4(),
|
||||||
|
info: secret_name.into(),
|
||||||
|
sent_out: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn request_type(&self) -> &str {
|
fn request_type(&self) -> &str {
|
||||||
match &self.info {
|
match &self.info {
|
||||||
SecretInfo::KeyRequest(_) => "m.room_key_request",
|
SecretInfo::KeyRequest(_) => "m.room_key_request",
|
||||||
|
@ -815,34 +825,22 @@ impl KeyRequestMachine {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
/// Create outgoing secret requests for the given
|
||||||
pub async fn request_missing_secrets(&self) -> Result<Vec<OutgoingRequest>, CryptoStoreError> {
|
pub fn request_missing_secrets(
|
||||||
let secret_names = self.store.get_missing_secrets().await;
|
own_user_id: &UserId,
|
||||||
|
secret_names: Vec<SecretName>,
|
||||||
Ok(if secret_names.is_empty() {
|
) -> Vec<OutgoingKeyRequest> {
|
||||||
|
if !secret_names.is_empty() {
|
||||||
info!(secret_names =? secret_names, "Creating new outgoing secret requests");
|
info!(secret_names =? secret_names, "Creating new outgoing secret requests");
|
||||||
|
|
||||||
let requests: Vec<OutgoingKeyRequest> = secret_names
|
secret_names
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|n| OutgoingKeyRequest {
|
.map(|n| OutgoingKeyRequest::from_secret_name(own_user_id.to_owned(), n))
|
||||||
request_recipient: self.user_id().to_owned(),
|
.collect()
|
||||||
request_id: Uuid::new_v4(),
|
|
||||||
info: n.into(),
|
|
||||||
sent_out: false,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let outgoing_requests =
|
|
||||||
requests.iter().map(|r| r.to_request(self.device_id())).collect();
|
|
||||||
|
|
||||||
let changes = Changes { key_requests: requests, ..Default::default() };
|
|
||||||
self.store.save_changes(changes).await?;
|
|
||||||
|
|
||||||
outgoing_requests
|
|
||||||
} else {
|
} else {
|
||||||
trace!("No secrets are missing from our store, not requesting them");
|
trace!("No secrets are missing from our store, not requesting them");
|
||||||
vec![]
|
vec![]
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn request_key_helper(
|
async fn request_key_helper(
|
||||||
|
@ -972,6 +970,94 @@ impl KeyRequestMachine {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn receive_secret(
|
||||||
|
&self,
|
||||||
|
sender_key: &str,
|
||||||
|
event: &mut ToDeviceEvent<SecretSendEventContent>,
|
||||||
|
) -> Result<Option<AnyToDeviceEvent>, CryptoStoreError> {
|
||||||
|
debug!(
|
||||||
|
sender = event.sender.as_str(),
|
||||||
|
request_id = event.content.request_id.as_str(),
|
||||||
|
"Received a m.secret.send event"
|
||||||
|
);
|
||||||
|
|
||||||
|
let request_id = if let Ok(r) = Uuid::parse_str(&event.content.request_id) {
|
||||||
|
r
|
||||||
|
} else {
|
||||||
|
warn!("Received a m.secret.send event but the request ID is invalid");
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(request) = self.store.get_outgoing_secret_requests(request_id).await? {
|
||||||
|
match &request.info {
|
||||||
|
SecretInfo::KeyRequest(_) => {
|
||||||
|
warn!(
|
||||||
|
sender = event.sender.as_str(),
|
||||||
|
request_id = event.content.request_id.as_str(),
|
||||||
|
"Received a m.secret.send event but the request was for a room key"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
SecretInfo::SecretRequest(secret_name) => {
|
||||||
|
debug!(
|
||||||
|
sender = event.sender.as_str(),
|
||||||
|
request_id = event.content.request_id.as_str(),
|
||||||
|
secret_name = secret_name.as_ref(),
|
||||||
|
"Received a m.secret.send event with a matching request"
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(device) =
|
||||||
|
self.store.get_device_from_curve_key(&event.sender, sender_key).await?
|
||||||
|
{
|
||||||
|
if device.verified() {
|
||||||
|
match self
|
||||||
|
.store
|
||||||
|
.import_secret(
|
||||||
|
&secret_name,
|
||||||
|
std::mem::take(&mut event.content.secret),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => self.mark_as_done(request).await?,
|
||||||
|
Err(e) => {
|
||||||
|
// If this is a store error propagate it up
|
||||||
|
// the call stack.
|
||||||
|
if let SecretImportError::Store(e) = e {
|
||||||
|
return Err(e);
|
||||||
|
} else {
|
||||||
|
// Otherwise warn that there was
|
||||||
|
// something wrong with the secret.
|
||||||
|
warn!(
|
||||||
|
secret_name = secret_name.as_ref(),
|
||||||
|
error =? e,
|
||||||
|
"Error while importing a secret"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
warn!(
|
||||||
|
sender = event.sender.as_str(),
|
||||||
|
request_id = event.content.request_id.as_str(),
|
||||||
|
secret_name = secret_name.as_ref(),
|
||||||
|
"Received a m.secret.send event from an unverified device"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
warn!(
|
||||||
|
sender = event.sender.as_str(),
|
||||||
|
request_id = event.content.request_id.as_str(),
|
||||||
|
secret_name = secret_name.as_ref(),
|
||||||
|
"Received a m.secret.send event from an unknown device"
|
||||||
|
);
|
||||||
|
self.store.update_tracked_user(&event.sender, true).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Some(AnyToDeviceEvent::SecretSend(event.clone())))
|
||||||
|
}
|
||||||
|
|
||||||
/// Receive a forwarded room key event.
|
/// Receive a forwarded room key event.
|
||||||
pub async fn receive_forwarded_room_key(
|
pub async fn receive_forwarded_room_key(
|
||||||
&self,
|
&self,
|
||||||
|
|
|
@ -706,6 +706,10 @@ impl OlmMachine {
|
||||||
.key_request_machine
|
.key_request_machine
|
||||||
.receive_forwarded_room_key(&decrypted.sender_key, &mut e)
|
.receive_forwarded_room_key(&decrypted.sender_key, &mut e)
|
||||||
.await?),
|
.await?),
|
||||||
|
AnyToDeviceEvent::SecretSend(mut e) => Ok((
|
||||||
|
self.key_request_machine.receive_secret(&decrypted.sender_key, &mut e).await?,
|
||||||
|
None,
|
||||||
|
)),
|
||||||
_ => {
|
_ => {
|
||||||
warn!(event_type =? event.event_type(), "Received an unexpected encrypted to-device event");
|
warn!(event_type =? event.event_type(), "Received an unexpected encrypted to-device event");
|
||||||
Ok((Some(event), None))
|
Ok((Some(event), None))
|
||||||
|
|
|
@ -35,7 +35,8 @@ use serde_json::Error as JsonError;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
error::SignatureError, identities::MasterPubkey, requests::UploadSigningKeysRequest,
|
error::SignatureError, identities::MasterPubkey, requests::UploadSigningKeysRequest,
|
||||||
ReadOnlyAccount, ReadOnlyDevice, ReadOnlyOwnUserIdentity, ReadOnlyUserIdentity,
|
store::SecretImportError, utilities::decode, OwnUserIdentity, ReadOnlyAccount, ReadOnlyDevice,
|
||||||
|
ReadOnlyOwnUserIdentity, ReadOnlyUserIdentity,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Private cross signing identity.
|
/// Private cross signing identity.
|
||||||
|
@ -135,6 +136,49 @@ impl PrivateCrossSigningIdentity {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn import_secret(
|
||||||
|
&self,
|
||||||
|
public_identity: OwnUserIdentity,
|
||||||
|
secret_name: &SecretName,
|
||||||
|
seed: String,
|
||||||
|
) -> Result<(), SecretImportError> {
|
||||||
|
let seed = decode(seed)?;
|
||||||
|
|
||||||
|
match secret_name {
|
||||||
|
SecretName::CrossSigningMasterKey => {
|
||||||
|
let master = MasterSigning::from_seed(self.user_id().clone(), seed);
|
||||||
|
|
||||||
|
if public_identity.master_key() == &master.public_key {
|
||||||
|
*self.master_key.lock().await = Some(master);
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(SecretImportError::MissmatchedPublicKeys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SecretName::CrossSigningUserSigningKey => {
|
||||||
|
let subkey = UserSigning::from_seed(self.user_id().clone(), seed);
|
||||||
|
|
||||||
|
if public_identity.user_signing_key() == &subkey.public_key {
|
||||||
|
*self.user_signing_key.lock().await = Some(subkey);
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(SecretImportError::MissmatchedPublicKeys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SecretName::CrossSigningSelfSigningKey => {
|
||||||
|
let subkey = SelfSigning::from_seed(self.user_id().clone(), seed);
|
||||||
|
|
||||||
|
if public_identity.self_signing_key() == &subkey.public_key {
|
||||||
|
*self.self_signing_key.lock().await = Some(subkey);
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(SecretImportError::MissmatchedPublicKeys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the names of the secrets we are missing.
|
/// Get the names of the secrets we are missing.
|
||||||
pub(crate) async fn get_missing_secrets(&self) -> Vec<SecretName> {
|
pub(crate) async fn get_missing_secrets(&self) -> Vec<SecretName> {
|
||||||
let mut missing = Vec::new();
|
let mut missing = Vec::new();
|
||||||
|
|
|
@ -146,6 +146,13 @@ impl MasterSigning {
|
||||||
encode(self.inner.seed.as_slice())
|
encode(self.inner.seed.as_slice())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn from_seed(user_id: UserId, seed: Vec<u8>) -> Self {
|
||||||
|
let inner = Signing::from_seed(seed);
|
||||||
|
let public_key = inner.cross_signing_key(user_id, KeyUsage::Master).into();
|
||||||
|
|
||||||
|
Self { inner, public_key }
|
||||||
|
}
|
||||||
|
|
||||||
pub fn from_pickle(
|
pub fn from_pickle(
|
||||||
pickle: PickledMasterSigning,
|
pickle: PickledMasterSigning,
|
||||||
pickle_key: &[u8],
|
pickle_key: &[u8],
|
||||||
|
@ -192,6 +199,13 @@ impl UserSigning {
|
||||||
encode(self.inner.seed.as_slice())
|
encode(self.inner.seed.as_slice())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn from_seed(user_id: UserId, seed: Vec<u8>) -> Self {
|
||||||
|
let inner = Signing::from_seed(seed);
|
||||||
|
let public_key = inner.cross_signing_key(user_id, KeyUsage::UserSigning).into();
|
||||||
|
|
||||||
|
Self { inner, public_key }
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn sign_user(
|
pub async fn sign_user(
|
||||||
&self,
|
&self,
|
||||||
user: &ReadOnlyUserIdentity,
|
user: &ReadOnlyUserIdentity,
|
||||||
|
@ -237,6 +251,13 @@ impl SelfSigning {
|
||||||
encode(self.inner.seed.as_slice())
|
encode(self.inner.seed.as_slice())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn from_seed(user_id: UserId, seed: Vec<u8>) -> Self {
|
||||||
|
let inner = Signing::from_seed(seed);
|
||||||
|
let public_key = inner.cross_signing_key(user_id, KeyUsage::SelfSigning).into();
|
||||||
|
|
||||||
|
Self { inner, public_key }
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn sign_device_helper(&self, value: Value) -> Result<Signature, SignatureError> {
|
pub async fn sign_device_helper(&self, value: Value) -> Result<Signature, SignatureError> {
|
||||||
self.inner.sign_json(value).await
|
self.inner.sign_json(value).await
|
||||||
}
|
}
|
||||||
|
|
|
@ -36,11 +36,7 @@ fn encode_key_info(info: &SecretInfo) -> String {
|
||||||
SecretInfo::KeyRequest(info) => {
|
SecretInfo::KeyRequest(info) => {
|
||||||
format!("{}{}{}{}", info.room_id, info.sender_key, info.algorithm, info.session_id)
|
format!("{}{}{}{}", info.room_id, info.sender_key, info.algorithm, info.session_id)
|
||||||
}
|
}
|
||||||
SecretInfo::SecretRequest(i) => {
|
SecretInfo::SecretRequest(i) => i.as_ref().to_owned(),
|
||||||
// TODO don't use serde here, use `as_ref()` when it becomes
|
|
||||||
// available
|
|
||||||
serde_json::to_string(i).expect("Can't serialize secret name")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -51,6 +51,7 @@ use std::{
|
||||||
sync::Arc,
|
sync::Arc,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use base64::DecodeError;
|
||||||
use matrix_sdk_common::{async_trait, locks::Mutex, uuid::Uuid, AsyncTraitDeps};
|
use matrix_sdk_common::{async_trait, locks::Mutex, uuid::Uuid, AsyncTraitDeps};
|
||||||
pub use memorystore::MemoryStore;
|
pub use memorystore::MemoryStore;
|
||||||
use olm_rs::errors::{OlmAccountError, OlmGroupSessionError, OlmSessionError};
|
use olm_rs::errors::{OlmAccountError, OlmGroupSessionError, OlmSessionError};
|
||||||
|
@ -61,7 +62,7 @@ use ruma::{
|
||||||
};
|
};
|
||||||
use serde_json::Error as SerdeError;
|
use serde_json::Error as SerdeError;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tracing::warn;
|
use tracing::{info, warn};
|
||||||
|
|
||||||
#[cfg(feature = "sled_cryptostore")]
|
#[cfg(feature = "sled_cryptostore")]
|
||||||
pub use self::sled::SledStore;
|
pub use self::sled::SledStore;
|
||||||
|
@ -134,6 +135,23 @@ impl DeviceChanges {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub(crate) enum SecretImportError {
|
||||||
|
/// The seed for the private key wasn't valid base64.
|
||||||
|
#[error(transparent)]
|
||||||
|
Base64(#[from] DecodeError),
|
||||||
|
/// The public key of the imported private key doesn't match to the public
|
||||||
|
/// key that was uploaded to the server.
|
||||||
|
#[error(
|
||||||
|
"The public key of the imported private key doesn't match to the \
|
||||||
|
public key that was uploaded to the server"
|
||||||
|
)]
|
||||||
|
MissmatchedPublicKeys,
|
||||||
|
/// The new version of the identity couldn't be stored.
|
||||||
|
#[error(transparent)]
|
||||||
|
Store(#[from] CryptoStoreError),
|
||||||
|
}
|
||||||
|
|
||||||
impl Store {
|
impl Store {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
user_id: Arc<UserId>,
|
user_id: Arc<UserId>,
|
||||||
|
@ -273,9 +291,39 @@ impl Store {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_missing_secrets(&self) -> Vec<SecretName> {
|
pub async fn import_secret(
|
||||||
// TODO add the backup key to our missing secrets
|
&self,
|
||||||
self.identity.lock().await.get_missing_secrets().await
|
secret_name: &SecretName,
|
||||||
|
secret: String,
|
||||||
|
) -> Result<(), SecretImportError> {
|
||||||
|
match secret_name {
|
||||||
|
SecretName::CrossSigningMasterKey
|
||||||
|
| SecretName::CrossSigningUserSigningKey
|
||||||
|
| SecretName::CrossSigningSelfSigningKey => {
|
||||||
|
if let Some(public_identity) =
|
||||||
|
self.get_identity(&self.user_id).await?.and_then(|i| i.own())
|
||||||
|
{
|
||||||
|
let identity = self.identity.lock().await;
|
||||||
|
|
||||||
|
identity.import_secret(public_identity, secret_name, secret).await?;
|
||||||
|
info!(
|
||||||
|
secret_name = secret_name.as_ref(),
|
||||||
|
"Successfully imported a private cross signing key"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut changes = Changes::default();
|
||||||
|
changes.private_identity = Some(identity.clone());
|
||||||
|
|
||||||
|
self.save_changes(changes).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SecretName::RecoveryKey => (),
|
||||||
|
name => {
|
||||||
|
warn!(secret =? name, "Tried to import an unknown secret");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -60,13 +60,7 @@ impl EncodeKey for Uuid {
|
||||||
|
|
||||||
impl EncodeKey for SecretName {
|
impl EncodeKey for SecretName {
|
||||||
fn encode(&self) -> Vec<u8> {
|
fn encode(&self) -> Vec<u8> {
|
||||||
[
|
[self.as_ref().as_bytes(), &[Self::SEPARATOR]].concat()
|
||||||
// TODO don't use serde here, use `as_ref()` when it becomes
|
|
||||||
// available
|
|
||||||
serde_json::to_string(self).expect("Can't serialize secret name").as_bytes(),
|
|
||||||
&[Self::SEPARATOR],
|
|
||||||
]
|
|
||||||
.concat()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -42,6 +42,7 @@ use tracing::{error, info, trace, warn};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
error::SignatureError,
|
error::SignatureError,
|
||||||
|
key_request::{KeyRequestMachine, OutgoingKeyRequest},
|
||||||
olm::PrivateCrossSigningIdentity,
|
olm::PrivateCrossSigningIdentity,
|
||||||
store::{Changes, CryptoStore},
|
store::{Changes, CryptoStore},
|
||||||
CryptoStoreError, LocalTrust, ReadOnlyDevice, ReadOnlyUserIdentities,
|
CryptoStoreError, LocalTrust, ReadOnlyDevice, ReadOnlyUserIdentities,
|
||||||
|
@ -351,6 +352,10 @@ impl IdentitiesBeingVerified {
|
||||||
return Ok(VerificationResult::Cancel(CancelCode::KeyMismatch));
|
return Ok(VerificationResult::Cancel(CancelCode::KeyMismatch));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let is_self_verification =
|
||||||
|
device.as_ref().map(|d| d.user_id() == self.user_id()).unwrap_or_default()
|
||||||
|
|| identity.as_ref().map(|i| i.own().is_some()).unwrap_or_default();
|
||||||
|
|
||||||
let mut changes = Changes::default();
|
let mut changes = Changes::default();
|
||||||
|
|
||||||
let signature_request = if let Some(device) = device {
|
let signature_request = if let Some(device) = device {
|
||||||
|
@ -361,7 +366,7 @@ impl IdentitiesBeingVerified {
|
||||||
Err(SignatureError::MissingSigningKey) => {
|
Err(SignatureError::MissingSigningKey) => {
|
||||||
warn!(
|
warn!(
|
||||||
"Can't sign the device keys for {} {}, \
|
"Can't sign the device keys for {} {}, \
|
||||||
no private user signing key found",
|
no private device signing key found",
|
||||||
device.user_id(),
|
device.user_id(),
|
||||||
device.device_id(),
|
device.device_id(),
|
||||||
);
|
);
|
||||||
|
@ -437,6 +442,11 @@ impl IdentitiesBeingVerified {
|
||||||
identity_signature_request
|
identity_signature_request
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if is_self_verification {
|
||||||
|
let secret_requests = self.request_missing_secrets().await;
|
||||||
|
changes.key_requests = secret_requests;
|
||||||
|
}
|
||||||
|
|
||||||
// TODO store the signature upload request as well.
|
// TODO store the signature upload request as well.
|
||||||
self.store.save_changes(changes).await?;
|
self.store.save_changes(changes).await?;
|
||||||
|
|
||||||
|
@ -445,6 +455,11 @@ impl IdentitiesBeingVerified {
|
||||||
.unwrap_or(VerificationResult::Ok))
|
.unwrap_or(VerificationResult::Ok))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn request_missing_secrets(&self) -> Vec<OutgoingKeyRequest> {
|
||||||
|
let secrets = self.private_identity.get_missing_secrets().await;
|
||||||
|
KeyRequestMachine::request_missing_secrets(self.user_id(), secrets)
|
||||||
|
}
|
||||||
|
|
||||||
async fn mark_identity_as_verified(
|
async fn mark_identity_as_verified(
|
||||||
&self,
|
&self,
|
||||||
verified_identities: Option<&[ReadOnlyUserIdentities]>,
|
verified_identities: Option<&[ReadOnlyUserIdentities]>,
|
||||||
|
|
Loading…
Reference in New Issue