mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 01:05:19 +00:00
Prekey bundles: forward-secret async first contact for courier mail (#1381)
* Add capability bits to announce TLV Announces now carry an optional capabilities TLV (0x05): a little-endian bitfield with named bits for upcoming features (prekeys, wifiBulk, gateway, groups, board, vouch, meshDiagnostics). Old clients skip the unknown TLV; peers without it decode as nil so features can distinguish "legacy peer" from "advertises nothing". PeerCapabilities lives in BitFoundation with a minimal-length encoding that preserves unknown bits for forward compatibility. Peer capabilities are stored in the BLE peer registry on verified announce and exposed via BLEService.peerCapabilities(_:). The local advertisement set is empty until each feature ships its bit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Prekey bundles: forward-secret async first contact for courier mail Courier envelopes were sealed with one-way Noise X to the recipient's long-lived static key, so a later compromise of that key exposed every envelope captured in transit. This adds one-time prekey bundles: - PrekeyBundle (MessageType 0x24): 8 one-time Curve25519 public prekeys bound to the owner's Noise static key by an Ed25519 signature over "bitchat-prekey-bundle-v1" canonical bytes; gossiped mesh-wide on its own 60s sync round (SyncTypeFlags bit 9, 200-peer cap, 24h freshness) and verified against the announce-bound signing key before caching. - Sealed envelope v2: Noise X where the responder static is the one-time prekey, prologue "bitchat-prekey-v1" || prekeyID. Sender identity rides encrypted inside and is authenticated exactly like v1 (blocked-sender check included). CourierEnvelope gains an optional prekeyID TLV that v1 decoders skip as unknown. - Local prekeys live in the Keychain; consumed privates survive a 48h grace window for spray-and-wait redeliveries, then are deleted (the forward-secrecy clock starts at deletion). The batch tops back up and re-gossips when unconsumed count drops below 3, and everything is wiped in panic mode. - Routing: courier sealing picks a cached verified bundle when one exists (one prekey per message, reused across deposit retries), with the advertised .prekeys capability as a veto for on-mesh peers, and falls back to static sealing otherwise. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Prekeys: authenticate bundle packets, fix consume-republish, deflake CI Fixes the prekey-bundle PR review + CI failure: - CI root cause: the receive queue (mesh.message) is concurrent, so a gossiped prekey bundle can be processed before the announce that binds its owner's signing key. The old handler dropped such bundles outright, so under CI parallel load the bundle was permanently lost and the cache/gossip tests flaked (verifiedBundleEntersGossipStore, prekeySealedMailTravelsViaCourierAndOpens). Bundles that arrive before their binding are now retained per-owner (bounded) and re-attempted when the verified announce lands, atomically to avoid a check-then-act race. - Authenticate the OUTER prekey-bundle packet (Codex P2 / review MEDIUM): require senderID == PeerID(bundle.noiseStaticPublicKey) and verify the packet's Ed25519 signature (covers senderID + timestamp) against the owner's bound signing key, in addition to the inner bundle signature. Stops replay under a fresh timestamp / fake senderID. - Key the gossip prekey-bundle store/dedup by the bundle's authenticated identity (noiseStaticPublicKey), not the unauthenticated packet senderID, so one valid bundle sprayed under many fabricated sender IDs can't multiply entries and exhaust the 200-owner cap. - Bump published-bundle generatedAt strictly on consume (Codex P1): consuming a prekey shrinks the published bundle, so it now republishes with a strictly newer generatedAt and re-gossips, so peers replace the cached copy and stop assigning the consumed ID before its 48h grace. - Guard the panic/clear detached Application Support tree-deletes behind TestEnvironment.isRunningTests: the SPM test process shares that tree, so the wipe could land mid-test and flake file-dependent tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Update sync tests for prekeyBundle as bit 9 / default sync round Prekeys makes bit 9 (prekeyBundle) a known SyncTypeFlags bit and enables a prekey sync round by default. That broke tests authored by other PRs that assumed bit 9 was phantom or that only their own sync round fires: - SyncTypeFlags(Board)Tests: move the "unknown bits" probes to bits 10+ (0xFE -> 0xFC / 0xFD), since bit 9 is now assigned. - GossipSync(Board)Tests + GossipSyncManagerTests: disable the prekey sync round in configs that run maintenance (as they already do for message/ fragment/fileTransfer), so they isolate the behavior under test. Full app suite (1301 tests) green locally via SPM. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
jack
Claude Fable 5
parent
f9032cf2b9
commit
87910541ef
@@ -62,6 +62,19 @@ final class BLEService: NSObject {
|
||||
// Local-only store-and-forward counters; nil in unit tests.
|
||||
var sfMetrics: StoreAndForwardMetrics?
|
||||
|
||||
// Verified one-time prekey bundles gossiped by other peers, used to seal
|
||||
// courier mail forward-secretly. Injectable for tests.
|
||||
var prekeyBundleStore: PrekeyBundleStore = .shared
|
||||
// Throttle for re-broadcasting our own (unchanged) bundle; guarded by
|
||||
// collectionsQueue barriers.
|
||||
private var lastPrekeyBundleSentAt: Date?
|
||||
// Prekey bundles that arrived before their owner's verified announce bound
|
||||
// a signing key. The receive queue is concurrent, so a bundle can race
|
||||
// ahead of the announce it depends on; we retain the latest such bundle per
|
||||
// owner (bounded) and re-attempt attribution when the announce lands.
|
||||
// Guarded by collectionsQueue barriers.
|
||||
private var pendingPrekeyBundles: [PeerID: BitchatPacket] = [:]
|
||||
private static let pendingPrekeyBundleCap = 64
|
||||
// Gateway mode: sink for received nostrCarrier packets (set by app
|
||||
// wiring, called on the main actor after transport-level checks) and the
|
||||
// runtime-toggled capability bits ORed into `PeerCapabilities.localSupported`
|
||||
@@ -320,7 +333,10 @@ final class BLEService: NSObject {
|
||||
fileTransferSyncIntervalSeconds: TransportConfig.syncFileTransferIntervalSeconds,
|
||||
messageSyncIntervalSeconds: TransportConfig.syncMessageIntervalSeconds,
|
||||
responseRateLimitMaxResponses: TransportConfig.syncResponseRateLimitMaxResponses,
|
||||
responseRateLimitWindowSeconds: TransportConfig.syncResponseRateLimitWindowSeconds
|
||||
responseRateLimitWindowSeconds: TransportConfig.syncResponseRateLimitWindowSeconds,
|
||||
prekeyBundleCapacity: TransportConfig.syncPrekeyBundleCapacity,
|
||||
prekeyBundleSyncIntervalSeconds: TransportConfig.syncPrekeyBundleIntervalSeconds,
|
||||
prekeyBundleMaxAgeSeconds: TransportConfig.syncPrekeyBundleMaxAgeSeconds
|
||||
)
|
||||
|
||||
// Only real Bluetooth sessions archive to disk; unit tests stay hermetic.
|
||||
@@ -371,6 +387,8 @@ final class BLEService: NSObject {
|
||||
ingressLinks.removeAll()
|
||||
recentTrafficTracker.removeAll()
|
||||
scheduledRelays.cancelAll()
|
||||
// Let the post-panic identity publish its fresh bundle promptly.
|
||||
lastPrekeyBundleSentAt = nil
|
||||
return transfers
|
||||
}
|
||||
|
||||
@@ -1368,6 +1386,10 @@ final class BLEService: NSObject {
|
||||
}
|
||||
// Ensure our own announce is included in sync state
|
||||
gossipSyncManager?.onPublicPacketSeen(signedPacket)
|
||||
|
||||
// Keep our prekey bundle riding alongside presence (throttled; the
|
||||
// send is a no-op when the bundle was refreshed recently).
|
||||
sendPrekeyBundle()
|
||||
}
|
||||
|
||||
// MARK: QR Verification over Noise
|
||||
@@ -1772,6 +1794,10 @@ extension BLEService {
|
||||
handleReceivedPacket(packet, from: fromPeerID)
|
||||
}
|
||||
|
||||
func _test_hasGossipPrekeyBundle(for peerID: PeerID) -> Bool {
|
||||
gossipSyncManager?._hasPrekeyBundle(for: peerID) ?? false
|
||||
}
|
||||
|
||||
func _test_acceptsIngress(packet: BitchatPacket, boundPeerID: PeerID?) -> Bool {
|
||||
let claimedSenderID = PeerID(hexData: packet.senderID)
|
||||
guard case .success = BLEIngressLinkRegistry.packetContext(
|
||||
@@ -2751,10 +2777,13 @@ extension BLEService {
|
||||
|
||||
// MARK: Courier Store-and-Forward
|
||||
|
||||
/// Seal `content` to the recipient's static key (one-way Noise X) and hand
|
||||
/// the envelope to the given couriers for physical delivery. Returns false
|
||||
/// when no courier is connected, the payload cannot be built, or sealing
|
||||
/// fails; link writes are queued asynchronously after the envelope is ready.
|
||||
/// Seal `content` for the recipient and hand the envelope to the given
|
||||
/// couriers for physical delivery. When a verified one-time prekey bundle
|
||||
/// is cached for the recipient, sealing targets one of its prekeys
|
||||
/// (forward secret, envelope v2); otherwise it falls back to their static
|
||||
/// key (one-way Noise X, v1) exactly as before. Returns false when no
|
||||
/// courier is connected, the payload cannot be built, or sealing fails;
|
||||
/// link writes are queued asynchronously after the envelope is ready.
|
||||
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool {
|
||||
let connected = couriers.filter { isPeerConnected($0) }
|
||||
guard !connected.isEmpty,
|
||||
@@ -2765,7 +2794,15 @@ extension BLEService {
|
||||
let payload: Data
|
||||
do {
|
||||
let now = Date()
|
||||
let sealed = try noiseService.sealCourierPayload(typedPayload, recipientStaticKey: recipientNoiseKey)
|
||||
let sealed: Data
|
||||
let prekeyID: UInt32?
|
||||
if let prekey = assignRecipientPrekey(messageID: messageID, recipientNoiseKey: recipientNoiseKey) {
|
||||
sealed = try noiseService.sealPrekeyPayload(typedPayload, recipientPrekey: prekey)
|
||||
prekeyID = prekey.id
|
||||
} else {
|
||||
sealed = try noiseService.sealCourierPayload(typedPayload, recipientStaticKey: recipientNoiseKey)
|
||||
prekeyID = nil
|
||||
}
|
||||
let envelope = CourierEnvelope(
|
||||
recipientTag: CourierEnvelope.recipientTag(
|
||||
noiseStaticKey: recipientNoiseKey,
|
||||
@@ -2773,7 +2810,8 @@ extension BLEService {
|
||||
),
|
||||
expiry: UInt64((now.timeIntervalSince1970 + CourierEnvelope.maxLifetimeSeconds) * 1000),
|
||||
ciphertext: sealed,
|
||||
copies: TransportConfig.courierInitialCopies
|
||||
copies: TransportConfig.courierInitialCopies,
|
||||
prekeyID: prekeyID
|
||||
)
|
||||
guard let encoded = envelope.encode() else { return false }
|
||||
payload = encoded
|
||||
@@ -2792,6 +2830,22 @@ extension BLEService {
|
||||
return true
|
||||
}
|
||||
|
||||
/// The prekey to seal a courier message with, or nil to fall back to
|
||||
/// static sealing. The real signal is a verified, unexpired bundle with a
|
||||
/// spare prekey; the advertised `.prekeys` capability only acts as a veto
|
||||
/// for peers we currently see on the mesh (a cached bundle can outlive a
|
||||
/// peer's downgrade to a build that no longer holds the privates).
|
||||
/// Re-deposits of the same message reuse its assigned prekey, so one
|
||||
/// message consumes exactly one prekey ID regardless of courier count.
|
||||
private func assignRecipientPrekey(messageID: String, recipientNoiseKey: Data) -> PrekeyBundle.Prekey? {
|
||||
let shortID = PeerID(publicKey: recipientNoiseKey)
|
||||
let knownOnMesh = collectionsQueue.sync { peerRegistry.info(for: shortID) != nil }
|
||||
if knownOnMesh, !peerCapabilities(shortID).contains(.prekeys) {
|
||||
return nil
|
||||
}
|
||||
return prekeyBundleStore.assignPrekey(messageID: messageID, recipientNoiseKey: recipientNoiseKey)
|
||||
}
|
||||
|
||||
private func makeCourierPacket(_ payload: Data, to peerID: PeerID) -> BitchatPacket {
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.courierEnvelope.rawValue,
|
||||
@@ -2827,7 +2881,25 @@ extension BLEService {
|
||||
|
||||
private func openCourierEnvelope(_ envelope: CourierEnvelope) {
|
||||
do {
|
||||
let (typedPayload, senderStaticKey) = try noiseService.openCourierPayload(envelope.ciphertext)
|
||||
let typedPayload: Data
|
||||
let senderStaticKey: Data
|
||||
if let prekeyID = envelope.prekeyID {
|
||||
// Envelope v2: sealed to one of our one-time prekeys. Opening
|
||||
// consumes the prekey (48h redelivery grace), which shrinks our
|
||||
// published bundle under a strictly newer generatedAt. Re-gossip
|
||||
// so peers replace their cached copy and stop assigning the
|
||||
// consumed ID before the grace lapses; force the broadcast when
|
||||
// the batch also topped back up (low-water), otherwise let the
|
||||
// rebroadcast throttle coalesce bursts.
|
||||
let opened = try noiseService.openPrekeyPayload(envelope.ciphertext, prekeyID: prekeyID)
|
||||
(typedPayload, senderStaticKey) = (opened.payload, opened.senderStaticKey)
|
||||
if opened.consumedPrekey {
|
||||
let replenished = noiseService.replenishPrekeysIfNeeded()
|
||||
sendPrekeyBundle(force: replenished)
|
||||
}
|
||||
} else {
|
||||
(typedPayload, senderStaticKey) = try noiseService.openCourierPayload(envelope.ciphertext)
|
||||
}
|
||||
guard let typeRaw = typedPayload.first,
|
||||
let payloadType = NoisePayloadType(rawValue: typeRaw),
|
||||
payloadType == .privateMessage else {
|
||||
@@ -2958,6 +3030,155 @@ extension BLEService {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: One-Time Prekey Bundles
|
||||
|
||||
/// Broadcasts our signed prekey bundle and tracks it for gossip sync.
|
||||
/// Unforced sends (piggybacked on announces) are throttled — gossip does
|
||||
/// the spreading, the broadcast just keeps our own gossip entry fresh.
|
||||
/// Forced sends (bundle changed after consumption) go immediately.
|
||||
private func sendPrekeyBundle(force: Bool = false) {
|
||||
let now = Date()
|
||||
let shouldSend: Bool = collectionsQueue.sync(flags: .barrier) {
|
||||
if !force,
|
||||
let last = lastPrekeyBundleSentAt,
|
||||
now.timeIntervalSince(last) < TransportConfig.prekeyBundleRebroadcastSeconds {
|
||||
return false
|
||||
}
|
||||
lastPrekeyBundleSentAt = now
|
||||
return true
|
||||
}
|
||||
guard shouldSend else { return }
|
||||
guard let bundle = noiseService.currentPrekeyBundle(),
|
||||
let payload = bundle.encode() else {
|
||||
SecureLogger.error("❌ Failed to build prekey bundle", category: .security)
|
||||
return
|
||||
}
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.prekeyBundle.rawValue,
|
||||
senderID: myPeerIDData,
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(now.timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
ttl: messageTTL
|
||||
)
|
||||
guard let signedPacket = noiseService.signPacket(packet) else {
|
||||
SecureLogger.error("❌ Failed to sign prekey bundle packet", category: .security)
|
||||
return
|
||||
}
|
||||
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
|
||||
broadcastPacket(signedPacket)
|
||||
} else {
|
||||
messageQueue.async { [weak self] in
|
||||
self?.broadcastPacket(signedPacket)
|
||||
}
|
||||
}
|
||||
gossipSyncManager?.onPublicPacketSeen(signedPacket)
|
||||
}
|
||||
|
||||
/// Ingests a gossiped prekey bundle. Attribution is layered: the outer
|
||||
/// packet must originate from the bundle owner (fabricated sender IDs, used
|
||||
/// to multiply cache/gossip entries, are rejected), and BOTH the inner
|
||||
/// bundle signature and the outer packet signature must verify against the
|
||||
/// owner's announce-bound signing key. Verifying the outer packet — whose
|
||||
/// signed bytes cover senderID and timestamp — stops a valid bundle from
|
||||
/// being replayed under a fresh timestamp or spoofed sender to pass
|
||||
/// freshness or poison attribution. Only after that does the packet enter
|
||||
/// our own gossip store, so we never help spread a bundle we couldn't
|
||||
/// attribute.
|
||||
private func handlePrekeyBundle(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
guard let bundle = PrekeyBundle.decode(packet.payload) else {
|
||||
SecureLogger.debug("🔑 Ignoring malformed prekey bundle from \(peerID.id.prefix(8))…", category: .security)
|
||||
return
|
||||
}
|
||||
// Our own bundle is tracked at send time; a copy echoing back adds nothing.
|
||||
guard bundle.noiseStaticPublicKey != noiseService.getStaticPublicKeyData() else { return }
|
||||
let owner = PeerID(publicKey: bundle.noiseStaticPublicKey)
|
||||
// The owner's genuine bundle (direct or relayed) always carries the
|
||||
// owner's senderID + outer signature; gossip resends preserve both. A
|
||||
// packet whose senderID isn't the owner can't be authenticated here.
|
||||
guard PeerID(hexData: packet.senderID) == owner else {
|
||||
SecureLogger.debug("🔑 Ignoring prekey bundle whose sender ≠ owner \(owner.id.prefix(8))…", category: .security)
|
||||
return
|
||||
}
|
||||
// Look up the announce-bound signing key and stash-if-unbound in ONE
|
||||
// barrier: the receive queue is concurrent, so this bundle can race
|
||||
// ahead of the announce that binds the key. Reading the live registry
|
||||
// and stashing atomically closes the check-then-act gap against
|
||||
// handleAnnounce's drain (see drainPendingPrekeyBundles).
|
||||
let signingKey: Data? = collectionsQueue.sync(flags: .barrier) {
|
||||
if let info = peerRegistry.info(for: owner),
|
||||
info.noisePublicKey == bundle.noiseStaticPublicKey,
|
||||
let key = info.signingPublicKey {
|
||||
return key
|
||||
}
|
||||
// Offline-verified identities are stable across this race.
|
||||
for candidate in identityManager.getCryptoIdentitiesByPeerIDPrefix(owner)
|
||||
where candidate.publicKey == bundle.noiseStaticPublicKey {
|
||||
if let key = candidate.signingPublicKey { return key }
|
||||
}
|
||||
// No binding yet: retain the latest bundle per owner, bounded, and
|
||||
// retry once the verified announce lands.
|
||||
if pendingPrekeyBundles[owner] != nil
|
||||
|| pendingPrekeyBundles.count < Self.pendingPrekeyBundleCap {
|
||||
pendingPrekeyBundles[owner] = packet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
guard let signingKey else {
|
||||
SecureLogger.debug("🔑 Deferring prekey bundle without a bound signing key (owner \(owner.id.prefix(8))…)", category: .security)
|
||||
return
|
||||
}
|
||||
ingestVerifiedPrekeyBundle(bundle, packet: packet, owner: owner, signingKey: signingKey)
|
||||
}
|
||||
|
||||
/// Verify a bundle's inner + outer signatures against the owner's bound
|
||||
/// signing key and, on success, cache it and let it enter our gossip store.
|
||||
private func ingestVerifiedPrekeyBundle(_ bundle: PrekeyBundle, packet: BitchatPacket, owner: PeerID, signingKey: Data) {
|
||||
guard noiseService.verifyPrekeyBundleSignature(bundle, signingPublicKey: signingKey),
|
||||
noiseService.verifyPacketSignature(packet, publicKey: signingKey) else {
|
||||
SecureLogger.debug("🔑 Ignoring prekey bundle without verifiable signature (owner \(owner.id.prefix(8))…)", category: .security)
|
||||
return
|
||||
}
|
||||
if prekeyBundleStore.ingest(bundle) {
|
||||
SecureLogger.debug("🔑 Cached prekey bundle for \(owner.id.prefix(8))… (\(bundle.prekeys.count) prekeys)", category: .security)
|
||||
}
|
||||
gossipSyncManager?.onPublicPacketSeen(packet)
|
||||
}
|
||||
|
||||
/// Re-attempt any prekey bundle that arrived before this owner's announce
|
||||
/// bound a signing key. Called from handleAnnounce after a verified
|
||||
/// announce, in a barrier ordered after the registry write, so a bundle
|
||||
/// stashed before the write is always observed here.
|
||||
private func drainPendingPrekeyBundles(for owner: PeerID) {
|
||||
let pending: BitchatPacket? = collectionsQueue.sync(flags: .barrier) {
|
||||
pendingPrekeyBundles.removeValue(forKey: owner)
|
||||
}
|
||||
guard let packet = pending,
|
||||
let bundle = PrekeyBundle.decode(packet.payload),
|
||||
let signingKey = announceBoundSigningKey(forNoiseKey: bundle.noiseStaticPublicKey) else { return }
|
||||
ingestVerifiedPrekeyBundle(bundle, packet: packet, owner: owner, signingKey: signingKey)
|
||||
}
|
||||
|
||||
/// Ed25519 signing key bound to a Noise static key by a verified
|
||||
/// announce: from the live registry when the owner is on the mesh, else
|
||||
/// from identities persisted for offline verification.
|
||||
private func announceBoundSigningKey(forNoiseKey noiseKey: Data) -> Data? {
|
||||
let shortID = PeerID(publicKey: noiseKey)
|
||||
if let info = collectionsQueue.sync(execute: { peerRegistry.info(for: shortID) }),
|
||||
info.noisePublicKey == noiseKey,
|
||||
let signingKey = info.signingPublicKey {
|
||||
return signingKey
|
||||
}
|
||||
for candidate in identityManager.getCryptoIdentitiesByPeerIDPrefix(shortID)
|
||||
where candidate.publicKey == noiseKey {
|
||||
if let signingKey = candidate.signingPublicKey {
|
||||
return signingKey
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: Gateway carrier (nostrCarrier)
|
||||
|
||||
/// Sign and send an encoded `toGateway` carrier payload directed at a
|
||||
@@ -3537,6 +3758,9 @@ extension BLEService {
|
||||
case .courierEnvelope:
|
||||
handleCourierEnvelope(packet, from: peerID)
|
||||
|
||||
case .prekeyBundle:
|
||||
handlePrekeyBundle(packet, from: senderID)
|
||||
|
||||
case .boardPost:
|
||||
// Invalid or deleted posts must not spread; skip the relay step.
|
||||
guard handleBoardPost(packet, from: senderID) else { return }
|
||||
@@ -3615,6 +3839,12 @@ extension BLEService {
|
||||
private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
let result = announceHandler.handle(packet, from: peerID)
|
||||
|
||||
// A verified announce is the moment a signing key becomes bound to this
|
||||
// owner's noise key: retry any prekey bundle that raced ahead of it.
|
||||
if let result, result.isVerified {
|
||||
drainPendingPrekeyBundles(for: result.peerID)
|
||||
}
|
||||
|
||||
// Courier work: an announce is the moment we learn a peer's Noise
|
||||
// static key, so check whether we're carrying mail addressed to them
|
||||
// (or spray-able mail they could carry). Verified announces only.
|
||||
|
||||
Reference in New Issue
Block a user