feat(wasm): persist/restore device key under master key; org_unwrap_key reads DEVICE_STATE
- device.rs: add PersistedDeviceState (serde mirror), export_state_bytes (plaintext JSON in Zeroizing<Vec<u8>>, NOT a wasm_bindgen export), import_state_bytes (repopulates DEVICE_STATE with Zeroizing<String> keys), signing_seed (extracts ed25519 seed via core::device::extract_ed25519_seed) - lib.rs: add persist_device_key (#[wasm_bindgen], encrypts state bytes under master key via relicario_core::crypto::encrypt; ciphertext only reaches JS), restore_device_key (#[wasm_bindgen], decrypts inside WASM then calls import_state_bytes; plaintext never leaves WASM) - lib.rs: refactor org_unwrap_key to drop device_private_openssh param; reads seed from DEVICE_STATE via device::signing_seed() - lib.rs: update org_unwrap_key_yields_a_session_that_decrypts_org_blobs to new signature (register_device first, use signing_pub for wrap_org_key) - lib.rs: add device_persist_tests module (4 tests: round-trip, org-unwrap post-restore, ciphertext-not-plaintext, no-device-error) - lib.rs: add DEVICE_TEST_LOCK static to serialize DEVICE_STATE-touching tests - lib.rs: add #[derive(Debug)] to SessionHandle - wasm.d.ts: update org_unwrap_key sig; declare persist_device_key + restore_device_key All gates: cargo test -p relicario-wasm (15/15), cargo test -p relicario-core (no regression), cargo build --target wasm32-unknown-unknown (clean), clippy -D warnings (clean). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014s527M917W47LDrfQ4t47g
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
use std::sync::Mutex;
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use relicario_core::device as core_device;
|
||||
@@ -20,6 +21,27 @@ struct DeviceState {
|
||||
deploy_public: String,
|
||||
}
|
||||
|
||||
/// Serialization-safe mirror of `DeviceState`.
|
||||
///
|
||||
/// Fields are plain `String` (not `Zeroizing`) because serde requires `Sized`
|
||||
/// owned types. The byte vector produced by `export_state_bytes` is wrapped in
|
||||
/// `Zeroizing<Vec<u8>>`; `import_state_bytes` re-wraps the private key fields
|
||||
/// back into `Zeroizing<String>` on decode.
|
||||
///
|
||||
/// # Security note
|
||||
///
|
||||
/// This struct itself is NOT zeroed on drop. The invariant that matters is:
|
||||
/// (a) `persist_device_key` in lib.rs encrypts the bytes before returning to JS,
|
||||
/// (b) `export_state_bytes` is NOT a `#[wasm_bindgen]` export.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct PersistedDeviceState {
|
||||
name: String,
|
||||
signing_private: String,
|
||||
signing_public: String,
|
||||
deploy_private: String,
|
||||
deploy_public: String,
|
||||
}
|
||||
|
||||
/// Register a new device, storing the keypairs internally and returning
|
||||
/// only the public keys. Private keys never leave WASM memory.
|
||||
pub fn register_device(name: &str) -> Result<(String, String), String> {
|
||||
@@ -69,3 +91,66 @@ pub fn get_device_info() -> Option<(String, String, String)> {
|
||||
pub fn clear_device() {
|
||||
*DEVICE_STATE.lock().unwrap() = None;
|
||||
}
|
||||
|
||||
/// Serialize the current `DEVICE_STATE` to JSON bytes, wrapped in `Zeroizing`.
|
||||
///
|
||||
/// # Security
|
||||
///
|
||||
/// The returned bytes are **plaintext secret material** — they contain the
|
||||
/// ed25519 private key in OpenSSH PEM format. The caller in `lib.rs` encrypts
|
||||
/// them immediately under the vault master key before returning anything to JS.
|
||||
/// This function **must never** be exposed as a `#[wasm_bindgen]` export.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `Err("no device registered")` if `DEVICE_STATE` is empty.
|
||||
pub fn export_state_bytes() -> Result<Zeroizing<Vec<u8>>, String> {
|
||||
let guard = DEVICE_STATE.lock().unwrap();
|
||||
let state = guard.as_ref().ok_or_else(|| "no device registered".to_string())?;
|
||||
let persisted = PersistedDeviceState {
|
||||
name: state.name.clone(),
|
||||
signing_private: state.signing_private.as_str().to_owned(),
|
||||
signing_public: state.signing_public.clone(),
|
||||
deploy_private: state.deploy_private.as_str().to_owned(),
|
||||
deploy_public: state.deploy_public.clone(),
|
||||
};
|
||||
let bytes = serde_json::to_vec(&persisted).map_err(|e| e.to_string())?;
|
||||
Ok(Zeroizing::new(bytes))
|
||||
}
|
||||
|
||||
/// Deserialize a `PersistedDeviceState` from raw bytes and repopulate
|
||||
/// `DEVICE_STATE`. Private key fields are re-wrapped in `Zeroizing<String>`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `Err(...)` if `bytes` is not valid JSON matching `PersistedDeviceState`.
|
||||
pub fn import_state_bytes(bytes: &[u8]) -> Result<(), String> {
|
||||
let persisted: PersistedDeviceState =
|
||||
serde_json::from_slice(bytes).map_err(|e| e.to_string())?;
|
||||
let state = DeviceState {
|
||||
name: persisted.name,
|
||||
signing_private: Zeroizing::new(persisted.signing_private),
|
||||
signing_public: persisted.signing_public,
|
||||
deploy_private: Zeroizing::new(persisted.deploy_private),
|
||||
deploy_public: persisted.deploy_public,
|
||||
};
|
||||
*DEVICE_STATE.lock().unwrap() = Some(state);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extract the raw 32-byte ed25519 seed from the registered device's signing key.
|
||||
///
|
||||
/// Used by `org_unwrap_key` (which calls `relicario_core::org::unwrap_org_key`)
|
||||
/// so the private key never has to cross to JS. The seed is wrapped in
|
||||
/// `Zeroizing<[u8; 32]>` and wiped on drop.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `Err("no device registered")` if `DEVICE_STATE` is empty, or an
|
||||
/// error from `extract_ed25519_seed` if the stored key can't be parsed.
|
||||
pub fn signing_seed() -> Result<Zeroizing<[u8; 32]>, String> {
|
||||
let guard = DEVICE_STATE.lock().unwrap();
|
||||
let state = guard.as_ref().ok_or_else(|| "no device registered".to_string())?;
|
||||
core_device::extract_ed25519_seed(state.signing_private.as_str())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ use relicario_core::{derive_master_key, imgsecret, KdfParams};
|
||||
/// `lock(handle)` remains available as the explicit early-cleanup path; the
|
||||
/// `Drop` impl is the safety net that catches code paths which forget to call
|
||||
/// `lock` before letting the handle go out of scope.
|
||||
#[derive(Debug)]
|
||||
#[wasm_bindgen]
|
||||
pub struct SessionHandle(u32);
|
||||
|
||||
@@ -609,31 +610,49 @@ pub fn wasm_unwrap_recovery_qr(
|
||||
|
||||
// ── Org vault WASM bridge ────────────────────────────────────────────────────
|
||||
|
||||
/// Unwrap a member's ECIES-wrapped org master key into a session handle.
|
||||
/// Encrypt the registered device key under the current vault master key and
|
||||
/// return CIPHERTEXT for JS to persist (e.g. `chrome.storage.local.device_key_enc`).
|
||||
///
|
||||
/// The device private key NEVER crosses to JS — only this encrypted blob does.
|
||||
/// On the decrypt side, `restore_device_key` decrypts inside WASM and repopulates
|
||||
/// `DEVICE_STATE` without ever returning plaintext to JS.
|
||||
#[wasm_bindgen]
|
||||
pub fn persist_device_key(handle: &SessionHandle) -> Result<Vec<u8>, JsError> {
|
||||
need_key(handle)?;
|
||||
let plain = device::export_state_bytes().map_err(|e| JsError::new(&e))?;
|
||||
session::with(handle.0, |k| relicario_core::crypto::encrypt(k, &plain))
|
||||
.unwrap()
|
||||
.map_err(|e| JsError::new(&e.to_string()))
|
||||
}
|
||||
|
||||
/// Decrypt a persisted device-key blob under the master key and repopulate
|
||||
/// `DEVICE_STATE` (decryption happens INSIDE WASM; plaintext never reaches JS).
|
||||
/// Call this at unlock time after `restore_device_key` to restore device signing
|
||||
/// capability across service-worker restarts.
|
||||
#[wasm_bindgen]
|
||||
pub fn restore_device_key(handle: &SessionHandle, encrypted: &[u8]) -> Result<(), JsError> {
|
||||
need_key(handle)?;
|
||||
let plain = session::with(handle.0, |k| relicario_core::crypto::decrypt(k, encrypted))
|
||||
.unwrap()
|
||||
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||
let plain = Zeroizing::new(plain);
|
||||
device::import_state_bytes(&plain).map_err(|e| JsError::new(&e))
|
||||
}
|
||||
|
||||
/// Unwrap a member's ECIES-wrapped org master key into a session handle, using
|
||||
/// the registered device key held in `DEVICE_STATE` (restored at unlock via
|
||||
/// `restore_device_key`). The device private key never crosses to JS.
|
||||
///
|
||||
/// `keys_blob` is the raw wrapped-key blob at `keys/<member_id>.enc` in the org
|
||||
/// repo — produced by `relicario_core::org::wrap_org_key`. `device_private_openssh`
|
||||
/// is the device's ed25519 private key.
|
||||
///
|
||||
/// **Device-key form (Step 1 finding):** the key arrives as an OpenSSH PEM blob —
|
||||
/// the multiline `-----BEGIN OPENSSH PRIVATE KEY-----...` form emitted by
|
||||
/// `relicario_core::device::generate_keypair()`, NOT a base64-encoded raw-bytes
|
||||
/// string. The plan's `device_private_key_base64` name was misleading; the spec
|
||||
/// name `device_private_openssh` matches reality. The SW holds this blob in WASM
|
||||
/// `DEVICE_STATE` and never exposes it to JS. Decoding to the zeroized 32-byte
|
||||
/// seed is delegated to `relicario_core::device::extract_ed25519_seed`, the same
|
||||
/// path `device::sign()` uses, so the secret never lands in a plain `[u8; 32]`.
|
||||
/// repo — produced by `relicario_core::org::wrap_org_key`.
|
||||
///
|
||||
/// The org key is held in the same Zeroizing WASM session registry as the personal
|
||||
/// master key; org items share the personal `.enc` AEAD format, so the returned
|
||||
/// handle works with `item_decrypt`/`manifest_decrypt` unchanged.
|
||||
#[wasm_bindgen]
|
||||
pub fn org_unwrap_key(
|
||||
keys_blob: &[u8],
|
||||
device_private_openssh: &str,
|
||||
) -> Result<SessionHandle, JsError> {
|
||||
let seed = relicario_core::device::extract_ed25519_seed(device_private_openssh)
|
||||
.map_err(|e| JsError::new(&format!("bad device key: {e}")))?;
|
||||
pub fn org_unwrap_key(keys_blob: &[u8]) -> Result<SessionHandle, JsError> {
|
||||
let seed = device::signing_seed()
|
||||
.map_err(|e| JsError::new(&format!("device key unavailable: {e}")))?;
|
||||
let org_key = relicario_core::org::unwrap_org_key(keys_blob, &seed)
|
||||
.map_err(|e| JsError::new(&format!("org unwrap failed: {e}")))?;
|
||||
// image_secret slot is unused for org sessions; store a zeroized placeholder.
|
||||
@@ -665,6 +684,12 @@ pub fn org_manifest_encrypt(handle: &SessionHandle, manifest_json: &str) -> Resu
|
||||
.map_err(|e| JsError::new(&e.to_string()))
|
||||
}
|
||||
|
||||
/// Tests that modify DEVICE_STATE must serialize to prevent races.
|
||||
/// Acquired by any test in org_tests or device_persist_tests that
|
||||
/// calls register_device / clear_device / persist_device_key / restore_device_key.
|
||||
#[cfg(test)]
|
||||
static DEVICE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
#[cfg(test)]
|
||||
mod org_tests {
|
||||
use super::*;
|
||||
@@ -708,31 +733,20 @@ mod org_tests {
|
||||
assert_ne!(encrypted, encrypted2, "nonces must differ");
|
||||
}
|
||||
|
||||
/// Returns (public_openssh, private_openssh) using the real core keypair generator.
|
||||
///
|
||||
/// Device private keys are produced by `relicario_core::device::generate_keypair()`
|
||||
/// in OpenSSH PEM format ("-----BEGIN OPENSSH PRIVATE KEY-----..."),
|
||||
/// NOT base64-encoded raw bytes. The plan parameter name "device_private_key_base64"
|
||||
/// is misleading; the spec name "device_private_openssh" matches the actual format.
|
||||
/// We call the real generator here (not a hand-rolled helper) so the test exercises
|
||||
/// the exact production key format and cannot silently drift. The private key is
|
||||
/// kept in its `Zeroizing<String>` wrapper — never copied into a plain `String`.
|
||||
fn test_device_keypair() -> (String, Zeroizing<String>) {
|
||||
let (priv_openssh, pub_openssh) = relicario_core::generate_keypair().unwrap();
|
||||
(pub_openssh, priv_openssh)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn org_unwrap_key_yields_a_session_that_decrypts_org_blobs() {
|
||||
// Generate a device keypair, wrap a known org key to it, unwrap via the WASM
|
||||
// path, then encrypt+decrypt an item through the returned handle to assert
|
||||
// round-trip.
|
||||
let org_key = Zeroizing::new([7u8; 32]);
|
||||
let (pub_openssh, priv_openssh) = test_device_keypair();
|
||||
let wrapped = wrap_org_key(&org_key, &pub_openssh).unwrap();
|
||||
// Serialize with all tests that touch DEVICE_STATE.
|
||||
let _guard = super::DEVICE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
device::clear_device();
|
||||
|
||||
// &priv_openssh derefs Zeroizing<String> → &str; no plain-String copy.
|
||||
let handle = org_unwrap_key(&wrapped, &priv_openssh).unwrap();
|
||||
// Register a device so DEVICE_STATE is populated; use its signing public key
|
||||
// to wrap a known org key. org_unwrap_key now reads the seed from DEVICE_STATE
|
||||
// — the device_private_openssh param is gone in the 4.5a refactor.
|
||||
let org_key = Zeroizing::new([7u8; 32]);
|
||||
let (signing_pub, _deploy_pub) = device::register_device("test-dev").unwrap();
|
||||
let wrapped = wrap_org_key(&org_key, &signing_pub).unwrap();
|
||||
|
||||
let handle = org_unwrap_key(&wrapped).unwrap();
|
||||
// item_encrypt is native-safe: returns Vec<u8>; JsError is only reachable on
|
||||
// the error path, which is not exercised here.
|
||||
let ct = item_encrypt(
|
||||
@@ -748,6 +762,134 @@ mod org_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod device_persist_tests {
|
||||
use super::*;
|
||||
|
||||
/// 1. Persist → restore round-trip.
|
||||
///
|
||||
/// After persisting the device state and simulating a SW restart (clear_device),
|
||||
/// restoring must bring back the exact same name and signing_public key.
|
||||
#[test]
|
||||
fn persist_restore_round_trip() {
|
||||
let _guard = super::DEVICE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
session::clear();
|
||||
device::clear_device();
|
||||
|
||||
// A master-key session (no Argon2id needed — we construct the key directly).
|
||||
let master_key = Zeroizing::new([0x42u8; 32]);
|
||||
let h = session::insert(master_key, Zeroizing::new([0u8; 32]));
|
||||
let handle = SessionHandle(h);
|
||||
|
||||
// Register a device; capture name + signing public key before persist.
|
||||
device::register_device("my-laptop").unwrap();
|
||||
let (name_before, signing_pub_before, _) = device::get_device_info().unwrap();
|
||||
|
||||
// Encrypt the device state under the master key.
|
||||
let enc = persist_device_key(&handle).unwrap();
|
||||
|
||||
// Simulate a SW restart: wipe DEVICE_STATE in-memory.
|
||||
device::clear_device();
|
||||
assert!(device::get_device_info().is_none(), "DEVICE_STATE must be cleared");
|
||||
|
||||
// Decrypt and repopulate.
|
||||
restore_device_key(&handle, &enc).unwrap();
|
||||
|
||||
// Name and signing_public must survive the full round-trip.
|
||||
let (name_after, signing_pub_after, _) = device::get_device_info().unwrap();
|
||||
assert_eq!(name_before, name_after, "device name must survive persist/restore");
|
||||
assert_eq!(
|
||||
signing_pub_before, signing_pub_after,
|
||||
"signing_public must survive persist/restore"
|
||||
);
|
||||
}
|
||||
|
||||
/// 2. org_unwrap_key works post-restore.
|
||||
///
|
||||
/// After a persist→clear→restore cycle, org_unwrap_key must be able to unwrap
|
||||
/// an org key that was wrapped to the device's signing public key.
|
||||
#[test]
|
||||
fn org_unwrap_key_works_after_restore() {
|
||||
let _guard = super::DEVICE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
session::clear();
|
||||
device::clear_device();
|
||||
|
||||
let master_key = Zeroizing::new([0x43u8; 32]);
|
||||
let h = session::insert(master_key, Zeroizing::new([0u8; 32]));
|
||||
let handle = SessionHandle(h);
|
||||
|
||||
// Register device; capture signing_pub for key wrapping.
|
||||
let (signing_pub, _deploy_pub) = device::register_device("my-laptop").unwrap();
|
||||
|
||||
// Full persist → wipe → restore cycle (simulates SW restart).
|
||||
let enc = persist_device_key(&handle).unwrap();
|
||||
device::clear_device();
|
||||
restore_device_key(&handle, &enc).unwrap();
|
||||
|
||||
// Wrap a known org key to the device's signing public key.
|
||||
let known_org_key = Zeroizing::new([0x77u8; 32]);
|
||||
let wrapped = relicario_core::org::wrap_org_key(&known_org_key, &signing_pub).unwrap();
|
||||
|
||||
// Unwrap using the refactored org_unwrap_key (reads seed from DEVICE_STATE).
|
||||
let org_handle = org_unwrap_key(&wrapped).unwrap();
|
||||
|
||||
// Encrypt an item under the org session, then decrypt with the known key
|
||||
// directly (avoids js-sys/serde_wasm_bindgen on native — mirrors existing org test).
|
||||
let ct = item_encrypt(
|
||||
&org_handle,
|
||||
r#"{"id":"a1b2c3d4e5f6a7b8","title":"Test","type":"secure_note","created":0,"modified":0,"core":{"type":"secure_note","body":"x"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let pt: relicario_core::Item =
|
||||
relicario_core::decrypt_item(&ct, &known_org_key).unwrap();
|
||||
assert!(format!("{:?}", pt.core).contains("SecureNote"));
|
||||
}
|
||||
|
||||
/// 3. Ciphertext is NOT plaintext.
|
||||
///
|
||||
/// The blob returned by persist_device_key must not contain the raw PEM private
|
||||
/// key header — proves the device key is encrypted, never handed to JS in clear.
|
||||
#[test]
|
||||
fn persist_output_is_ciphertext_not_plaintext() {
|
||||
let _guard = super::DEVICE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
session::clear();
|
||||
device::clear_device();
|
||||
|
||||
let master_key = Zeroizing::new([0x44u8; 32]);
|
||||
let h = session::insert(master_key, Zeroizing::new([0u8; 32]));
|
||||
let handle = SessionHandle(h);
|
||||
|
||||
device::register_device("my-laptop").unwrap();
|
||||
let enc = persist_device_key(&handle).unwrap();
|
||||
|
||||
// Encrypted bytes must NOT contain the OpenSSH PEM header verbatim.
|
||||
// The XChaCha20-Poly1305 ciphertext is randomized; the ASCII string
|
||||
// "BEGIN OPENSSH PRIVATE KEY" cannot appear in it.
|
||||
let as_lossy = String::from_utf8_lossy(&enc);
|
||||
assert!(
|
||||
!as_lossy.contains("BEGIN OPENSSH PRIVATE KEY"),
|
||||
"persist_device_key must return ciphertext, not the raw PEM private key (device privkey must never reach JS)"
|
||||
);
|
||||
}
|
||||
|
||||
/// 4. org_unwrap_key errors when no device is registered.
|
||||
///
|
||||
/// `org_unwrap_key` wraps its error in `JsError`, which panics on non-wasm
|
||||
/// targets (established constraint in this codebase — see session_tests comment).
|
||||
/// We test `device::signing_seed` directly instead: that is the codepath
|
||||
/// `org_unwrap_key` calls first, and it is what produces the "no device
|
||||
/// registered" error that `org_unwrap_key` surfaces as "device key unavailable".
|
||||
#[test]
|
||||
fn org_unwrap_key_errors_when_no_device_registered() {
|
||||
let _guard = super::DEVICE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
device::clear_device();
|
||||
|
||||
let err = device::signing_seed()
|
||||
.expect_err("signing_seed must fail when no device is registered");
|
||||
assert_eq!(err, "no device registered", "error message must match");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod session_tests {
|
||||
use super::*;
|
||||
|
||||
15
extension/src/wasm.d.ts
vendored
15
extension/src/wasm.d.ts
vendored
@@ -87,9 +87,22 @@ declare module 'relicario-wasm' {
|
||||
export function wasm_generate_recovery_qr(handle: SessionHandle, passphrase: string): string;
|
||||
export function wasm_unwrap_recovery_qr(payload_b64: string, passphrase: string): Uint8Array;
|
||||
|
||||
export function org_unwrap_key(keys_blob: Uint8Array, device_private_openssh: string): SessionHandle;
|
||||
/** Unwrap an ECIES-wrapped org master key using the registered device key in DEVICE_STATE. */
|
||||
export function org_unwrap_key(keys_blob: Uint8Array): SessionHandle;
|
||||
export function org_manifest_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown;
|
||||
export function org_manifest_encrypt(handle: SessionHandle, manifest_json: string): Uint8Array;
|
||||
/**
|
||||
* Encrypt the registered device key under the vault master key.
|
||||
* Returns CIPHERTEXT — the device private key never crosses to JS.
|
||||
* Store the returned bytes in chrome.storage.local as `device_key_enc`.
|
||||
*/
|
||||
export function persist_device_key(handle: SessionHandle): Uint8Array;
|
||||
/**
|
||||
* Decrypt a persisted device-key blob under the master key and repopulate
|
||||
* DEVICE_STATE. Decryption happens inside WASM; plaintext never reaches JS.
|
||||
* Call at unlock time with the `device_key_enc` blob from chrome.storage.local.
|
||||
*/
|
||||
export function restore_device_key(handle: SessionHandle, encrypted: Uint8Array): void;
|
||||
// Pluggable second factor: key-file bindings (Task 3)
|
||||
export function unlock_with_secret(passphrase: string, secret: Uint8Array, salt: Uint8Array, params_json: string): SessionHandle;
|
||||
export function keyfile_encode(secret: Uint8Array): Uint8Array;
|
||||
|
||||
Reference in New Issue
Block a user