feat: add E2E encryption module to meshservice
X25519 key agreement + HKDF-SHA256 + ChaCha20-Poly1305 AEAD for opt-in payload encryption. Each message uses a fresh ephemeral key for forward secrecy. 11 new tests cover roundtrip, wrong-key rejection, tampering, wire format integration, and edge cases.
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -3207,8 +3207,10 @@ name = "meshservice"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"chacha20poly1305",
|
||||||
"ciborium",
|
"ciborium",
|
||||||
"ed25519-dalek 2.2.0",
|
"ed25519-dalek 2.2.0",
|
||||||
|
"hkdf",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
"serde",
|
"serde",
|
||||||
"sha2 0.10.9",
|
"sha2 0.10.9",
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ ciborium = "0.2"
|
|||||||
ed25519-dalek = { version = "2.1", features = ["serde"] }
|
ed25519-dalek = { version = "2.1", features = ["serde"] }
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
x25519-dalek = "2.0"
|
x25519-dalek = { version = "2.0", features = ["static_secrets"] }
|
||||||
|
chacha20poly1305 = "0.10"
|
||||||
|
hkdf = "0.12"
|
||||||
|
|
||||||
# Async
|
# Async
|
||||||
tokio = { version = "1.36", features = ["sync", "time"] }
|
tokio = { version = "1.36", features = ["sync", "time"] }
|
||||||
|
|||||||
392
crates/meshservice/src/crypto.rs
Normal file
392
crates/meshservice/src/crypto.rs
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
//! End-to-end encryption for service message payloads.
|
||||||
|
//!
|
||||||
|
//! Uses X25519 key agreement + HKDF-SHA256 key derivation + ChaCha20-Poly1305 AEAD.
|
||||||
|
//! Encryption is opt-in per message: the sender encrypts the payload before
|
||||||
|
//! constructing the `ServiceMessage`, and the recipient decrypts after receiving.
|
||||||
|
//!
|
||||||
|
//! ## Key model
|
||||||
|
//!
|
||||||
|
//! Each `ServiceIdentity` (Ed25519) can derive an X25519 keypair for encryption.
|
||||||
|
//! - Sender generates an ephemeral X25519 key per message (forward secrecy).
|
||||||
|
//! - Shared secret is computed via X25519 DH with the recipient's public key.
|
||||||
|
//! - HKDF derives a per-message encryption key.
|
||||||
|
//! - ChaCha20-Poly1305 encrypts the payload with a random nonce.
|
||||||
|
//!
|
||||||
|
//! ## Wire format of encrypted payload
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! [1 byte: version = 0x01]
|
||||||
|
//! [32 bytes: sender ephemeral X25519 public key]
|
||||||
|
//! [12 bytes: nonce]
|
||||||
|
//! [N bytes: ciphertext + 16-byte Poly1305 tag]
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use chacha20poly1305::aead::{Aead, KeyInit};
|
||||||
|
use chacha20poly1305::{ChaCha20Poly1305, Nonce};
|
||||||
|
use hkdf::Hkdf;
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
use rand::RngCore;
|
||||||
|
use x25519_dalek::{PublicKey as X25519Public, StaticSecret};
|
||||||
|
|
||||||
|
use crate::error::ServiceError;
|
||||||
|
use crate::identity::ServiceIdentity;
|
||||||
|
|
||||||
|
/// Current encrypted payload version byte.
|
||||||
|
const ENCRYPTED_VERSION: u8 = 0x01;
|
||||||
|
/// Overhead: 1 (version) + 32 (ephemeral pubkey) + 12 (nonce) + 16 (tag).
|
||||||
|
const ENCRYPTION_OVERHEAD: usize = 1 + 32 + 12 + 16;
|
||||||
|
|
||||||
|
/// X25519 keypair derived from a `ServiceIdentity` for encryption.
|
||||||
|
///
|
||||||
|
/// The Ed25519 seed is reused as the X25519 static secret. This is the
|
||||||
|
/// standard Ed25519-to-X25519 conversion used by libsodium and others.
|
||||||
|
pub struct EncryptionKeyPair {
|
||||||
|
secret: StaticSecret,
|
||||||
|
public: X25519Public,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EncryptionKeyPair {
|
||||||
|
/// Derive an encryption keypair from a `ServiceIdentity`.
|
||||||
|
pub fn from_identity(identity: &ServiceIdentity) -> Self {
|
||||||
|
let secret = StaticSecret::from(identity.secret_key());
|
||||||
|
let public = X25519Public::from(&secret);
|
||||||
|
Self { secret, public }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the X25519 public key bytes (advertise to peers for encryption).
|
||||||
|
pub fn public_bytes(&self) -> [u8; 32] {
|
||||||
|
self.public.to_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypt a plaintext payload for a specific recipient.
|
||||||
|
///
|
||||||
|
/// Uses a fresh ephemeral key for forward secrecy: even if the sender's
|
||||||
|
/// long-term key is compromised, past messages remain confidential.
|
||||||
|
pub fn encrypt_for(
|
||||||
|
&self,
|
||||||
|
recipient_x25519_public: &[u8; 32],
|
||||||
|
plaintext: &[u8],
|
||||||
|
) -> Result<Vec<u8>, ServiceError> {
|
||||||
|
// Generate ephemeral keypair for this message
|
||||||
|
let eph_secret = StaticSecret::random_from_rng(OsRng);
|
||||||
|
let eph_public = X25519Public::from(&eph_secret);
|
||||||
|
|
||||||
|
// X25519 DH with recipient
|
||||||
|
let recipient_pub = X25519Public::from(*recipient_x25519_public);
|
||||||
|
let shared = eph_secret.diffie_hellman(&recipient_pub);
|
||||||
|
|
||||||
|
// Derive encryption key via HKDF
|
||||||
|
let key = derive_key(shared.as_bytes(), b"meshservice-e2e-v1");
|
||||||
|
|
||||||
|
// Encrypt with ChaCha20-Poly1305
|
||||||
|
let cipher = ChaCha20Poly1305::new((&key).into());
|
||||||
|
let mut nonce_bytes = [0u8; 12];
|
||||||
|
OsRng.fill_bytes(&mut nonce_bytes);
|
||||||
|
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||||
|
|
||||||
|
let ciphertext = cipher
|
||||||
|
.encrypt(nonce, plaintext)
|
||||||
|
.map_err(|_| ServiceError::Crypto("encryption failed".into()))?;
|
||||||
|
|
||||||
|
// Assemble: version || ephemeral_public || nonce || ciphertext+tag
|
||||||
|
let mut out = Vec::with_capacity(ENCRYPTION_OVERHEAD + plaintext.len());
|
||||||
|
out.push(ENCRYPTED_VERSION);
|
||||||
|
out.extend_from_slice(&eph_public.to_bytes());
|
||||||
|
out.extend_from_slice(&nonce_bytes);
|
||||||
|
out.extend_from_slice(&ciphertext);
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt an encrypted payload sent to us.
|
||||||
|
///
|
||||||
|
/// Extracts the sender's ephemeral public key from the payload, computes
|
||||||
|
/// the shared secret with our static X25519 key, and decrypts.
|
||||||
|
pub fn decrypt(&self, encrypted: &[u8]) -> Result<Vec<u8>, ServiceError> {
|
||||||
|
if encrypted.len() < ENCRYPTION_OVERHEAD {
|
||||||
|
return Err(ServiceError::Crypto("ciphertext too short".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let version = encrypted[0];
|
||||||
|
if version != ENCRYPTED_VERSION {
|
||||||
|
return Err(ServiceError::Crypto(format!(
|
||||||
|
"unsupported encryption version: {version}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let eph_public_bytes: [u8; 32] = encrypted[1..33]
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| ServiceError::Crypto("invalid ephemeral key".into()))?;
|
||||||
|
let nonce_bytes: [u8; 12] = encrypted[33..45]
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| ServiceError::Crypto("invalid nonce".into()))?;
|
||||||
|
let ciphertext = &encrypted[45..];
|
||||||
|
|
||||||
|
// X25519 DH with sender's ephemeral key
|
||||||
|
let eph_public = X25519Public::from(eph_public_bytes);
|
||||||
|
let shared = self.secret.diffie_hellman(&eph_public);
|
||||||
|
|
||||||
|
// Derive decryption key
|
||||||
|
let key = derive_key(shared.as_bytes(), b"meshservice-e2e-v1");
|
||||||
|
|
||||||
|
// Decrypt
|
||||||
|
let cipher = ChaCha20Poly1305::new((&key).into());
|
||||||
|
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||||
|
|
||||||
|
cipher
|
||||||
|
.decrypt(nonce, ciphertext)
|
||||||
|
.map_err(|_| ServiceError::Crypto("decryption failed".into()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive a 32-byte key from a shared secret using HKDF-SHA256.
|
||||||
|
fn derive_key(shared_secret: &[u8], info: &[u8]) -> [u8; 32] {
|
||||||
|
let hk = Hkdf::<sha2::Sha256>::new(None, shared_secret);
|
||||||
|
let mut key = [0u8; 32];
|
||||||
|
hk.expand(info, &mut key)
|
||||||
|
.expect("HKDF expand to 32 bytes should never fail");
|
||||||
|
key
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether a payload appears to be encrypted (starts with version byte
|
||||||
|
/// and has minimum length).
|
||||||
|
pub fn is_encrypted_payload(payload: &[u8]) -> bool {
|
||||||
|
payload.len() >= ENCRYPTION_OVERHEAD && payload[0] == ENCRYPTED_VERSION
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the encryption overhead in bytes (useful for size budgets on
|
||||||
|
/// constrained transports like LoRa).
|
||||||
|
pub const fn encryption_overhead() -> usize {
|
||||||
|
ENCRYPTION_OVERHEAD
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::identity::ServiceIdentity;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encrypt_decrypt_roundtrip() {
|
||||||
|
let sender_id = ServiceIdentity::generate();
|
||||||
|
let recipient_id = ServiceIdentity::generate();
|
||||||
|
|
||||||
|
let sender_keys = EncryptionKeyPair::from_identity(&sender_id);
|
||||||
|
let recipient_keys = EncryptionKeyPair::from_identity(&recipient_id);
|
||||||
|
|
||||||
|
let plaintext = b"Hello, encrypted mesh world!";
|
||||||
|
let encrypted = sender_keys
|
||||||
|
.encrypt_for(&recipient_keys.public_bytes(), plaintext)
|
||||||
|
.expect("encrypt");
|
||||||
|
|
||||||
|
let decrypted = recipient_keys.decrypt(&encrypted).expect("decrypt");
|
||||||
|
assert_eq!(decrypted, plaintext);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrong_recipient_cannot_decrypt() {
|
||||||
|
let sender_id = ServiceIdentity::generate();
|
||||||
|
let recipient_id = ServiceIdentity::generate();
|
||||||
|
let wrong_id = ServiceIdentity::generate();
|
||||||
|
|
||||||
|
let sender_keys = EncryptionKeyPair::from_identity(&sender_id);
|
||||||
|
let recipient_keys = EncryptionKeyPair::from_identity(&recipient_id);
|
||||||
|
let wrong_keys = EncryptionKeyPair::from_identity(&wrong_id);
|
||||||
|
|
||||||
|
let encrypted = sender_keys
|
||||||
|
.encrypt_for(&recipient_keys.public_bytes(), b"secret data")
|
||||||
|
.expect("encrypt");
|
||||||
|
|
||||||
|
let result = wrong_keys.decrypt(&encrypted);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tampered_ciphertext_fails() {
|
||||||
|
let sender_id = ServiceIdentity::generate();
|
||||||
|
let recipient_id = ServiceIdentity::generate();
|
||||||
|
|
||||||
|
let sender_keys = EncryptionKeyPair::from_identity(&sender_id);
|
||||||
|
let recipient_keys = EncryptionKeyPair::from_identity(&recipient_id);
|
||||||
|
|
||||||
|
let mut encrypted = sender_keys
|
||||||
|
.encrypt_for(&recipient_keys.public_bytes(), b"do not tamper")
|
||||||
|
.expect("encrypt");
|
||||||
|
|
||||||
|
// Flip a byte in the ciphertext portion
|
||||||
|
let last = encrypted.len() - 1;
|
||||||
|
encrypted[last] ^= 0xff;
|
||||||
|
|
||||||
|
let result = recipient_keys.decrypt(&encrypted);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncated_ciphertext_rejected() {
|
||||||
|
let recipient_id = ServiceIdentity::generate();
|
||||||
|
let keys = EncryptionKeyPair::from_identity(&recipient_id);
|
||||||
|
|
||||||
|
let result = keys.decrypt(&[0x01; 10]);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bad_version_rejected() {
|
||||||
|
let recipient_id = ServiceIdentity::generate();
|
||||||
|
let keys = EncryptionKeyPair::from_identity(&recipient_id);
|
||||||
|
|
||||||
|
// Valid length but wrong version
|
||||||
|
let mut fake = vec![0x99u8; ENCRYPTION_OVERHEAD + 10];
|
||||||
|
fake[0] = 0x99;
|
||||||
|
|
||||||
|
let result = keys.decrypt(&fake);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn each_encryption_produces_different_ciphertext() {
|
||||||
|
let sender_id = ServiceIdentity::generate();
|
||||||
|
let recipient_id = ServiceIdentity::generate();
|
||||||
|
|
||||||
|
let sender_keys = EncryptionKeyPair::from_identity(&sender_id);
|
||||||
|
let recipient_keys = EncryptionKeyPair::from_identity(&recipient_id);
|
||||||
|
|
||||||
|
let plaintext = b"same message twice";
|
||||||
|
let enc1 = sender_keys
|
||||||
|
.encrypt_for(&recipient_keys.public_bytes(), plaintext)
|
||||||
|
.expect("encrypt 1");
|
||||||
|
let enc2 = sender_keys
|
||||||
|
.encrypt_for(&recipient_keys.public_bytes(), plaintext)
|
||||||
|
.expect("encrypt 2");
|
||||||
|
|
||||||
|
// Different ephemeral keys + nonces => different ciphertext
|
||||||
|
assert_ne!(enc1, enc2);
|
||||||
|
|
||||||
|
// Both decrypt to the same plaintext
|
||||||
|
let dec1 = recipient_keys.decrypt(&enc1).expect("decrypt 1");
|
||||||
|
let dec2 = recipient_keys.decrypt(&enc2).expect("decrypt 2");
|
||||||
|
assert_eq!(dec1, plaintext);
|
||||||
|
assert_eq!(dec2, plaintext);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_plaintext_roundtrip() {
|
||||||
|
let sender_id = ServiceIdentity::generate();
|
||||||
|
let recipient_id = ServiceIdentity::generate();
|
||||||
|
|
||||||
|
let sender_keys = EncryptionKeyPair::from_identity(&sender_id);
|
||||||
|
let recipient_keys = EncryptionKeyPair::from_identity(&recipient_id);
|
||||||
|
|
||||||
|
let encrypted = sender_keys
|
||||||
|
.encrypt_for(&recipient_keys.public_bytes(), b"")
|
||||||
|
.expect("encrypt empty");
|
||||||
|
|
||||||
|
assert_eq!(encrypted.len(), ENCRYPTION_OVERHEAD);
|
||||||
|
|
||||||
|
let decrypted = recipient_keys.decrypt(&encrypted).expect("decrypt empty");
|
||||||
|
assert!(decrypted.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_encrypted_payload_detection() {
|
||||||
|
let sender_id = ServiceIdentity::generate();
|
||||||
|
let recipient_id = ServiceIdentity::generate();
|
||||||
|
|
||||||
|
let sender_keys = EncryptionKeyPair::from_identity(&sender_id);
|
||||||
|
let recipient_keys = EncryptionKeyPair::from_identity(&recipient_id);
|
||||||
|
|
||||||
|
let encrypted = sender_keys
|
||||||
|
.encrypt_for(&recipient_keys.public_bytes(), b"test")
|
||||||
|
.expect("encrypt");
|
||||||
|
|
||||||
|
assert!(is_encrypted_payload(&encrypted));
|
||||||
|
assert!(!is_encrypted_payload(b"plain text"));
|
||||||
|
assert!(!is_encrypted_payload(&[]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_bytes_deterministic() {
|
||||||
|
let id = ServiceIdentity::generate();
|
||||||
|
let keys1 = EncryptionKeyPair::from_identity(&id);
|
||||||
|
let keys2 = EncryptionKeyPair::from_identity(&id);
|
||||||
|
assert_eq!(keys1.public_bytes(), keys2.public_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encrypt_decrypt_with_service_message() {
|
||||||
|
// Full integration: encrypt payload, wrap in ServiceMessage, decrypt
|
||||||
|
use crate::message::ServiceMessage;
|
||||||
|
use crate::service_ids::FAPP;
|
||||||
|
|
||||||
|
let sender_id = ServiceIdentity::generate();
|
||||||
|
let recipient_id = ServiceIdentity::generate();
|
||||||
|
|
||||||
|
let sender_keys = EncryptionKeyPair::from_identity(&sender_id);
|
||||||
|
let recipient_keys = EncryptionKeyPair::from_identity(&recipient_id);
|
||||||
|
|
||||||
|
// Encrypt the payload before creating the message
|
||||||
|
let plaintext = b"confidential appointment details";
|
||||||
|
let encrypted_payload = sender_keys
|
||||||
|
.encrypt_for(&recipient_keys.public_bytes(), plaintext)
|
||||||
|
.expect("encrypt");
|
||||||
|
|
||||||
|
// Create a signed service message with the encrypted payload
|
||||||
|
let msg = ServiceMessage::new(
|
||||||
|
&sender_id,
|
||||||
|
FAPP,
|
||||||
|
crate::message::MessageType::Reserve,
|
||||||
|
encrypted_payload.clone(),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify the message signature still works (signs over encrypted payload)
|
||||||
|
assert!(msg.verify(&sender_id.public_key()));
|
||||||
|
|
||||||
|
// Recipient decrypts the payload
|
||||||
|
let decrypted = recipient_keys.decrypt(&msg.payload).expect("decrypt");
|
||||||
|
assert_eq!(decrypted, plaintext);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encrypt_decrypt_wire_roundtrip() {
|
||||||
|
// Full wire roundtrip: encrypt -> sign -> encode -> decode -> verify -> decrypt
|
||||||
|
use crate::message::ServiceMessage;
|
||||||
|
use crate::service_ids::FAPP;
|
||||||
|
use crate::wire;
|
||||||
|
|
||||||
|
let sender_id = ServiceIdentity::generate();
|
||||||
|
let recipient_id = ServiceIdentity::generate();
|
||||||
|
|
||||||
|
let sender_keys = EncryptionKeyPair::from_identity(&sender_id);
|
||||||
|
let recipient_keys = EncryptionKeyPair::from_identity(&recipient_id);
|
||||||
|
|
||||||
|
let plaintext = b"sensitive medical data over the mesh";
|
||||||
|
let encrypted_payload = sender_keys
|
||||||
|
.encrypt_for(&recipient_keys.public_bytes(), plaintext)
|
||||||
|
.expect("encrypt");
|
||||||
|
|
||||||
|
let msg = ServiceMessage::new(
|
||||||
|
&sender_id,
|
||||||
|
FAPP,
|
||||||
|
crate::message::MessageType::Reserve,
|
||||||
|
encrypted_payload,
|
||||||
|
42,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Encode to wire format
|
||||||
|
let wire_bytes = wire::encode(&msg).expect("encode");
|
||||||
|
|
||||||
|
// Decode from wire format
|
||||||
|
let decoded = wire::decode(&wire_bytes).expect("decode");
|
||||||
|
|
||||||
|
// Verify signature
|
||||||
|
assert!(decoded.verify(&sender_id.public_key()));
|
||||||
|
|
||||||
|
// Decrypt payload
|
||||||
|
let decrypted = recipient_keys.decrypt(&decoded.payload).expect("decrypt");
|
||||||
|
assert_eq!(decrypted, plaintext);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encryption_overhead_constant() {
|
||||||
|
assert_eq!(encryption_overhead(), 61);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,7 @@ pub mod services;
|
|||||||
pub mod wire;
|
pub mod wire;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod anti_abuse;
|
pub mod anti_abuse;
|
||||||
|
pub mod crypto;
|
||||||
|
|
||||||
pub use identity::ServiceIdentity;
|
pub use identity::ServiceIdentity;
|
||||||
pub use message::{ServiceMessage, MessageType};
|
pub use message::{ServiceMessage, MessageType};
|
||||||
@@ -54,6 +55,7 @@ pub use store::ServiceStore;
|
|||||||
pub use verification::{Verification, VerificationLevel};
|
pub use verification::{Verification, VerificationLevel};
|
||||||
pub use error::ServiceError;
|
pub use error::ServiceError;
|
||||||
pub use anti_abuse::{RateLimiter, RateLimits, ProofOfWork, SenderReputation, TherapistPolicy};
|
pub use anti_abuse::{RateLimiter, RateLimits, ProofOfWork, SenderReputation, TherapistPolicy};
|
||||||
|
pub use crypto::{EncryptionKeyPair, is_encrypted_payload, encryption_overhead};
|
||||||
|
|
||||||
/// Well-known service IDs.
|
/// Well-known service IDs.
|
||||||
pub mod service_ids {
|
pub mod service_ids {
|
||||||
|
|||||||
Reference in New Issue
Block a user