chore: initial commit for v0.0.1
DChain single-node blockchain + React Native messenger client. Core: - PBFT consensus with multi-sig validator admission + equivocation slashing - BadgerDB + schema migration scaffold (CurrentSchemaVersion=0) - libp2p gossipsub (tx/v1, blocks/v1, relay/v1, version/v1) - Native Go contracts (username_registry) alongside WASM (wazero) - WebSocket gateway with topic-based fanout + Ed25519-nonce auth - Relay mailbox with NaCl envelope encryption (X25519 + Ed25519) - Prometheus /metrics, per-IP rate limit, body-size cap Deployment: - Single-node compose (deploy/single/) with Caddy TLS + optional Prometheus - 3-node dev compose (docker-compose.yml) with mocked internet topology - 3-validator prod compose (deploy/prod/) for federation - Auto-update from Gitea via /api/update-check + systemd timer - Build-time version injection (ldflags → node --version) - UI / Swagger toggle flags (DCHAIN_DISABLE_UI, DCHAIN_DISABLE_SWAGGER) Client (client-app/): - Expo / React Native / NativeWind - E2E NaCl encryption, typing indicator, contact requests - Auto-discovery of canonical contracts, chain_id aware, WS reconnect on node switch Documentation: - README.md, CHANGELOG.md, CONTEXT.md - deploy/single/README.md with 6 operator scenarios - deploy/UPDATE_STRATEGY.md with 4-layer forward-compat design - docs/contracts/*.md per contract
This commit is contained in:
245
identity/identity.go
Normal file
245
identity/identity.go
Normal file
@@ -0,0 +1,245 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/sha512"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/curve25519"
|
||||
"golang.org/x/crypto/nacl/box"
|
||||
|
||||
"go-blockchain/blockchain"
|
||||
)
|
||||
|
||||
// Identity holds an Ed25519 keypair and a Curve25519 (X25519) keypair.
|
||||
// Ed25519 is used for signing transactions and consensus messages.
|
||||
// X25519 is used for NaCl box (E2E) message encryption.
|
||||
type Identity struct {
|
||||
PubKey ed25519.PublicKey
|
||||
PrivKey ed25519.PrivateKey
|
||||
|
||||
// X25519 keypair for NaCl box encryption.
|
||||
// Generated together with Ed25519; stored alongside in key files.
|
||||
X25519Pub [32]byte
|
||||
X25519Priv [32]byte
|
||||
}
|
||||
|
||||
// Generate creates a fresh Ed25519 + X25519 keypair.
|
||||
func Generate() (*Identity, error) {
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate ed25519: %w", err)
|
||||
}
|
||||
xpub, xpriv, err := box.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate x25519: %w", err)
|
||||
}
|
||||
return &Identity{
|
||||
PubKey: pub,
|
||||
PrivKey: priv,
|
||||
X25519Pub: *xpub,
|
||||
X25519Priv: *xpriv,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PubKeyHex returns the hex-encoded Ed25519 public key.
|
||||
func (id *Identity) PubKeyHex() string {
|
||||
return hex.EncodeToString(id.PubKey)
|
||||
}
|
||||
|
||||
// PrivKeyHex returns the hex-encoded Ed25519 private key.
|
||||
func (id *Identity) PrivKeyHex() string {
|
||||
return hex.EncodeToString(id.PrivKey)
|
||||
}
|
||||
|
||||
// X25519PubHex returns the hex-encoded Curve25519 public key.
|
||||
func (id *Identity) X25519PubHex() string {
|
||||
return hex.EncodeToString(id.X25519Pub[:])
|
||||
}
|
||||
|
||||
// X25519PrivHex returns the hex-encoded Curve25519 private key.
|
||||
func (id *Identity) X25519PrivHex() string {
|
||||
return hex.EncodeToString(id.X25519Priv[:])
|
||||
}
|
||||
|
||||
// Sign returns an Ed25519 signature over msg.
|
||||
func (id *Identity) Sign(msg []byte) []byte {
|
||||
return ed25519.Sign(id.PrivKey, msg)
|
||||
}
|
||||
|
||||
// Verify returns true if sig is a valid Ed25519 signature over msg by pubKeyHex.
|
||||
func Verify(pubKeyHex string, msg, sig []byte) (bool, error) {
|
||||
pubBytes, err := hex.DecodeString(pubKeyHex)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("invalid pub key hex: %w", err)
|
||||
}
|
||||
return ed25519.Verify(ed25519.PublicKey(pubBytes), msg, sig), nil
|
||||
}
|
||||
|
||||
// MineRegistration performs a lightweight proof-of-work so that
|
||||
// identity registration has a CPU cost (Sybil barrier).
|
||||
func MineRegistration(pubKeyHex string, difficulty int) (nonce uint64, target string, err error) {
|
||||
prefix := strings.Repeat("0", difficulty/4)
|
||||
pubBytes, err := hex.DecodeString(pubKeyHex)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
buf := make([]byte, len(pubBytes)+8)
|
||||
copy(buf, pubBytes)
|
||||
for nonce = 0; ; nonce++ {
|
||||
binary.BigEndian.PutUint64(buf[len(pubBytes):], nonce)
|
||||
h := sha256.Sum256(buf)
|
||||
hexHash := hex.EncodeToString(h[:])
|
||||
if strings.HasPrefix(hexHash, prefix) {
|
||||
return nonce, hexHash, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterTx builds and signs a REGISTER_KEY transaction.
|
||||
// It includes the X25519 public key so recipients can look it up on-chain.
|
||||
func RegisterTx(id *Identity, nickname string, powDifficulty int) (*blockchain.Transaction, error) {
|
||||
nonce, target, err := MineRegistration(id.PubKeyHex(), powDifficulty)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
payload := blockchain.RegisterKeyPayload{
|
||||
PubKey: id.PubKeyHex(),
|
||||
Nickname: nickname,
|
||||
PowNonce: nonce,
|
||||
PowTarget: target,
|
||||
X25519PubKey: id.X25519PubHex(),
|
||||
}
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tx := &blockchain.Transaction{
|
||||
ID: txID(id.PubKeyHex(), blockchain.EventRegisterKey),
|
||||
Type: blockchain.EventRegisterKey,
|
||||
From: id.PubKeyHex(),
|
||||
Payload: payloadBytes,
|
||||
Fee: blockchain.RegistrationFee,
|
||||
Timestamp: time.Now().UTC(),
|
||||
}
|
||||
tx.Signature = id.Sign(txSignBytes(tx))
|
||||
return tx, nil
|
||||
}
|
||||
|
||||
// SignMessage returns a detached Ed25519 signature over an arbitrary message.
|
||||
func (id *Identity) SignMessage(msg []byte) []byte {
|
||||
return id.Sign(msg)
|
||||
}
|
||||
|
||||
// VerifyMessage verifies a detached signature (see SignMessage).
|
||||
func VerifyMessage(pubKeyHex string, msg, sig []byte) (bool, error) {
|
||||
return Verify(pubKeyHex, msg, sig)
|
||||
}
|
||||
|
||||
// FromHex reconstructs an Identity from hex-encoded Ed25519 keys.
|
||||
// X25519 fields are left zeroed; use FromHexFull for complete identity.
|
||||
func FromHex(pubHex, privHex string) (*Identity, error) {
|
||||
return FromHexFull(pubHex, privHex, "", "")
|
||||
}
|
||||
|
||||
// deriveX25519 deterministically derives a Curve25519 keypair from an Ed25519
|
||||
// private key using the standard Ed25519→X25519 conversion (SHA-512 of seed +
|
||||
// X25519 clamping). This matches libsodium's crypto_sign_ed25519_sk_to_curve25519.
|
||||
func deriveX25519(privKey ed25519.PrivateKey) (pub [32]byte, priv [32]byte) {
|
||||
// Ed25519 private key = seed (32 bytes) || public key (32 bytes).
|
||||
seed := privKey[:32]
|
||||
h := sha512.Sum512(seed)
|
||||
// Apply X25519 scalar clamping.
|
||||
h[0] &= 248
|
||||
h[31] &= 127
|
||||
h[31] |= 64
|
||||
copy(priv[:], h[:32])
|
||||
pubSlice, _ := curve25519.X25519(priv[:], curve25519.Basepoint)
|
||||
copy(pub[:], pubSlice)
|
||||
return pub, priv
|
||||
}
|
||||
|
||||
// FromHexFull reconstructs a complete Identity including X25519 keys.
|
||||
// When x25519PubHex/x25519PrivHex are empty, X25519 is derived deterministically
|
||||
// from the Ed25519 private key using the standard Ed25519→X25519 conversion.
|
||||
func FromHexFull(pubHex, privHex, x25519PubHex, x25519PrivHex string) (*Identity, error) {
|
||||
pubBytes, err := hex.DecodeString(pubHex)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode pub key: %w", err)
|
||||
}
|
||||
privBytes, err := hex.DecodeString(privHex)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode priv key: %w", err)
|
||||
}
|
||||
id := &Identity{
|
||||
PubKey: ed25519.PublicKey(pubBytes),
|
||||
PrivKey: ed25519.PrivateKey(privBytes),
|
||||
}
|
||||
if x25519PubHex != "" && x25519PrivHex != "" {
|
||||
b, err := hex.DecodeString(x25519PubHex)
|
||||
if err != nil || len(b) != 32 {
|
||||
return nil, fmt.Errorf("decode x25519 pub key: %w", err)
|
||||
}
|
||||
copy(id.X25519Pub[:], b)
|
||||
b, err = hex.DecodeString(x25519PrivHex)
|
||||
if err != nil || len(b) != 32 {
|
||||
return nil, fmt.Errorf("decode x25519 priv key: %w", err)
|
||||
}
|
||||
copy(id.X25519Priv[:], b)
|
||||
} else {
|
||||
// Derive X25519 deterministically from Ed25519 private key.
|
||||
id.X25519Pub, id.X25519Priv = deriveX25519(id.PrivKey)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// txID generates a deterministic transaction ID.
|
||||
func txID(fromPubHex string, eventType blockchain.EventType) string {
|
||||
h := sha256.Sum256([]byte(fromPubHex + string(eventType) + fmt.Sprint(time.Now().UnixNano())))
|
||||
return hex.EncodeToString(h[:16])
|
||||
}
|
||||
|
||||
// TxSignBytes returns the canonical bytes that must be signed (and verified)
|
||||
// for a transaction. Use this whenever building a transaction outside of the
|
||||
// identity package — signing json.Marshal(tx) instead is a common mistake
|
||||
// that produces signatures VerifyTx will always reject.
|
||||
func TxSignBytes(tx *blockchain.Transaction) []byte { return txSignBytes(tx) }
|
||||
|
||||
// txSignBytes returns the canonical bytes that are signed for a transaction.
|
||||
func txSignBytes(tx *blockchain.Transaction) []byte {
|
||||
data, _ := json.Marshal(struct {
|
||||
ID string `json:"id"`
|
||||
Type blockchain.EventType `json:"type"`
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Amount uint64 `json:"amount"`
|
||||
Fee uint64 `json:"fee"`
|
||||
Payload []byte `json:"payload"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}{
|
||||
tx.ID, tx.Type, tx.From, tx.To, tx.Amount, tx.Fee, tx.Payload, tx.Timestamp,
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
// VerifyTx verifies a transaction's Ed25519 signature.
|
||||
func VerifyTx(tx *blockchain.Transaction) error {
|
||||
ok, err := Verify(tx.From, txSignBytes(tx), tx.Signature)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return errors.New("transaction signature invalid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
257
identity/identity_test.go
Normal file
257
identity/identity_test.go
Normal file
@@ -0,0 +1,257 @@
|
||||
package identity_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go-blockchain/identity"
|
||||
)
|
||||
|
||||
func mustGenerate(t *testing.T) *identity.Identity {
|
||||
t.Helper()
|
||||
id, err := identity.Generate()
|
||||
if err != nil {
|
||||
t.Fatalf("identity.Generate: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// TestGenerate checks that a freshly generated identity has non-zero keys of
|
||||
// the expected lengths.
|
||||
func TestGenerate(t *testing.T) {
|
||||
id := mustGenerate(t)
|
||||
|
||||
// Ed25519 public key: 32 bytes → 64 hex chars.
|
||||
pubHex := id.PubKeyHex()
|
||||
if len(pubHex) != 64 {
|
||||
t.Errorf("PubKeyHex length: got %d, want 64", len(pubHex))
|
||||
}
|
||||
|
||||
// Ed25519 private key (seed + pub): 64 bytes → 128 hex chars.
|
||||
privHex := id.PrivKeyHex()
|
||||
if len(privHex) != 128 {
|
||||
t.Errorf("PrivKeyHex length: got %d, want 128", len(privHex))
|
||||
}
|
||||
|
||||
// X25519 keys: 32 bytes each → 64 hex chars each.
|
||||
if len(id.X25519PubHex()) != 64 {
|
||||
t.Errorf("X25519PubHex length: got %d, want 64", len(id.X25519PubHex()))
|
||||
}
|
||||
if len(id.X25519PrivHex()) != 64 {
|
||||
t.Errorf("X25519PrivHex length: got %d, want 64", len(id.X25519PrivHex()))
|
||||
}
|
||||
|
||||
// Keys must not be all-zero strings.
|
||||
allZero := strings.Repeat("0", 64)
|
||||
if pubHex == allZero {
|
||||
t.Error("PubKeyHex is all zeros")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateUnique verifies that two Generate calls produce distinct keypairs.
|
||||
func TestGenerateUnique(t *testing.T) {
|
||||
id1 := mustGenerate(t)
|
||||
id2 := mustGenerate(t)
|
||||
|
||||
if id1.PubKeyHex() == id2.PubKeyHex() {
|
||||
t.Error("two Generate calls produced the same Ed25519 public key")
|
||||
}
|
||||
if id1.X25519PubHex() == id2.X25519PubHex() {
|
||||
t.Error("two Generate calls produced the same X25519 public key")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSignVerify signs a message and verifies that the signature is valid.
|
||||
func TestSignVerify(t *testing.T) {
|
||||
id := mustGenerate(t)
|
||||
msg := []byte("hello blockchain")
|
||||
sig := id.Sign(msg)
|
||||
|
||||
ok, err := identity.Verify(id.PubKeyHex(), msg, sig)
|
||||
if err != nil {
|
||||
t.Fatalf("Verify: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Error("Verify should return true for a valid signature")
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyWrongKey verifies that a signature fails when checked against a
|
||||
// different public key (should return false, not an error).
|
||||
func TestVerifyWrongKey(t *testing.T) {
|
||||
id1 := mustGenerate(t)
|
||||
id2 := mustGenerate(t)
|
||||
|
||||
msg := []byte("hello blockchain")
|
||||
sig := id1.Sign(msg)
|
||||
|
||||
ok, err := identity.Verify(id2.PubKeyHex(), msg, sig)
|
||||
if err != nil {
|
||||
t.Fatalf("Verify returned unexpected error: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Error("Verify should return false when checked against a different public key")
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyTamperedMessage verifies that a signature is invalid when the
|
||||
// message is modified after signing.
|
||||
func TestVerifyTamperedMessage(t *testing.T) {
|
||||
id := mustGenerate(t)
|
||||
msg := []byte("original message")
|
||||
sig := id.Sign(msg)
|
||||
|
||||
tampered := []byte("tampered message")
|
||||
ok, err := identity.Verify(id.PubKeyHex(), tampered, sig)
|
||||
if err != nil {
|
||||
t.Fatalf("Verify returned unexpected error: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Error("Verify should return false for a tampered message")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFromHexRoundTrip serialises an identity to hex, reconstructs it via
|
||||
// FromHex, and verifies that the reconstructed identity can sign and verify.
|
||||
func TestFromHexRoundTrip(t *testing.T) {
|
||||
orig := mustGenerate(t)
|
||||
|
||||
restored, err := identity.FromHex(orig.PubKeyHex(), orig.PrivKeyHex())
|
||||
if err != nil {
|
||||
t.Fatalf("FromHex: %v", err)
|
||||
}
|
||||
|
||||
if restored.PubKeyHex() != orig.PubKeyHex() {
|
||||
t.Errorf("PubKeyHex mismatch after FromHex round-trip")
|
||||
}
|
||||
|
||||
msg := []byte("round-trip test")
|
||||
sig := restored.Sign(msg)
|
||||
|
||||
ok, err := identity.Verify(orig.PubKeyHex(), msg, sig)
|
||||
if err != nil {
|
||||
t.Fatalf("Verify: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Error("signature from restored identity should verify against original public key")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFromHexFullRoundTrip serialises all four keys and reconstructs via
|
||||
// FromHexFull, checking both Ed25519 and X25519 key equality.
|
||||
func TestFromHexFullRoundTrip(t *testing.T) {
|
||||
orig := mustGenerate(t)
|
||||
|
||||
restored, err := identity.FromHexFull(
|
||||
orig.PubKeyHex(), orig.PrivKeyHex(),
|
||||
orig.X25519PubHex(), orig.X25519PrivHex(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("FromHexFull: %v", err)
|
||||
}
|
||||
|
||||
if restored.PubKeyHex() != orig.PubKeyHex() {
|
||||
t.Error("Ed25519 public key mismatch after FromHexFull round-trip")
|
||||
}
|
||||
if restored.X25519PubHex() != orig.X25519PubHex() {
|
||||
t.Error("X25519 public key mismatch after FromHexFull round-trip")
|
||||
}
|
||||
if restored.X25519PrivHex() != orig.X25519PrivHex() {
|
||||
t.Error("X25519 private key mismatch after FromHexFull round-trip")
|
||||
}
|
||||
|
||||
// Ed25519 sign+verify still works after full round-trip.
|
||||
msg := []byte("full round-trip test")
|
||||
sig := restored.Sign(msg)
|
||||
ok, err := identity.Verify(orig.PubKeyHex(), msg, sig)
|
||||
if err != nil {
|
||||
t.Fatalf("Verify: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Error("signature from fully restored identity should verify")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFromHexMissingX25519 verifies that FromHexFull with empty X25519 strings
|
||||
// derives a valid (non-zero) X25519 keypair deterministically from the Ed25519 key.
|
||||
func TestFromHexMissingX25519(t *testing.T) {
|
||||
orig := mustGenerate(t)
|
||||
|
||||
id, err := identity.FromHexFull(orig.PubKeyHex(), orig.PrivKeyHex(), "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("FromHexFull: %v", err)
|
||||
}
|
||||
|
||||
// Derived X25519 keys must be non-zero.
|
||||
allZero := strings.Repeat("0", 64)
|
||||
if id.X25519PubHex() == allZero {
|
||||
t.Error("X25519PubHex should be derived (non-zero) when empty string passed")
|
||||
}
|
||||
if id.X25519PrivHex() == allZero {
|
||||
t.Error("X25519PrivHex should be derived (non-zero) when empty string passed")
|
||||
}
|
||||
|
||||
// Calling again with the same Ed25519 key must produce the same X25519 keys (deterministic).
|
||||
id2, err := identity.FromHexFull(orig.PubKeyHex(), orig.PrivKeyHex(), "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("FromHexFull second call: %v", err)
|
||||
}
|
||||
if id.X25519PubHex() != id2.X25519PubHex() {
|
||||
t.Error("X25519 derivation is not deterministic")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMineRegistration runs proof-of-work at difficulty 16 and verifies that
|
||||
// the resulting target hash starts with "0000".
|
||||
// MineRegistration uses difficulty/4 as the number of leading hex zeros, so
|
||||
// difficulty=16 produces a 4-zero prefix. This completes in well under a second.
|
||||
func TestMineRegistration(t *testing.T) {
|
||||
id := mustGenerate(t)
|
||||
|
||||
// difficulty/4 == 4 leading hex zeros → prefix "0000".
|
||||
nonce, target, err := identity.MineRegistration(id.PubKeyHex(), 16)
|
||||
if err != nil {
|
||||
t.Fatalf("MineRegistration: %v", err)
|
||||
}
|
||||
|
||||
// The nonce is just a counter — any value is acceptable.
|
||||
_ = nonce
|
||||
|
||||
if !strings.HasPrefix(target, "0000") {
|
||||
t.Errorf("target should start with '0000' for difficulty 16, got %s", target)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterTxValid builds a REGISTER_KEY transaction at difficulty 4 and
|
||||
// verifies the signature via VerifyTx.
|
||||
func TestRegisterTxValid(t *testing.T) {
|
||||
id := mustGenerate(t)
|
||||
|
||||
tx, err := identity.RegisterTx(id, "testnode", 4)
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterTx: %v", err)
|
||||
}
|
||||
if tx == nil {
|
||||
t.Fatal("RegisterTx returned nil transaction")
|
||||
}
|
||||
|
||||
if err := identity.VerifyTx(tx); err != nil {
|
||||
t.Errorf("VerifyTx should return nil for a freshly built transaction, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestX25519KeyLengths checks that X25519PubHex and X25519PrivHex are each
|
||||
// exactly 64 hex characters (32 bytes).
|
||||
func TestX25519KeyLengths(t *testing.T) {
|
||||
id := mustGenerate(t)
|
||||
|
||||
pubHex := id.X25519PubHex()
|
||||
privHex := id.X25519PrivHex()
|
||||
|
||||
if len(pubHex) != 64 {
|
||||
t.Errorf("X25519PubHex: expected 64 hex chars (32 bytes), got %d", len(pubHex))
|
||||
}
|
||||
if len(privHex) != 64 {
|
||||
t.Errorf("X25519PrivHex: expected 64 hex chars (32 bytes), got %d", len(privHex))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user