update concord backend
This commit is contained in:
@@ -447,16 +447,19 @@ pub fn parse_link(input: &str) -> Result<ParsedInviteLink, InviteError> {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_direct_invite(
|
||||
inviter: &Keys,
|
||||
pub async fn build_direct_invite<S>(
|
||||
inviter: &S,
|
||||
recipient: &PublicKey,
|
||||
invite: &CommunityInvite,
|
||||
) -> Result<Event, InviteError> {
|
||||
) -> Result<Event, InviteError>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44,
|
||||
{
|
||||
invite.validate()?;
|
||||
|
||||
let json = serde_json::to_string(invite).map_err(json_error)?;
|
||||
let rumor = EventBuilder::new(Kind::Custom(KIND_DIRECT_INVITE), json)
|
||||
.finalize_unsigned(inviter.public_key());
|
||||
let author = inviter.get_public_key_async().await.map_err(crypto_error)?;
|
||||
let rumor = EventBuilder::new(Kind::Custom(KIND_DIRECT_INVITE), json).finalize_unsigned(author);
|
||||
|
||||
let mut tags = vec![Tag::custom("k", [KIND_DIRECT_INVITE.to_string()])];
|
||||
|
||||
@@ -469,15 +472,22 @@ pub fn build_direct_invite(
|
||||
|
||||
GiftWrapBuilder::new(*recipient, rumor)
|
||||
.extra_tags(tags)
|
||||
.finalize(inviter)
|
||||
.finalize_async(inviter)
|
||||
.await
|
||||
.map_err(crypto_error)
|
||||
}
|
||||
|
||||
pub fn unwrap_direct_invite(
|
||||
/// The NIP-59 unwrap is `Sized`-bounded in the SDK, so this stays `Sized` too.
|
||||
pub async fn unwrap_direct_invite<S>(
|
||||
wrap: &Event,
|
||||
recipient: &Keys,
|
||||
) -> Result<(PublicKey, CommunityInvite), InviteError> {
|
||||
let unwrapped = UnwrappedGift::from_gift_wrap(recipient, wrap).map_err(crypto_error)?;
|
||||
recipient: &S,
|
||||
) -> Result<(PublicKey, CommunityInvite), InviteError>
|
||||
where
|
||||
S: AsyncNip44,
|
||||
{
|
||||
let unwrapped = UnwrappedGift::from_gift_wrap_async(recipient, wrap)
|
||||
.await
|
||||
.map_err(crypto_error)?;
|
||||
|
||||
if unwrapped.rumor.kind.as_u16() != KIND_DIRECT_INVITE {
|
||||
return Err(InviteError::BadEvent("rumor is not a direct invite"));
|
||||
@@ -927,7 +937,12 @@ mod tests {
|
||||
let recipient = Keys::generate();
|
||||
let invite = bundle();
|
||||
|
||||
let wrap = build_direct_invite(&inviter, &recipient.public_key(), &invite).expect("builds");
|
||||
let wrap = smol::block_on(build_direct_invite(
|
||||
&inviter,
|
||||
&recipient.public_key(),
|
||||
&invite,
|
||||
))
|
||||
.expect("builds");
|
||||
assert_eq!(wrap.kind, Kind::GiftWrap);
|
||||
assert_ne!(
|
||||
wrap.pubkey,
|
||||
@@ -939,13 +954,14 @@ mod tests {
|
||||
"the k tag is what makes an invite indexable"
|
||||
);
|
||||
|
||||
let (sender, opened) = unwrap_direct_invite(&wrap, &recipient).expect("unwraps");
|
||||
let (sender, opened) =
|
||||
smol::block_on(unwrap_direct_invite(&wrap, &recipient)).expect("unwraps");
|
||||
assert_eq!(sender, inviter.public_key());
|
||||
assert_eq!(opened.community_id, invite.community_id);
|
||||
|
||||
// Somebody else's wrap is not ours to open...
|
||||
let stranger = Keys::generate();
|
||||
assert!(unwrap_direct_invite(&wrap, &stranger).is_err());
|
||||
assert!(smol::block_on(unwrap_direct_invite(&wrap, &stranger)).is_err());
|
||||
|
||||
// ...and a wrap that opens to some other kind is not an invite.
|
||||
let rumor = EventBuilder::new(Kind::Custom(crate::cord03::KIND_MESSAGE), "hello")
|
||||
@@ -954,7 +970,7 @@ mod tests {
|
||||
.finalize(&recipient)
|
||||
.expect("wraps");
|
||||
assert!(matches!(
|
||||
unwrap_direct_invite(&wrap, &recipient),
|
||||
smol::block_on(unwrap_direct_invite(&wrap, &recipient)),
|
||||
Err(InviteError::BadEvent(_))
|
||||
));
|
||||
}
|
||||
|
||||
@@ -2,11 +2,9 @@ use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fmt;
|
||||
|
||||
use anyhow::Result;
|
||||
use data_encoding::HEXLOWER;
|
||||
use nostr::nips::nip44::v2::ConversationKey;
|
||||
use data_encoding::{BASE64, HEXLOWER};
|
||||
use nostr_sdk::prelude::{
|
||||
AsyncGetPublicKey, AsyncSignEvent, Event, Keys, PublicKey, SecretKey, Tag, Timestamp,
|
||||
UnsignedEvent,
|
||||
AsyncGetPublicKey, AsyncNip44, AsyncSignEvent, Event, PublicKey, Tag, Timestamp, UnsignedEvent,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -301,34 +299,48 @@ pub fn blob_locator(
|
||||
))
|
||||
}
|
||||
|
||||
pub fn build_blob(
|
||||
rotator: &Keys,
|
||||
pub async fn build_blob<S>(
|
||||
rotator: &S,
|
||||
recipient: &PublicKey,
|
||||
scope: RekeyScope,
|
||||
epoch: Epoch,
|
||||
new_key: &[u8; 32],
|
||||
control_pk: Option<&[u8; 32]>,
|
||||
control_root: Option<&[u8; 32]>,
|
||||
) -> Result<RekeyBlob, RekeyError> {
|
||||
) -> Result<RekeyBlob, RekeyError>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
|
||||
{
|
||||
let plaintext = encode_blob_plaintext(scope, epoch, new_key, control_pk, control_root)?;
|
||||
let rotator_pk = rotator.get_public_key_async().await.map_err(crypto_error)?;
|
||||
|
||||
let wrapped = rotator
|
||||
.nip44_encrypt_async(recipient, &BASE64.encode(&plaintext))
|
||||
.await
|
||||
.map_err(crypto_error)?;
|
||||
|
||||
Ok(RekeyBlob {
|
||||
locator: blob_locator(&rotator.public_key(), recipient, scope, epoch),
|
||||
wrapped: seal_to(rotator.secret_key(), recipient, &plaintext)?,
|
||||
locator: blob_locator(&rotator_pk, recipient, scope, epoch),
|
||||
wrapped,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn open_blob(
|
||||
recipient: &Keys,
|
||||
pub async fn open_blob<S>(
|
||||
recipient: &S,
|
||||
rotator: &PublicKey,
|
||||
scope: RekeyScope,
|
||||
epoch: Epoch,
|
||||
blob: &RekeyBlob,
|
||||
community_id: &CommunityId,
|
||||
) -> Result<KeyDelivery, RekeyError> {
|
||||
let conversation =
|
||||
ConversationKey::derive(recipient.secret_key(), rotator).map_err(crypto_error)?;
|
||||
let plaintext = cord01::open_bytes(&conversation, &blob.wrapped)?;
|
||||
) -> Result<KeyDelivery, RekeyError>
|
||||
where
|
||||
S: AsyncNip44 + ?Sized,
|
||||
{
|
||||
let text = recipient
|
||||
.nip44_decrypt_async(rotator, &blob.wrapped)
|
||||
.await
|
||||
.map_err(crypto_error)?;
|
||||
let plaintext = BASE64.decode(text.as_bytes()).map_err(crypto_error)?;
|
||||
|
||||
parse_blob_plaintext(&plaintext, scope, epoch, community_id)
|
||||
}
|
||||
@@ -344,15 +356,6 @@ pub fn find_my_blobs<'a>(
|
||||
blobs.iter().filter(move |blob| blob.locator == wanted)
|
||||
}
|
||||
|
||||
fn seal_to(
|
||||
secret: &SecretKey,
|
||||
recipient: &PublicKey,
|
||||
plaintext: &[u8],
|
||||
) -> Result<String, RekeyError> {
|
||||
let conversation = ConversationKey::derive(secret, recipient).map_err(crypto_error)?;
|
||||
Ok(cord01::seal_bytes(&conversation, plaintext)?)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RekeyChunk {
|
||||
pub rotator: PublicKey,
|
||||
@@ -868,6 +871,8 @@ fn crypto_error(error: impl fmt::Display) -> RekeyError {
|
||||
mod tests {
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use nostr_sdk::prelude::Keys;
|
||||
|
||||
use super::*;
|
||||
use crate::cord01::KIND_WRAP;
|
||||
use crate::cord02::{
|
||||
@@ -922,16 +927,16 @@ mod tests {
|
||||
let scope = RekeyScope::Channel(channel());
|
||||
|
||||
let open = |keys: &Keys, scope: RekeyScope, epoch: Epoch, blob: &RekeyBlob| {
|
||||
open_blob(
|
||||
smol::block_on(open_blob(
|
||||
keys,
|
||||
&rotator.public_key(),
|
||||
scope,
|
||||
epoch,
|
||||
blob,
|
||||
&community_id,
|
||||
)
|
||||
))
|
||||
};
|
||||
let blob = build_blob(
|
||||
let blob = smol::block_on(build_blob(
|
||||
&rotator,
|
||||
&recipient.public_key(),
|
||||
scope,
|
||||
@@ -939,7 +944,7 @@ mod tests {
|
||||
&key,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
))
|
||||
.expect("builds");
|
||||
|
||||
assert_eq!(
|
||||
@@ -972,7 +977,7 @@ mod tests {
|
||||
.to_bytes();
|
||||
|
||||
let base = |pk: Option<&[u8; 32]>, root: Option<&[u8; 32]>| {
|
||||
build_blob(
|
||||
smol::block_on(build_blob(
|
||||
&rotator,
|
||||
&recipient.public_key(),
|
||||
RekeyScope::Base,
|
||||
@@ -980,7 +985,7 @@ mod tests {
|
||||
&key,
|
||||
pk,
|
||||
root,
|
||||
)
|
||||
))
|
||||
.expect("builds")
|
||||
};
|
||||
|
||||
@@ -1053,7 +1058,7 @@ mod tests {
|
||||
let community_id = community();
|
||||
|
||||
let blob_for = |recipient: &Keys, key: [u8; 32]| {
|
||||
build_blob(
|
||||
smol::block_on(build_blob(
|
||||
&rotator,
|
||||
&recipient.public_key(),
|
||||
scope,
|
||||
@@ -1061,7 +1066,7 @@ mod tests {
|
||||
&key,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
))
|
||||
.expect("builds")
|
||||
};
|
||||
let mine = blob_for(&me, [0xAA; 32]);
|
||||
@@ -1139,14 +1144,14 @@ mod tests {
|
||||
.next()
|
||||
.expect("located");
|
||||
assert_eq!(
|
||||
open_blob(
|
||||
smol::block_on(open_blob(
|
||||
&me,
|
||||
&rotator.public_key(),
|
||||
scope,
|
||||
epoch,
|
||||
located,
|
||||
&community_id
|
||||
)
|
||||
))
|
||||
.expect("opens")
|
||||
.new_key,
|
||||
[0xAA; 32]
|
||||
@@ -1423,7 +1428,7 @@ mod tests {
|
||||
.map(|_| {
|
||||
let member = Keys::generate();
|
||||
|
||||
build_blob(
|
||||
smol::block_on(build_blob(
|
||||
&rotator,
|
||||
&member.public_key(),
|
||||
scope,
|
||||
@@ -1431,7 +1436,7 @@ mod tests {
|
||||
&[0xCD; 32],
|
||||
None,
|
||||
None,
|
||||
)
|
||||
))
|
||||
.expect("builds")
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
//! One module per CORD document. CORD-07 (audio/video) is unimplemented, and
|
||||
//! CORD-08's timer rides the Chat and Control planes it edits rather than owning a file.
|
||||
|
||||
pub mod cord01;
|
||||
pub mod cord02;
|
||||
pub mod cord03;
|
||||
|
||||
@@ -132,10 +132,10 @@ Account-key sites to migrate:
|
||||
| `cord03::seal_rumor` (`cord03.rs:295`) | `author: &Keys` | `author: &S` | `AsyncGetPublicKey + AsyncSignEvent` |
|
||||
| `list::build_list_event` (`list.rs:186`) | `keys: &Keys` | `keys: &S` | all three |
|
||||
| `list::parse_list_event` (`list.rs:197`) | `keys: &Keys` | `keys: &S` | `AsyncGetPublicKey + AsyncNip44` |
|
||||
| `cord05::{build_direct_invite, unwrap_direct_invite}` (`:451,478`) | `inviter`/`recipient: &Keys` | `&S` (stage 3) | build: all three; unwrap: `AsyncNip44` |
|
||||
| `cord05::{build_direct_invite, unwrap_direct_invite}` (`:451,478`) | `inviter`/`recipient: &Keys` | **done** | build: all three (`Sized`); unwrap: `AsyncNip44` (`Sized`) |
|
||||
| `cord05::{build_invite_list, parse_invite_list}` (`:593,604`) | `keys: &Keys` | **done** | build: all three; parse: `AsyncGetPublicKey + AsyncNip44` |
|
||||
| `cord06::build_blob` (`:302`) | `rotator: &Keys` | `rotator: &S` (stage 3) | `AsyncGetPublicKey + AsyncNip44` |
|
||||
| `cord06::open_blob` (`:319`) | `recipient: &Keys` | `recipient: &S` (stage 3) | `AsyncNip44` |
|
||||
| `cord06::build_blob` (`:302`) | `rotator: &Keys` | **done** | `AsyncGetPublicKey + AsyncNip44` |
|
||||
| `cord06::open_blob` (`:319`) | `recipient: &Keys` | **done** | `AsyncNip44` |
|
||||
| `cord06::{build_rekey_chunks, seal_dissolved}` (`:602,737`) | actor `&Keys` | **done** | `AsyncGetPublicKey + AsyncSignEvent` |
|
||||
|
||||
Leave unchanged: `cord05::{build_bundle_event, build_revocation}`, all
|
||||
@@ -190,8 +190,8 @@ shared helpers, so the unwired callers had to be migrated in the same pass to
|
||||
keep the crate compiling: `cord05::{build_invite_list, parse_invite_list}` and
|
||||
`cord06::{build_rekey_chunks, seal_dissolved}` (Phase 3's mechanical part).
|
||||
`cord05::{build_direct_invite, unwrap_direct_invite}` and
|
||||
`cord06::{build_blob, open_blob}` are untouched — they use the NIP-59 and
|
||||
group-key paths, not the migrated helpers — and remain `&Keys` for Phase 3.
|
||||
`cord06::{build_blob, open_blob}` were untouched by Phase 1 — they use the NIP-59
|
||||
and group-key paths, not the migrated helpers — and were migrated in Phase 3.
|
||||
|
||||
### Phase 2 — app uses the signer — DONE
|
||||
|
||||
@@ -228,14 +228,28 @@ contract the registry depends on: `create` persists a state `load` returns, the
|
||||
subscription filter addresses the genesis wraps, `fold` yields the created
|
||||
community, and an inbound control edit folds over it.
|
||||
|
||||
### Phase 3 — migrate the remaining unwired writers
|
||||
### Phase 3 — migrate the remaining unwired writers — DONE
|
||||
|
||||
`cord05` direct invite / invite list, `cord06` blob/rekey/dissolved, when (or
|
||||
before) the flows that use them are wired. The helpers already force the
|
||||
`cord05` invite-list and `cord06` rekey/dissolved writers to be generic and
|
||||
`async` (see Phase 1); what remains is `cord05::{build_direct_invite,
|
||||
unwrap_direct_invite}` and `cord06::{build_blob, open_blob}`, plus keeping the
|
||||
`Sized` generics (no `&dyn`) for the NIP-59 paths.
|
||||
`cord05::{build_direct_invite, unwrap_direct_invite}` and
|
||||
`cord06::{build_blob, open_blob}` now take a signer. The NIP-59 pair keeps a
|
||||
`Sized` `S` (`AsyncGetPublicKey + AsyncSignEvent + AsyncNip44` to build,
|
||||
`AsyncNip44` to unwrap) because the SDK's `GiftWrapBuilder::finalize_async` and
|
||||
`UnwrappedGift::from_gift_wrap_async` are `Sized`-bounded. The blob pair is
|
||||
`AsyncGetPublicKey + AsyncNip44` to build and `AsyncNip44` to open, with `?Sized`.
|
||||
|
||||
The blobs forced one behavior change, because a signer's NIP-44 is text-only
|
||||
(`nip44_encrypt_async(public_key, &str)`) while the blob plaintext is a
|
||||
fixed-width binary record. `build_blob` now carries that record base64-encoded
|
||||
inside the NIP-44 envelope and `open_blob` decodes it again. The record layout,
|
||||
the `locator`, and the envelope are unchanged; only the bytes inside the envelope
|
||||
differ. There are no golden vectors for blobs and no producer or consumer other
|
||||
than these two functions, so the round-trip stays self-consistent; cord06 remains
|
||||
unwired and persists nothing.
|
||||
|
||||
Validation: `cargo test -p concord` — 46 passed, 0 failed (the 80-blob
|
||||
`a_full_send_chunk_stays_within_a_relay_event` size assertion still holds under
|
||||
the base64 record). `cargo clippy -p concord --all-targets` and
|
||||
`cargo fmt -p concord --check` are clean.
|
||||
|
||||
### Phase 4 — duplication and hygiene (independent, low risk)
|
||||
|
||||
@@ -282,7 +296,9 @@ Truly unreferenced even by tests (safe candidates, but kept per D1):
|
||||
|
||||
- No mass deletion of unwired modules (D1).
|
||||
- No changes to frozen HKDF derivations, locators, golden vectors, or `cord01`
|
||||
envelope semantics.
|
||||
envelope semantics. The one exception Phase 3 forced is the blob plaintext
|
||||
encoding (base64 inside the envelope, see Phase 3); the blob record layout and
|
||||
`locator` are untouched.
|
||||
- No group-key encryption through the signer.
|
||||
- Tests move only alongside the code they cover.
|
||||
|
||||
|
||||
+27
-14
@@ -98,7 +98,7 @@ let invite = match cord05::parse_bundle_event(&event, &link.link_signer, &invite
|
||||
A Direct Invite arrives as a NIP-59 gift wrap addressed to the member:
|
||||
|
||||
```rust
|
||||
let (inviter, invite) = cord05::unwrap_direct_invite(&wrap, &my_keys)?;
|
||||
let (inviter, invite) = cord05::unwrap_direct_invite(&wrap, &my_keys).await?;
|
||||
```
|
||||
|
||||
Either way the invite carries `community_id`, `owner`, `owner_salt`,
|
||||
@@ -308,7 +308,7 @@ keep it against the token in the member's own Invite List — a local document
|
||||
encrypted to self, exactly like the Community List:
|
||||
|
||||
```rust
|
||||
let mut list = cord05::parse_invite_list(&my_keys, &event)?;
|
||||
let mut list = cord05::parse_invite_list(&my_keys, &event).await?;
|
||||
list.entries.push(InviteEntry {
|
||||
token: HEXLOWER.encode(&token),
|
||||
signer_sk: link_signer.secret_key().to_secret_hex(),
|
||||
@@ -319,7 +319,7 @@ list.entries.push(InviteEntry {
|
||||
expires_at: None,
|
||||
extra: Default::default(),
|
||||
});
|
||||
let event = cord05::build_invite_list(&my_keys, &list)?; // kind 13303
|
||||
let event = cord05::build_invite_list(&my_keys, &list).await?; // kind 13303
|
||||
|
||||
// Retiring is a tombstone, never a deletion: it beats a stale copy terminally.
|
||||
list.tombstones.push(InviteTombstone {
|
||||
@@ -356,12 +356,16 @@ let (control_pk, control_root) = match scope {
|
||||
RekeyScope::Channel(_) => (None, None),
|
||||
};
|
||||
|
||||
let blobs = members
|
||||
.iter()
|
||||
.map(|member| {
|
||||
cord06::build_blob(&my_keys, member, scope, plan.epoch, &new_key, control_pk.as_ref(), control_root)
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let mut blobs = Vec::with_capacity(members.len());
|
||||
|
||||
for member in &members {
|
||||
blobs.push(
|
||||
cord06::build_blob(
|
||||
&my_keys, member, scope, plan.epoch, &new_key, control_pk.as_ref(), control_root,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
|
||||
let rekey_group = cord06::rekey_group(scope, &community_root, &community_id, plan.epoch)?;
|
||||
let wraps = cord06::build_rekey_chunks(
|
||||
@@ -375,7 +379,8 @@ let wraps = cord06::build_rekey_chunks(
|
||||
citation,
|
||||
false,
|
||||
now_secs,
|
||||
)?;
|
||||
)
|
||||
.await?;
|
||||
```
|
||||
|
||||
On the receiving side, `cord06::parse_rekey_chunk(&opened)` per wrap, then
|
||||
@@ -385,11 +390,15 @@ member finds their delivery with `find_my_blobs` / `open_blob`, and adopts the k
|
||||
only if the plaintext binds to the scope and epoch they expect and its `prevcommit`
|
||||
matches the key they already hold. Two concurrent rotations settle on `fork_winner`.
|
||||
|
||||
The blob plaintext is a fixed-width binary record, but a signer's NIP-44 is
|
||||
text-only, so `build_blob` carries it base64-encoded inside the envelope.
|
||||
`open_blob` mirrors that, so the record layout and the `locator` are unchanged.
|
||||
|
||||
Dissolution is owner-only and terminal:
|
||||
|
||||
```rust
|
||||
let rumor = cord06::dissolved_tombstone_rumor(owner_pk, &community_id, now_secs);
|
||||
let wrap = cord06::seal_dissolved(&rumor, &community_id, &my_keys, now_secs)?;
|
||||
let wrap = cord06::seal_dissolved(&rumor, &community_id, &my_keys, now_secs).await?;
|
||||
|
||||
// A receiver seals the community read-only on sight.
|
||||
if cord06::verify_dissolved(&wrap, &identity) {
|
||||
@@ -534,9 +543,13 @@ client.subscribe(filter).with_id(sub_id).await?;
|
||||
address changes (join, channel added, rekey folded). GPUI integration above is
|
||||
the shape to build, not code that exists.
|
||||
- **Account-key writers take any signer, not `&Keys`.** `genesis`,
|
||||
`ControlWriter`, the guestbook and chat `seal_rumor`s and the `list` builders are
|
||||
`async` and generic over the SDK's `AsyncGetPublicKey` / `AsyncSignEvent` /
|
||||
`AsyncNip44` traits, so a `Keys` and an app `UniversalSigner` both work.
|
||||
`ControlWriter`, the guestbook and chat `seal_rumor`s, the `list` builders, and
|
||||
the `cord05` invite writers (`build_direct_invite` / `unwrap_direct_invite`,
|
||||
`build_invite_list` / `parse_invite_list`) and `cord06` blob writers
|
||||
(`build_blob` / `open_blob`) are `async` and generic over the SDK's
|
||||
`AsyncGetPublicKey` / `AsyncSignEvent` / `AsyncNip44` traits, so a `Keys` and an
|
||||
app `UniversalSigner` both work. The NIP-59 paths (`build_direct_invite`,
|
||||
`unwrap_direct_invite`) stay `Sized` because the SDK's gift-wrap helpers are.
|
||||
Group-key and locally-held-secret writers (`cord01` wrap functions,
|
||||
`cord05::build_bundle_event`, `store`) still take the raw key material they
|
||||
genuinely need.
|
||||
|
||||
Reference in New Issue
Block a user