implemented and tested NIP-43 invite based ACL

This commit is contained in:
2025-11-09 10:41:58 +00:00
parent f0beb83ceb
commit d0dbd2e2dc
25 changed files with 2958 additions and 203 deletions

259
pkg/database/nip43.go Normal file
View File

@@ -0,0 +1,259 @@
package database
import (
"encoding/binary"
"fmt"
"time"
"github.com/dgraph-io/badger/v4"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
"next.orly.dev/pkg/encoders/hex"
)
// NIP43Membership represents membership metadata for NIP-43
type NIP43Membership struct {
Pubkey []byte
AddedAt time.Time
InviteCode string
}
// Database key prefixes for NIP-43
const (
nip43MemberPrefix = "nip43:member:"
nip43InvitePrefix = "nip43:invite:"
)
// AddNIP43Member adds a member to the NIP-43 membership list
func (d *D) AddNIP43Member(pubkey []byte, inviteCode string) error {
if len(pubkey) != 32 {
return fmt.Errorf("invalid pubkey length: %d", len(pubkey))
}
key := append([]byte(nip43MemberPrefix), pubkey...)
// Create membership record
membership := NIP43Membership{
Pubkey: pubkey,
AddedAt: time.Now(),
InviteCode: inviteCode,
}
// Serialize membership data
val := serializeNIP43Membership(membership)
return d.DB.Update(func(txn *badger.Txn) error {
return txn.Set(key, val)
})
}
// RemoveNIP43Member removes a member from the NIP-43 membership list
func (d *D) RemoveNIP43Member(pubkey []byte) error {
if len(pubkey) != 32 {
return fmt.Errorf("invalid pubkey length: %d", len(pubkey))
}
key := append([]byte(nip43MemberPrefix), pubkey...)
return d.DB.Update(func(txn *badger.Txn) error {
return txn.Delete(key)
})
}
// IsNIP43Member checks if a pubkey is a NIP-43 member
func (d *D) IsNIP43Member(pubkey []byte) (isMember bool, err error) {
if len(pubkey) != 32 {
return false, fmt.Errorf("invalid pubkey length: %d", len(pubkey))
}
key := append([]byte(nip43MemberPrefix), pubkey...)
err = d.DB.View(func(txn *badger.Txn) error {
_, err := txn.Get(key)
if err == badger.ErrKeyNotFound {
isMember = false
return nil
}
if err != nil {
return err
}
isMember = true
return nil
})
return isMember, err
}
// GetNIP43Membership retrieves membership details for a pubkey
func (d *D) GetNIP43Membership(pubkey []byte) (*NIP43Membership, error) {
if len(pubkey) != 32 {
return nil, fmt.Errorf("invalid pubkey length: %d", len(pubkey))
}
key := append([]byte(nip43MemberPrefix), pubkey...)
var membership *NIP43Membership
err := d.DB.View(func(txn *badger.Txn) error {
item, err := txn.Get(key)
if err != nil {
return err
}
return item.Value(func(val []byte) error {
membership = deserializeNIP43Membership(val)
return nil
})
})
if err != nil {
return nil, err
}
return membership, nil
}
// GetAllNIP43Members returns all NIP-43 members
func (d *D) GetAllNIP43Members() ([][]byte, error) {
var members [][]byte
prefix := []byte(nip43MemberPrefix)
err := d.DB.View(func(txn *badger.Txn) error {
opts := badger.DefaultIteratorOptions
opts.Prefix = prefix
opts.PrefetchValues = false // We only need keys
it := txn.NewIterator(opts)
defer it.Close()
for it.Seek(prefix); it.ValidForPrefix(prefix); it.Next() {
item := it.Item()
key := item.Key()
// Extract pubkey from key (skip prefix)
pubkey := make([]byte, 32)
copy(pubkey, key[len(prefix):])
members = append(members, pubkey)
}
return nil
})
return members, err
}
// StoreInviteCode stores an invite code with expiry
func (d *D) StoreInviteCode(code string, expiresAt time.Time) error {
key := append([]byte(nip43InvitePrefix), []byte(code)...)
// Serialize expiry time as unix timestamp
val := make([]byte, 8)
binary.BigEndian.PutUint64(val, uint64(expiresAt.Unix()))
return d.DB.Update(func(txn *badger.Txn) error {
entry := badger.NewEntry(key, val).WithTTL(time.Until(expiresAt))
return txn.SetEntry(entry)
})
}
// ValidateInviteCode checks if an invite code is valid and not expired
func (d *D) ValidateInviteCode(code string) (valid bool, err error) {
key := append([]byte(nip43InvitePrefix), []byte(code)...)
err = d.DB.View(func(txn *badger.Txn) error {
item, err := txn.Get(key)
if err == badger.ErrKeyNotFound {
valid = false
return nil
}
if err != nil {
return err
}
return item.Value(func(val []byte) error {
if len(val) != 8 {
return fmt.Errorf("invalid invite code value")
}
expiresAt := int64(binary.BigEndian.Uint64(val))
valid = time.Now().Unix() < expiresAt
return nil
})
})
return valid, err
}
// DeleteInviteCode removes an invite code (after use)
func (d *D) DeleteInviteCode(code string) error {
key := append([]byte(nip43InvitePrefix), []byte(code)...)
return d.DB.Update(func(txn *badger.Txn) error {
return txn.Delete(key)
})
}
// Helper functions for serialization
func serializeNIP43Membership(m NIP43Membership) []byte {
// Format: [pubkey(32)] [timestamp(8)] [invite_code_len(2)] [invite_code]
codeBytes := []byte(m.InviteCode)
codeLen := len(codeBytes)
buf := make([]byte, 32+8+2+codeLen)
// Copy pubkey
copy(buf[0:32], m.Pubkey)
// Write timestamp
binary.BigEndian.PutUint64(buf[32:40], uint64(m.AddedAt.Unix()))
// Write invite code length
binary.BigEndian.PutUint16(buf[40:42], uint16(codeLen))
// Write invite code
copy(buf[42:], codeBytes)
return buf
}
func deserializeNIP43Membership(data []byte) *NIP43Membership {
if len(data) < 42 {
return nil
}
m := &NIP43Membership{}
// Read pubkey
m.Pubkey = make([]byte, 32)
copy(m.Pubkey, data[0:32])
// Read timestamp
timestamp := binary.BigEndian.Uint64(data[32:40])
m.AddedAt = time.Unix(int64(timestamp), 0)
// Read invite code
codeLen := binary.BigEndian.Uint16(data[40:42])
if len(data) >= 42+int(codeLen) {
m.InviteCode = string(data[42 : 42+codeLen])
}
return m
}
// PublishNIP43MembershipEvent publishes membership change events
func (d *D) PublishNIP43MembershipEvent(kind int, pubkey []byte) error {
log.I.F("publishing NIP-43 event kind %d for pubkey %s", kind, hex.Enc(pubkey))
// Get relay identity
relaySecret, err := d.GetOrCreateRelayIdentitySecret()
if chk.E(err) {
return err
}
// This would integrate with the event publisher
// For now, just log it
log.D.F("would publish kind %d event for member %s", kind, hex.Enc(pubkey))
// The actual publishing will be done by the handler
_ = relaySecret
return nil
}

406
pkg/database/nip43_test.go Normal file
View File

@@ -0,0 +1,406 @@
package database
import (
"context"
"os"
"testing"
"time"
)
func setupNIP43TestDB(t *testing.T) (*D, func()) {
tempDir, err := os.MkdirTemp("", "nip43_test_*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
db, err := New(ctx, cancel, tempDir, "info")
if err != nil {
os.RemoveAll(tempDir)
t.Fatalf("failed to open database: %v", err)
}
cleanup := func() {
db.Close()
os.RemoveAll(tempDir)
}
return db, cleanup
}
// TestAddNIP43Member tests adding a member
func TestAddNIP43Member(t *testing.T) {
db, cleanup := setupNIP43TestDB(t)
defer cleanup()
pubkey := make([]byte, 32)
for i := range pubkey {
pubkey[i] = byte(i)
}
inviteCode := "test-invite-123"
err := db.AddNIP43Member(pubkey, inviteCode)
if err != nil {
t.Fatalf("failed to add member: %v", err)
}
// Verify member was added
isMember, err := db.IsNIP43Member(pubkey)
if err != nil {
t.Fatalf("failed to check membership: %v", err)
}
if !isMember {
t.Error("member was not added")
}
}
// TestAddNIP43Member_InvalidPubkey tests adding member with invalid pubkey
func TestAddNIP43Member_InvalidPubkey(t *testing.T) {
db, cleanup := setupNIP43TestDB(t)
defer cleanup()
// Test with wrong length
invalidPubkey := make([]byte, 16)
err := db.AddNIP43Member(invalidPubkey, "test-code")
if err == nil {
t.Error("expected error for invalid pubkey length")
}
}
// TestRemoveNIP43Member tests removing a member
func TestRemoveNIP43Member(t *testing.T) {
db, cleanup := setupNIP43TestDB(t)
defer cleanup()
pubkey := make([]byte, 32)
for i := range pubkey {
pubkey[i] = byte(i)
}
// Add member
err := db.AddNIP43Member(pubkey, "test-code")
if err != nil {
t.Fatalf("failed to add member: %v", err)
}
// Remove member
err = db.RemoveNIP43Member(pubkey)
if err != nil {
t.Fatalf("failed to remove member: %v", err)
}
// Verify member was removed
isMember, err := db.IsNIP43Member(pubkey)
if err != nil {
t.Fatalf("failed to check membership: %v", err)
}
if isMember {
t.Error("member was not removed")
}
}
// TestIsNIP43Member tests membership checking
func TestIsNIP43Member(t *testing.T) {
db, cleanup := setupNIP43TestDB(t)
defer cleanup()
pubkey := make([]byte, 32)
for i := range pubkey {
pubkey[i] = byte(i)
}
// Check non-existent member
isMember, err := db.IsNIP43Member(pubkey)
if err != nil {
t.Fatalf("failed to check membership: %v", err)
}
if isMember {
t.Error("non-existent member reported as member")
}
// Add member
err = db.AddNIP43Member(pubkey, "test-code")
if err != nil {
t.Fatalf("failed to add member: %v", err)
}
// Check existing member
isMember, err = db.IsNIP43Member(pubkey)
if err != nil {
t.Fatalf("failed to check membership: %v", err)
}
if !isMember {
t.Error("existing member not found")
}
}
// TestGetNIP43Membership tests retrieving membership details
func TestGetNIP43Membership(t *testing.T) {
db, cleanup := setupNIP43TestDB(t)
defer cleanup()
pubkey := make([]byte, 32)
for i := range pubkey {
pubkey[i] = byte(i)
}
inviteCode := "test-invite-abc123"
// Add member
beforeAdd := time.Now()
err := db.AddNIP43Member(pubkey, inviteCode)
if err != nil {
t.Fatalf("failed to add member: %v", err)
}
afterAdd := time.Now()
// Get membership
membership, err := db.GetNIP43Membership(pubkey)
if err != nil {
t.Fatalf("failed to get membership: %v", err)
}
// Verify details
if len(membership.Pubkey) != 32 {
t.Errorf("wrong pubkey length: got %d, want 32", len(membership.Pubkey))
}
for i := range pubkey {
if membership.Pubkey[i] != pubkey[i] {
t.Errorf("pubkey mismatch at index %d", i)
break
}
}
if membership.InviteCode != inviteCode {
t.Errorf("invite code mismatch: got %s, want %s", membership.InviteCode, inviteCode)
}
// Allow some tolerance for timestamp (database operations may take time)
if membership.AddedAt.Before(beforeAdd.Add(-5*time.Second)) || membership.AddedAt.After(afterAdd.Add(5*time.Second)) {
t.Errorf("AddedAt timestamp out of expected range: got %v, expected between %v and %v",
membership.AddedAt, beforeAdd, afterAdd)
}
}
// TestGetAllNIP43Members tests retrieving all members
func TestGetAllNIP43Members(t *testing.T) {
db, cleanup := setupNIP43TestDB(t)
defer cleanup()
// Add multiple members
memberCount := 5
for i := 0; i < memberCount; i++ {
pubkey := make([]byte, 32)
for j := range pubkey {
pubkey[j] = byte(i*10 + j)
}
err := db.AddNIP43Member(pubkey, "code-"+string(rune(i)))
if err != nil {
t.Fatalf("failed to add member %d: %v", i, err)
}
}
// Get all members
members, err := db.GetAllNIP43Members()
if err != nil {
t.Fatalf("failed to get all members: %v", err)
}
if len(members) != memberCount {
t.Errorf("wrong member count: got %d, want %d", len(members), memberCount)
}
// Verify each member has valid pubkey
for i, member := range members {
if len(member) != 32 {
t.Errorf("member %d has invalid pubkey length: %d", i, len(member))
}
}
}
// TestStoreInviteCode tests storing invite codes
func TestStoreInviteCode(t *testing.T) {
db, cleanup := setupNIP43TestDB(t)
defer cleanup()
code := "test-invite-xyz789"
expiresAt := time.Now().Add(24 * time.Hour)
err := db.StoreInviteCode(code, expiresAt)
if err != nil {
t.Fatalf("failed to store invite code: %v", err)
}
// Validate the code
valid, err := db.ValidateInviteCode(code)
if err != nil {
t.Fatalf("failed to validate invite code: %v", err)
}
if !valid {
t.Error("stored invite code is not valid")
}
}
// TestValidateInviteCode_Expired tests expired invite code handling
func TestValidateInviteCode_Expired(t *testing.T) {
db, cleanup := setupNIP43TestDB(t)
defer cleanup()
code := "expired-code"
expiresAt := time.Now().Add(-1 * time.Hour) // Already expired
err := db.StoreInviteCode(code, expiresAt)
if err != nil {
t.Fatalf("failed to store invite code: %v", err)
}
// Validate the code - should be invalid because it's expired
valid, err := db.ValidateInviteCode(code)
if err != nil {
t.Fatalf("failed to validate invite code: %v", err)
}
if valid {
t.Error("expired invite code reported as valid")
}
}
// TestValidateInviteCode_NonExistent tests non-existent code validation
func TestValidateInviteCode_NonExistent(t *testing.T) {
db, cleanup := setupNIP43TestDB(t)
defer cleanup()
valid, err := db.ValidateInviteCode("non-existent-code")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if valid {
t.Error("non-existent code reported as valid")
}
}
// TestDeleteInviteCode tests deleting invite codes
func TestDeleteInviteCode(t *testing.T) {
db, cleanup := setupNIP43TestDB(t)
defer cleanup()
code := "delete-me-code"
expiresAt := time.Now().Add(24 * time.Hour)
// Store code
err := db.StoreInviteCode(code, expiresAt)
if err != nil {
t.Fatalf("failed to store invite code: %v", err)
}
// Verify it exists
valid, err := db.ValidateInviteCode(code)
if err != nil {
t.Fatalf("failed to validate invite code: %v", err)
}
if !valid {
t.Error("stored code is not valid")
}
// Delete code
err = db.DeleteInviteCode(code)
if err != nil {
t.Fatalf("failed to delete invite code: %v", err)
}
// Verify it's gone
valid, err = db.ValidateInviteCode(code)
if err != nil {
t.Fatalf("failed to validate after delete: %v", err)
}
if valid {
t.Error("deleted code still valid")
}
}
// TestNIP43Membership_Serialization tests membership serialization
func TestNIP43Membership_Serialization(t *testing.T) {
pubkey := make([]byte, 32)
for i := range pubkey {
pubkey[i] = byte(i)
}
original := NIP43Membership{
Pubkey: pubkey,
AddedAt: time.Now(),
InviteCode: "test-code-123",
}
// Serialize
data := serializeNIP43Membership(original)
// Deserialize
deserialized := deserializeNIP43Membership(data)
// Verify
if deserialized == nil {
t.Fatal("deserialization returned nil")
}
if len(deserialized.Pubkey) != 32 {
t.Errorf("wrong pubkey length: got %d, want 32", len(deserialized.Pubkey))
}
for i := range pubkey {
if deserialized.Pubkey[i] != pubkey[i] {
t.Errorf("pubkey mismatch at index %d", i)
break
}
}
if deserialized.InviteCode != original.InviteCode {
t.Errorf("invite code mismatch: got %s, want %s", deserialized.InviteCode, original.InviteCode)
}
// Allow 1 second tolerance for timestamp comparison (due to Unix conversion)
timeDiff := deserialized.AddedAt.Sub(original.AddedAt)
if timeDiff < -1*time.Second || timeDiff > 1*time.Second {
t.Errorf("timestamp mismatch: got %v, want %v (diff: %v)", deserialized.AddedAt, original.AddedAt, timeDiff)
}
}
// TestNIP43Membership_ConcurrentAccess tests concurrent access to membership
func TestNIP43Membership_ConcurrentAccess(t *testing.T) {
db, cleanup := setupNIP43TestDB(t)
defer cleanup()
const goroutines = 10
const membersPerGoroutine = 5
done := make(chan bool, goroutines)
// Add members concurrently
for g := 0; g < goroutines; g++ {
go func(offset int) {
for i := 0; i < membersPerGoroutine; i++ {
pubkey := make([]byte, 32)
for j := range pubkey {
pubkey[j] = byte((offset*membersPerGoroutine+i)*10 + j)
}
if err := db.AddNIP43Member(pubkey, "code"); err != nil {
t.Errorf("failed to add member: %v", err)
}
}
done <- true
}(g)
}
// Wait for all goroutines
for i := 0; i < goroutines; i++ {
<-done
}
// Verify all members were added
members, err := db.GetAllNIP43Members()
if err != nil {
t.Fatalf("failed to get all members: %v", err)
}
expected := goroutines * membersPerGoroutine
if len(members) != expected {
t.Errorf("wrong member count: got %d, want %d", len(members), expected)
}
}