Refactor NIP-XX Document and Protocol Implementation for Directory Consensus

- Updated the NIP-XX document to clarify terminology, replacing "attestations" with "acts" for consistency.
- Enhanced the protocol by introducing new event kinds: Trust Act (Kind 39101) and Group Tag Act (Kind 39102), with detailed specifications for their structure and usage.
- Modified the signature generation process to include the canonical WebSocket URL, ensuring proper binding and verification.
- Improved validation mechanisms for identity tags and event replication requests, reinforcing security and integrity within the directory consensus protocol.
- Added comprehensive documentation for new event types and their respective validation processes, ensuring clarity for developers and users.
- Introduced new helper functions and structures to facilitate the creation and management of directory events and acts.
This commit is contained in:
2025-10-25 12:33:47 +01:00
parent f0e89c84bd
commit 5652cec845
14 changed files with 3287 additions and 44 deletions

View File

@@ -0,0 +1,376 @@
// Package directory implements the distributed directory consensus protocol
// as defined in NIP-XX for Nostr relay operators.
//
// # Overview
//
// This package provides complete message encoding, validation, and helper
// functions for implementing the distributed directory consensus protocol.
// The protocol enables Nostr relay operators to form trusted consortiums
// that automatically synchronize essential identity-related events while
// maintaining decentralization and Byzantine fault tolerance.
//
// # Event Kinds
//
// The protocol defines six new event kinds:
//
// - 39100: Relay Identity Announcement - Announces relay participation
// - 39101: Trust Act - Creates trust relationships between relays
// - 39102: Group Tag Act - Attests to arbitrary string values
// - 39103: Public Key Advertisement - Advertises HD-derived keys
// - 39104: Directory Event Replication Request - Requests event replication
// - 39105: Directory Event Replication Response - Responds to replication requests
//
// # Directory Events
//
// The following existing event kinds are considered "directory events" and
// are automatically replicated among consortium members:
//
// - Kind 0: User Metadata
// - Kind 3: Follow Lists
// - Kind 5: Event Deletion Requests
// - Kind 1984: Reporting
// - Kind 10002: Relay List Metadata
// - Kind 10000: Mute Lists
// - Kind 10050: DM Relay Lists
//
// # Basic Usage
//
// ## Creating a Relay Identity Announcement
//
// pubkey := []byte{...} // 32-byte relay identity key
// announcement, err := directory.NewRelayIdentityAnnouncement(
// pubkey,
// "relay.example.com", // name
// "A community relay", // description
// "admin@example.com", // contact
// "wss://relay.example.com", // relay URL
// "abc123...", // signing key (hex)
// "def456...", // encryption key (hex)
// "1", // version
// "https://relay.example.com/.well-known/nostr.json", // NIP-11 URL
// )
// if err != nil {
// log.Fatal(err)
// }
//
// ## Creating a Trust Act
//
// act, err := directory.NewTrustAct(
// pubkey,
// "target_relay_pubkey_hex", // target relay
// directory.TrustLevelHigh, // trust level
// "wss://target.relay.com", // target URL
// nil, // no expiry
// directory.TrustReasonManual, // manual trust
// []uint16{1, 6, 7}, // additional kinds to replicate
// nil, // no identity tag
// )
//
// ## Creating a Public Key Advertisement
//
// validFrom := time.Now()
// validUntil := validFrom.Add(30 * 24 * time.Hour) // 30 days
//
// keyAd, err := directory.NewPublicKeyAdvertisement(
// pubkey,
// "signing-key-001", // key ID
// "fedcba9876543210...", // public key (hex)
// directory.KeyPurposeSigning, // purpose
// validFrom, // valid from
// validUntil, // valid until
// "secp256k1", // algorithm
// "m/39103'/1237'/0'/0/1", // derivation path
// 1, // key index
// nil, // no identity tag
// )
//
// # Identity Tags
//
// Identity tags (I tags) provide npub-encoded identities with proof-of-control
// signatures. They bind an identity to a specific delegate key, preventing
// unauthorized use.
//
// ## Creating Identity Tags
//
// // Create identity tag builder with private key
// identityPrivkey := []byte{...} // 32-byte private key
// builder, err := directory.NewIdentityTagBuilder(identityPrivkey)
// if err != nil {
// log.Fatal(err)
// }
//
// // Create signed identity tag for delegate key
// delegatePubkey := []byte{...} // 32-byte delegate public key
// identityTag, err := builder.CreateIdentityTag(delegatePubkey)
// if err != nil {
// log.Fatal(err)
// }
//
// // Use in trust act
// act, err := directory.NewTrustAct(
// pubkey,
// "target_relay_pubkey_hex",
// directory.TrustLevelHigh,
// "wss://target.relay.com",
// nil,
// directory.TrustReasonManual,
// []uint16{1, 6, 7},
// identityTag, // Include identity tag
// )
//
// # Validation
//
// All message types include comprehensive validation:
//
// // Validate a parsed event
// if err := announcement.Validate(); err != nil {
// log.Printf("Invalid announcement: %v", err)
// return
// }
//
// // Validate any consortium event
// if err := directory.ValidateConsortiumEvent(event); err != nil {
// log.Printf("Invalid consortium event: %v", err)
// return
// }
//
// // Verify NIP-11 binding
// valid, err := directory.ValidateRelayIdentityBinding(
// announcement,
// nip11Pubkey,
// nip11Nonce,
// nip11Sig,
// relayAddress,
// )
// if err != nil || !valid {
// log.Printf("Invalid relay identity binding")
// return
// }
//
// # Trust Calculation
//
// The package provides utilities for calculating trust relationships:
//
// // Create trust calculator
// calculator := directory.NewTrustCalculator()
//
// // Add trust acts
// calculator.AddAct(act1)
// calculator.AddAct(act2)
//
// // Get direct trust level
// level := calculator.GetTrustLevel("relay_pubkey_hex")
//
// // Calculate inherited trust
// inheritedLevel := calculator.CalculateInheritedTrust(
// "from_relay_pubkey",
// "to_relay_pubkey",
// )
//
// # Replication Filtering
//
// Determine which events should be replicated to which relays:
//
// // Create replication filter
// filter := directory.NewReplicationFilter(calculator)
// filter.AddTrustAct(act)
//
// // Check if event should be replicated
// shouldReplicate := filter.ShouldReplicate(event, "target_relay_pubkey")
//
// // Get all replication targets for an event
// targets := filter.GetReplicationTargets(event)
//
// # Event Batching
//
// Batch events for efficient replication:
//
// // Create event batcher
// batcher := directory.NewEventBatcher(100) // max 100 events per batch
//
// // Add events to batches
// batcher.AddEvent("wss://relay1.com", event1)
// batcher.AddEvent("wss://relay1.com", event2)
// batcher.AddEvent("wss://relay2.com", event3)
//
// // Check if batch is full
// if batcher.IsBatchFull("wss://relay1.com") {
// batch := batcher.FlushBatch("wss://relay1.com")
// // Send batch for replication
// }
//
// # Replication Requests and Responses
//
// ## Creating Replication Requests
//
// requestID, err := directory.GenerateRequestID()
// if err != nil {
// log.Fatal(err)
// }
//
// request, err := directory.NewDirectoryEventReplicationRequest(
// pubkey,
// requestID,
// "wss://target.relay.com",
// []*event.E{event1, event2, event3},
// )
//
// ## Creating Replication Responses
//
// // Success response
// results := []*directory.EventResult{
// directory.CreateEventResult("event_id_1", true, ""),
// directory.CreateEventResult("event_id_2", false, "duplicate event"),
// }
//
// response, err := directory.CreateSuccessResponse(
// pubkey,
// requestID,
// "wss://source.relay.com",
// results,
// )
//
// // Error response
// errorResponse, err := directory.CreateErrorResponse(
// pubkey,
// requestID,
// "wss://source.relay.com",
// "relay temporarily unavailable",
// )
//
// # Key Management
//
// The protocol uses BIP32 HD key derivation for deterministic key generation:
//
// // Create key pool manager
// masterSeed := []byte{...} // BIP39 seed
// manager := directory.NewKeyPoolManager(masterSeed, 0) // identity index 0
//
// // Generate derivation paths
// signingPath := manager.GenerateDerivationPath(directory.KeyPurposeSigning, 5)
// // Returns: "m/39103'/1237'/0'/0/5"
//
// encryptionPath := manager.GenerateDerivationPath(directory.KeyPurposeEncryption, 3)
// // Returns: "m/39103'/1237'/0'/1/3"
//
// // Track key usage
// nextIndex := manager.GetNextKeyIndex(directory.KeyPurposeSigning)
// manager.SetKeyIndex(directory.KeyPurposeSigning, 10) // Skip to index 10
//
// # Error Handling
//
// All functions return detailed errors using the errorf package:
//
// announcement, err := directory.ParseRelayIdentityAnnouncement(event)
// if err != nil {
// // Handle specific error types
// switch {
// case strings.Contains(err.Error(), "invalid event kind"):
// log.Printf("Wrong event kind: %v", err)
// case strings.Contains(err.Error(), "missing"):
// log.Printf("Missing required field: %v", err)
// default:
// log.Printf("Parse error: %v", err)
// }
// return
// }
//
// # Security Considerations
//
// The package implements several security measures:
//
// - All events must have valid signatures
// - Identity tags prevent unauthorized identity use
// - NIP-11 binding prevents relay impersonation
// - Timestamp validation prevents replay attacks
// - Content size limits prevent DoS attacks
// - Nonce validation ensures cryptographic security
//
// # Protocol Constants
//
// Important protocol constants:
//
// - MaxKeyDelegations: 512 unused key delegations per identity
// - KeyExpirationDays: 30 days for unused key delegations
// - MinNonceSize: 16 bytes minimum for nonces
// - MaxContentLength: 65536 bytes maximum for event content
//
// # Integration Example
//
// Complete example of implementing consortium membership:
//
// package main
//
// import (
// "log"
// "time"
//
// "next.orly.dev/pkg/protocol/directory"
// )
//
// func main() {
// // Generate relay identity key
// relayPrivkey := []byte{...} // 32 bytes
// relayPubkey := schnorr.PubkeyFromSeckey(relayPrivkey)
//
// // Create relay identity announcement
// announcement, err := directory.NewRelayIdentityAnnouncement(
// relayPubkey,
// "my-relay.com",
// "My Community Relay",
// "admin@my-relay.com",
// "wss://my-relay.com",
// hex.EncodeToString(signingPubkey),
// hex.EncodeToString(encryptionPubkey),
// "1",
// "https://my-relay.com/.well-known/nostr.json",
// )
// if err != nil {
// log.Fatal(err)
// }
//
// // Sign and publish announcement
// if err := announcement.Event.Sign(relayPrivkey); err != nil {
// log.Fatal(err)
// }
//
// // Create trust act for another relay
// act, err := directory.NewTrustAct(
// relayPubkey,
// "trusted_relay_pubkey_hex",
// directory.TrustLevelHigh,
// "wss://trusted-relay.com",
// nil, // no expiry
// directory.TrustReasonManual,
// []uint16{1, 6, 7}, // replicate text notes, reposts, reactions
// nil, // no identity tag
// )
// if err != nil {
// log.Fatal(err)
// }
//
// // Sign and publish act
// if err := act.Event.Sign(relayPrivkey); err != nil {
// log.Fatal(err)
// }
//
// // Set up replication filter
// calculator := directory.NewTrustCalculator()
// calculator.AddAct(act)
//
// filter := directory.NewReplicationFilter(calculator)
// filter.AddTrustAct(act)
//
// // When receiving events, check if they should be replicated
// for event := range eventChannel {
// targets := filter.GetReplicationTargets(event)
// for _, target := range targets {
// // Replicate event to target relay
// replicateEvent(event, target)
// }
// }
// }
//
// For more detailed examples and advanced usage patterns, see the test files
// and the reference implementation in the main relay codebase.
package directory

View File

@@ -0,0 +1,241 @@
package directory
import (
"strconv"
"lol.mleku.dev/chk"
"lol.mleku.dev/errorf"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/tag"
)
// GroupTagAct represents a complete Group Tag Act event
// (Kind 39102) with typed access to its components.
type GroupTagAct struct {
Event *event.E
GroupID string
TagName string
TagValue string
Actor string
Confidence int
Description string
}
// NewGroupTagAct creates a new Group Tag Act event.
func NewGroupTagAct(
pubkey []byte,
groupID, tagName, tagValue, actor string,
confidence int,
description string,
) (gta *GroupTagAct, err error) {
// Validate required fields
if len(pubkey) != 32 {
return nil, errorf.E("pubkey must be 32 bytes")
}
if groupID == "" {
return nil, errorf.E("group ID is required")
}
if tagName == "" {
return nil, errorf.E("tag name is required")
}
if tagValue == "" {
return nil, errorf.E("tag value is required")
}
if actor == "" {
return nil, errorf.E("actor is required")
}
if len(actor) != 64 {
return nil, errorf.E("actor must be 64 hex characters")
}
if confidence < 0 || confidence > 100 {
return nil, errorf.E("confidence must be between 0 and 100")
}
// Create base event
ev := CreateBaseEvent(pubkey, GroupTagActKind)
ev.Content = []byte(description)
// Add required tags
ev.Tags.Append(tag.NewFromAny(string(DTag), groupID))
ev.Tags.Append(tag.NewFromAny(string(GroupTagTag), tagName, tagValue))
ev.Tags.Append(tag.NewFromAny(string(ActorTag), actor))
ev.Tags.Append(tag.NewFromAny(string(ConfidenceTag), strconv.Itoa(confidence)))
gta = &GroupTagAct{
Event: ev,
GroupID: groupID,
TagName: tagName,
TagValue: tagValue,
Actor: actor,
Confidence: confidence,
Description: description,
}
return
}
// ParseGroupTagAct parses an event into a GroupTagAct
// structure with validation.
func ParseGroupTagAct(ev *event.E) (gta *GroupTagAct, err error) {
if ev == nil {
return nil, errorf.E("event cannot be nil")
}
// Validate event kind
if ev.Kind != GroupTagActKind.K {
return nil, errorf.E("invalid event kind: expected %d, got %d",
GroupTagActKind.K, ev.Kind)
}
// Extract required tags
dTag := ev.Tags.GetFirst(DTag)
if dTag == nil {
return nil, errorf.E("missing d tag")
}
groupTagTag := ev.Tags.GetFirst(GroupTagTag)
if groupTagTag == nil {
return nil, errorf.E("missing group_tag tag")
}
// Validate group_tag has at least 2 elements (name and value)
if groupTagTag.Len() < 3 { // "group_tag", name, value
return nil, errorf.E("group_tag must have name and value")
}
actorTag := ev.Tags.GetFirst(ActorTag)
if actorTag == nil {
return nil, errorf.E("missing actor tag")
}
confidenceTag := ev.Tags.GetFirst(ConfidenceTag)
if confidenceTag == nil {
return nil, errorf.E("missing confidence tag")
}
// Parse confidence
var confidence int
if confidence, err = strconv.Atoi(string(confidenceTag.Value())); chk.E(err) {
return nil, errorf.E("invalid confidence value: %w", err)
}
if confidence < 0 || confidence > 100 {
return nil, errorf.E("confidence must be between 0 and 100")
}
gta = &GroupTagAct{
Event: ev,
GroupID: string(dTag.Value()),
TagName: string(groupTagTag.T[1]),
TagValue: string(groupTagTag.T[2]),
Actor: string(actorTag.Value()),
Confidence: confidence,
Description: string(ev.Content),
}
return
}
// Validate performs comprehensive validation of a GroupTagAct.
func (gta *GroupTagAct) Validate() (err error) {
if gta == nil {
return errorf.E("GroupTagAct cannot be nil")
}
if gta.Event == nil {
return errorf.E("event cannot be nil")
}
// Validate event signature
if _, err = gta.Event.Verify(); chk.E(err) {
return errorf.E("invalid event signature: %w", err)
}
// Validate required fields
if gta.GroupID == "" {
return errorf.E("group ID is required")
}
if gta.TagName == "" {
return errorf.E("tag name is required")
}
if gta.TagValue == "" {
return errorf.E("tag value is required")
}
if gta.Actor == "" {
return errorf.E("actor is required")
}
if len(gta.Actor) != 64 {
return errorf.E("actor must be 64 hex characters")
}
if gta.Confidence < 0 || gta.Confidence > 100 {
return errorf.E("confidence must be between 0 and 100")
}
return nil
}
// GetGroupID returns the group identifier.
func (gta *GroupTagAct) GetGroupID() string {
return gta.GroupID
}
// GetTagName returns the tag name being attested.
func (gta *GroupTagAct) GetTagName() string {
return gta.TagName
}
// GetTagValue returns the tag value being attested.
func (gta *GroupTagAct) GetTagValue() string {
return gta.TagValue
}
// GetActor returns the public key of the relay making the act.
func (gta *GroupTagAct) GetActor() string {
return gta.Actor
}
// GetConfidence returns the confidence level (0-100) in this act.
func (gta *GroupTagAct) GetConfidence() int {
return gta.Confidence
}
// GetDescription returns the optional description of the act.
func (gta *GroupTagAct) GetDescription() string {
return gta.Description
}
// IsHighConfidence returns true if the confidence level is 80 or higher.
func (gta *GroupTagAct) IsHighConfidence() bool {
return gta.Confidence >= 80
}
// IsMediumConfidence returns true if the confidence level is between 50 and 79.
func (gta *GroupTagAct) IsMediumConfidence() bool {
return gta.Confidence >= 50 && gta.Confidence < 80
}
// IsLowConfidence returns true if the confidence level is below 50.
func (gta *GroupTagAct) IsLowConfidence() bool {
return gta.Confidence < 50
}
// MatchesTag returns true if this act matches the given tag name and value.
func (gta *GroupTagAct) MatchesTag(name, value string) bool {
return gta.TagName == name && gta.TagValue == value
}
// MatchesGroup returns true if this act belongs to the given group.
func (gta *GroupTagAct) MatchesGroup(groupID string) bool {
return gta.GroupID == groupID
}
// IsAttestedBy returns true if this act was made by the given actor.
func (gta *GroupTagAct) IsAttestedBy(actor string) bool {
return gta.Actor == actor
}

View File

@@ -0,0 +1,426 @@
package directory
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"strconv"
"strings"
"time"
"lol.mleku.dev/chk"
"lol.mleku.dev/errorf"
"next.orly.dev/pkg/crypto/ec/schnorr"
"next.orly.dev/pkg/crypto/ec/secp256k1"
"next.orly.dev/pkg/encoders/bech32encoding"
"next.orly.dev/pkg/encoders/event"
)
// IdentityTagBuilder helps construct identity tags with proper signatures.
type IdentityTagBuilder struct {
identityPrivkey []byte
identityPubkey []byte
npubIdentity string
}
// NewIdentityTagBuilder creates a new identity tag builder with the given
// identity private key.
func NewIdentityTagBuilder(identityPrivkey []byte) (builder *IdentityTagBuilder, err error) {
if len(identityPrivkey) != 32 {
return nil, errorf.E("identity private key must be 32 bytes")
}
// Derive public key from secret key
identitySecKey := secp256k1.SecKeyFromBytes(identityPrivkey)
identityPubkey := identitySecKey.PubKey()
identityPubkeyBytes := schnorr.SerializePubKey(identityPubkey)
// Encode as npub
var npubIdentity []byte
if npubIdentity, err = bech32encoding.PublicKeyToNpub(identityPubkey); chk.E(err) {
return nil, errorf.E("failed to encode npub: %w", err)
}
return &IdentityTagBuilder{
identityPrivkey: identityPrivkey,
identityPubkey: identityPubkeyBytes,
npubIdentity: string(npubIdentity),
}, nil
}
// CreateIdentityTag creates a signed identity tag for the given delegate pubkey.
func (builder *IdentityTagBuilder) CreateIdentityTag(delegatePubkey []byte) (identityTag *IdentityTag, err error) {
if len(delegatePubkey) != 32 {
return nil, errorf.E("delegate pubkey must be 32 bytes")
}
// Generate nonce
var nonceHex string
if nonceHex, err = GenerateNonceHex(16); chk.E(err) {
return nil, errorf.E("failed to generate nonce: %w", err)
}
// Create message: nonce + delegate_pubkey_hex + identity_pubkey_hex
delegatePubkeyHex := hex.EncodeToString(delegatePubkey)
identityPubkeyHex := hex.EncodeToString(builder.identityPubkey)
message := nonceHex + delegatePubkeyHex + identityPubkeyHex
// Hash and sign
hash := sha256.Sum256([]byte(message))
identitySecKey := secp256k1.SecKeyFromBytes(builder.identityPrivkey)
var sig *schnorr.Signature
if sig, err = schnorr.Sign(identitySecKey, hash[:]); chk.E(err) {
return nil, errorf.E("failed to sign identity tag: %w", err)
}
signature := sig.Serialize()
identityTag = &IdentityTag{
NPubIdentity: builder.npubIdentity,
Nonce: nonceHex,
Signature: hex.EncodeToString(signature),
}
return
}
// GetNPubIdentity returns the npub-encoded identity.
func (builder *IdentityTagBuilder) GetNPubIdentity() string {
return builder.npubIdentity
}
// GetIdentityPubkey returns the raw identity public key.
func (builder *IdentityTagBuilder) GetIdentityPubkey() []byte {
return builder.identityPubkey
}
// KeyPoolManager helps manage HD key derivation and advertisement.
type KeyPoolManager struct {
masterSeed []byte
identityIndex uint32
currentIndices map[KeyPurpose]int
}
// NewKeyPoolManager creates a new key pool manager with the given master seed.
func NewKeyPoolManager(masterSeed []byte, identityIndex uint32) *KeyPoolManager {
return &KeyPoolManager{
masterSeed: masterSeed,
identityIndex: identityIndex,
currentIndices: make(map[KeyPurpose]int),
}
}
// GenerateDerivationPath creates a BIP32 derivation path for the given purpose and index.
func (kpm *KeyPoolManager) GenerateDerivationPath(purpose KeyPurpose, index int) string {
var usageIndex int
switch purpose {
case KeyPurposeSigning:
usageIndex = 0
case KeyPurposeEncryption:
usageIndex = 1
case KeyPurposeDelegation:
usageIndex = 2
default:
usageIndex = 0
}
return fmt.Sprintf("m/39103'/1237'/%d'/%d/%d", kpm.identityIndex, usageIndex, index)
}
// GetNextKeyIndex returns the next available key index for the given purpose.
func (kpm *KeyPoolManager) GetNextKeyIndex(purpose KeyPurpose) int {
current := kpm.currentIndices[purpose]
kpm.currentIndices[purpose] = current + 1
return current
}
// SetKeyIndex sets the current key index for the given purpose.
func (kpm *KeyPoolManager) SetKeyIndex(purpose KeyPurpose, index int) {
kpm.currentIndices[purpose] = index
}
// GetCurrentKeyIndex returns the current key index for the given purpose.
func (kpm *KeyPoolManager) GetCurrentKeyIndex(purpose KeyPurpose) int {
return kpm.currentIndices[purpose]
}
// TrustCalculator helps calculate trust scores and inheritance.
type TrustCalculator struct {
acts map[string]*TrustAct
}
// NewTrustCalculator creates a new trust calculator.
func NewTrustCalculator() *TrustCalculator {
return &TrustCalculator{
acts: make(map[string]*TrustAct),
}
}
// AddAct adds a trust act to the calculator.
func (tc *TrustCalculator) AddAct(act *TrustAct) {
key := act.GetTargetPubkey()
tc.acts[key] = act
}
// GetTrustLevel returns the trust level for a given pubkey.
func (tc *TrustCalculator) GetTrustLevel(pubkey string) TrustLevel {
if act, exists := tc.acts[pubkey]; exists {
if !act.IsExpired() {
return act.GetTrustLevel()
}
}
return TrustLevel("")
}
// CalculateInheritedTrust calculates inherited trust through the web of trust.
func (tc *TrustCalculator) CalculateInheritedTrust(
fromPubkey, toPubkey string,
) TrustLevel {
// Direct trust
if directTrust := tc.GetTrustLevel(toPubkey); directTrust != "" {
return directTrust
}
// Look for inherited trust through intermediate nodes
for intermediatePubkey, act := range tc.acts {
if act.IsExpired() {
continue
}
// Check if we trust the intermediate node
intermediateLevel := tc.GetTrustLevel(intermediatePubkey)
if intermediateLevel == "" {
continue
}
// Check if intermediate node trusts the target
targetLevel := tc.GetTrustLevel(toPubkey)
if targetLevel == "" {
continue
}
// Calculate inherited trust level
return tc.combinesTrustLevels(intermediateLevel, targetLevel)
}
return TrustLevel("")
}
// combinesTrustLevels combines two trust levels to calculate inherited trust.
func (tc *TrustCalculator) combinesTrustLevels(level1, level2 TrustLevel) TrustLevel {
// Trust inheritance rules:
// high + high = medium
// high + medium = low
// medium + medium = low
// anything else = no trust
if level1 == TrustLevelHigh && level2 == TrustLevelHigh {
return TrustLevelMedium
}
if (level1 == TrustLevelHigh && level2 == TrustLevelMedium) ||
(level1 == TrustLevelMedium && level2 == TrustLevelHigh) {
return TrustLevelLow
}
if level1 == TrustLevelMedium && level2 == TrustLevelMedium {
return TrustLevelLow
}
return TrustLevel("")
}
// ReplicationFilter helps determine which events should be replicated.
type ReplicationFilter struct {
trustCalculator *TrustCalculator
acts map[string]*TrustAct
}
// NewReplicationFilter creates a new replication filter.
func NewReplicationFilter(trustCalculator *TrustCalculator) *ReplicationFilter {
return &ReplicationFilter{
trustCalculator: trustCalculator,
acts: make(map[string]*TrustAct),
}
}
// AddTrustAct adds a trust act to the filter.
func (rf *ReplicationFilter) AddTrustAct(act *TrustAct) {
rf.acts[act.GetTargetPubkey()] = act
}
// ShouldReplicate determines if an event should be replicated to a target relay.
func (rf *ReplicationFilter) ShouldReplicate(ev *event.E, targetPubkey string) bool {
act, exists := rf.acts[targetPubkey]
if !exists || act.IsExpired() {
return false
}
return act.ShouldReplicate(ev.Kind)
}
// GetReplicationTargets returns all target relays that should receive an event.
func (rf *ReplicationFilter) GetReplicationTargets(ev *event.E) []string {
var targets []string
for pubkey, act := range rf.acts {
if !act.IsExpired() && act.ShouldReplicate(ev.Kind) {
targets = append(targets, pubkey)
}
}
return targets
}
// EventBatcher helps batch events for efficient replication.
type EventBatcher struct {
maxBatchSize int
batches map[string][]*event.E
}
// NewEventBatcher creates a new event batcher.
func NewEventBatcher(maxBatchSize int) *EventBatcher {
if maxBatchSize <= 0 {
maxBatchSize = 100 // Default batch size
}
return &EventBatcher{
maxBatchSize: maxBatchSize,
batches: make(map[string][]*event.E),
}
}
// AddEvent adds an event to the batch for a target relay.
func (eb *EventBatcher) AddEvent(targetRelay string, ev *event.E) {
eb.batches[targetRelay] = append(eb.batches[targetRelay], ev)
}
// GetBatch returns the current batch for a target relay.
func (eb *EventBatcher) GetBatch(targetRelay string) []*event.E {
return eb.batches[targetRelay]
}
// IsBatchFull returns true if the batch for a target relay is full.
func (eb *EventBatcher) IsBatchFull(targetRelay string) bool {
return len(eb.batches[targetRelay]) >= eb.maxBatchSize
}
// FlushBatch returns and clears the batch for a target relay.
func (eb *EventBatcher) FlushBatch(targetRelay string) []*event.E {
batch := eb.batches[targetRelay]
eb.batches[targetRelay] = nil
return batch
}
// GetAllBatches returns all current batches.
func (eb *EventBatcher) GetAllBatches() map[string][]*event.E {
result := make(map[string][]*event.E)
for relay, batch := range eb.batches {
if len(batch) > 0 {
result[relay] = batch
}
}
return result
}
// FlushAllBatches returns and clears all batches.
func (eb *EventBatcher) FlushAllBatches() map[string][]*event.E {
result := eb.GetAllBatches()
eb.batches = make(map[string][]*event.E)
return result
}
// Utility functions
// ParseKindsList parses a comma-separated list of event kinds.
func ParseKindsList(kindsStr string) (kinds []uint16, err error) {
if kindsStr == "" {
return nil, nil
}
kindStrings := strings.Split(kindsStr, ",")
for _, kindStr := range kindStrings {
kindStr = strings.TrimSpace(kindStr)
if kindStr == "" {
continue
}
var kind uint64
if kind, err = strconv.ParseUint(kindStr, 10, 16); chk.E(err) {
return nil, errorf.E("invalid kind: %s", kindStr)
}
kinds = append(kinds, uint16(kind))
}
return
}
// FormatKindsList formats a list of event kinds as a comma-separated string.
func FormatKindsList(kinds []uint16) string {
if len(kinds) == 0 {
return ""
}
var kindStrings []string
for _, kind := range kinds {
kindStrings = append(kindStrings, strconv.FormatUint(uint64(kind), 10))
}
return strings.Join(kindStrings, ",")
}
// GenerateRequestID generates a unique request ID for replication requests.
func GenerateRequestID() (requestID string, err error) {
// Use timestamp + random nonce for uniqueness
timestamp := time.Now().Unix()
var nonce string
if nonce, err = GenerateNonceHex(8); chk.E(err) {
return
}
requestID = fmt.Sprintf("%d-%s", timestamp, nonce)
return
}
// CreateSuccessResponse creates a successful replication response.
func CreateSuccessResponse(
pubkey []byte,
requestID, sourceRelay string,
eventResults []*EventResult,
) (response *DirectoryEventReplicationResponse, err error) {
return NewDirectoryEventReplicationResponse(
pubkey,
requestID,
ReplicationStatusSuccess,
"",
sourceRelay,
eventResults,
)
}
// CreateErrorResponse creates an error replication response.
func CreateErrorResponse(
pubkey []byte,
requestID, sourceRelay, errorMsg string,
) (response *DirectoryEventReplicationResponse, err error) {
return NewDirectoryEventReplicationResponse(
pubkey,
requestID,
ReplicationStatusError,
errorMsg,
sourceRelay,
nil,
)
}
// CreateEventResult creates an event result for a replication response.
func CreateEventResult(eventID string, success bool, errorMsg string) *EventResult {
status := ReplicationStatusSuccess
if !success {
status = ReplicationStatusError
}
return &EventResult{
EventID: eventID,
Status: status,
Error: errorMsg,
}
}

View File

@@ -0,0 +1,368 @@
package directory
import (
"strconv"
"time"
"lol.mleku.dev/chk"
"lol.mleku.dev/errorf"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/tag"
)
// PublicKeyAdvertisement represents a complete Public Key Advertisement event
// (Kind 39103) with typed access to its components.
type PublicKeyAdvertisement struct {
Event *event.E
KeyID string
PublicKey string
Purpose KeyPurpose
Expiry *time.Time
Algorithm string
DerivationPath string
KeyIndex int
IdentityTag *IdentityTag
}
// NewPublicKeyAdvertisement creates a new Public Key Advertisement event.
func NewPublicKeyAdvertisement(
pubkey []byte,
keyID, publicKey string,
purpose KeyPurpose,
expiry *time.Time,
algorithm, derivationPath string,
keyIndex int,
identityTag *IdentityTag,
) (pka *PublicKeyAdvertisement, err error) {
// Validate required fields
if len(pubkey) != 32 {
return nil, errorf.E("pubkey must be 32 bytes")
}
if keyID == "" {
return nil, errorf.E("key ID is required")
}
if publicKey == "" {
return nil, errorf.E("public key is required")
}
if len(publicKey) != 64 {
return nil, errorf.E("public key must be 64 hex characters")
}
if err = ValidateKeyPurpose(string(purpose)); chk.E(err) {
return
}
// Expiry is optional, but if provided, must be in the future
if expiry != nil && expiry.Before(time.Now()) {
return nil, errorf.E("expiry time must be in the future")
}
if algorithm == "" {
algorithm = "secp256k1" // Default algorithm
}
if derivationPath == "" {
return nil, errorf.E("derivation path is required")
}
if keyIndex < 0 {
return nil, errorf.E("key index must be non-negative")
}
// Validate identity tag if provided
if identityTag != nil {
if err = identityTag.Validate(); chk.E(err) {
return
}
}
// Create base event
ev := CreateBaseEvent(pubkey, PublicKeyAdvertisementKind)
// Add required tags
ev.Tags.Append(tag.NewFromAny(string(DTag), keyID))
ev.Tags.Append(tag.NewFromAny(string(PubkeyTag), publicKey))
ev.Tags.Append(tag.NewFromAny(string(PurposeTag), string(purpose)))
ev.Tags.Append(tag.NewFromAny(string(AlgorithmTag), algorithm))
ev.Tags.Append(tag.NewFromAny(string(DerivationPathTag), derivationPath))
ev.Tags.Append(tag.NewFromAny(string(KeyIndexTag), strconv.Itoa(keyIndex)))
// Add optional expiry tag
if expiry != nil {
ev.Tags.Append(tag.NewFromAny(string(ExpiryTag), strconv.FormatInt(expiry.Unix(), 10)))
}
// Add identity tag if provided
if identityTag != nil {
ev.Tags.Append(tag.NewFromAny(string(ITag),
identityTag.NPubIdentity,
identityTag.Nonce,
identityTag.Signature))
}
pka = &PublicKeyAdvertisement{
Event: ev,
KeyID: keyID,
PublicKey: publicKey,
Purpose: purpose,
Expiry: expiry,
Algorithm: algorithm,
DerivationPath: derivationPath,
KeyIndex: keyIndex,
IdentityTag: identityTag,
}
return
}
// ParsePublicKeyAdvertisement parses an event into a PublicKeyAdvertisement
// structure with validation.
func ParsePublicKeyAdvertisement(ev *event.E) (pka *PublicKeyAdvertisement, err error) {
if ev == nil {
return nil, errorf.E("event cannot be nil")
}
// Validate event kind
if ev.Kind != PublicKeyAdvertisementKind.K {
return nil, errorf.E("invalid event kind: expected %d, got %d",
PublicKeyAdvertisementKind.K, ev.Kind)
}
// Extract required tags
dTag := ev.Tags.GetFirst(DTag)
if dTag == nil {
return nil, errorf.E("missing d tag")
}
pubkeyTag := ev.Tags.GetFirst(PubkeyTag)
if pubkeyTag == nil {
return nil, errorf.E("missing pubkey tag")
}
purposeTag := ev.Tags.GetFirst(PurposeTag)
if purposeTag == nil {
return nil, errorf.E("missing purpose tag")
}
// Parse optional expiry
var expiry *time.Time
expiryTag := ev.Tags.GetFirst(ExpiryTag)
if expiryTag != nil {
var expiryUnix int64
if expiryUnix, err = strconv.ParseInt(string(expiryTag.Value()), 10, 64); chk.E(err) {
return nil, errorf.E("invalid expiry timestamp: %w", err)
}
expiryTime := time.Unix(expiryUnix, 0)
expiry = &expiryTime
}
algorithmTag := ev.Tags.GetFirst(AlgorithmTag)
if algorithmTag == nil {
return nil, errorf.E("missing algorithm tag")
}
derivationPathTag := ev.Tags.GetFirst(DerivationPathTag)
if derivationPathTag == nil {
return nil, errorf.E("missing derivation_path tag")
}
keyIndexTag := ev.Tags.GetFirst(KeyIndexTag)
if keyIndexTag == nil {
return nil, errorf.E("missing key_index tag")
}
// Validate and parse purpose
purpose := KeyPurpose(purposeTag.Value())
if err = ValidateKeyPurpose(string(purpose)); chk.E(err) {
return
}
// Parse key index
var keyIndex int
if keyIndex, err = strconv.Atoi(string(keyIndexTag.Value())); chk.E(err) {
return nil, errorf.E("invalid key_index: %w", err)
}
// Parse identity tag (I tag)
var identityTag *IdentityTag
iTag := ev.Tags.GetFirst(ITag)
if iTag != nil {
if identityTag, err = ParseIdentityTag(iTag); chk.E(err) {
return
}
}
pka = &PublicKeyAdvertisement{
Event: ev,
KeyID: string(dTag.Value()),
PublicKey: string(pubkeyTag.Value()),
Purpose: purpose,
Expiry: expiry,
Algorithm: string(algorithmTag.Value()),
DerivationPath: string(derivationPathTag.Value()),
KeyIndex: keyIndex,
IdentityTag: identityTag,
}
return
}
// Validate performs comprehensive validation of a PublicKeyAdvertisement.
func (pka *PublicKeyAdvertisement) Validate() (err error) {
if pka == nil {
return errorf.E("PublicKeyAdvertisement cannot be nil")
}
if pka.Event == nil {
return errorf.E("event cannot be nil")
}
// Validate event signature
if _, err = pka.Event.Verify(); chk.E(err) {
return errorf.E("invalid event signature: %w", err)
}
// Validate required fields
if pka.KeyID == "" {
return errorf.E("key ID is required")
}
if pka.PublicKey == "" {
return errorf.E("public key is required")
}
if len(pka.PublicKey) != 64 {
return errorf.E("public key must be 64 hex characters")
}
if err = ValidateKeyPurpose(string(pka.Purpose)); chk.E(err) {
return
}
// Ensure no more mistakes by correcting field usage comprehensively
// Update relevant parts of the code to use Expiry instead of removed fields.
if pka.Expiry != nil && pka.Expiry.Before(time.Now()) {
return errorf.E("public key advertisement is expired")
}
// Make sure any logic that checks valid periods is now using the created_at timestamp rather than a specific validity period
// Statements using ValidFrom or ValidUntil should be revised or removed according to the new logic.
if pka.Algorithm == "" {
return errorf.E("algorithm is required")
}
if pka.DerivationPath == "" {
return errorf.E("derivation path is required")
}
if pka.KeyIndex < 0 {
return errorf.E("key index must be non-negative")
}
// Validate identity tag if present
if pka.IdentityTag != nil {
if err = pka.IdentityTag.Validate(); chk.E(err) {
return
}
}
return nil
}
// IsValid returns true if the key is currently valid (within its validity period).
func (pka *PublicKeyAdvertisement) IsValid() bool {
if pka.Expiry == nil {
return false
}
return time.Now().Before(*pka.Expiry)
}
// IsExpired returns true if the key has expired.
func (pka *PublicKeyAdvertisement) IsExpired() bool {
if pka.Expiry == nil {
return false
}
return time.Now().After(*pka.Expiry)
}
// IsNotYetValid returns true if the key is not yet valid.
func (pka *PublicKeyAdvertisement) IsNotYetValid() bool {
if pka.Expiry == nil {
return true // Consider valid if no expiry is set
}
return time.Now().Before(*pka.Expiry)
}
// TimeUntilExpiry returns the duration until the key expires.
// Returns 0 if already expired.
func (pka *PublicKeyAdvertisement) TimeUntilExpiry() time.Duration {
if pka.Expiry == nil {
return 0
}
if pka.IsExpired() {
return 0
}
return time.Until(*pka.Expiry)
}
// TimeUntilValid returns the duration until the key becomes valid.
// Returns 0 if already valid or expired.
func (pka *PublicKeyAdvertisement) TimeUntilValid() time.Duration {
if !pka.IsNotYetValid() {
return 0
}
return time.Until(*pka.Expiry)
}
// GetKeyID returns the unique key identifier.
func (pka *PublicKeyAdvertisement) GetKeyID() string {
return pka.KeyID
}
// GetPublicKey returns the hex-encoded public key.
func (pka *PublicKeyAdvertisement) GetPublicKey() string {
return pka.PublicKey
}
// GetPurpose returns the key purpose.
func (pka *PublicKeyAdvertisement) GetPurpose() KeyPurpose {
return pka.Purpose
}
// GetAlgorithm returns the cryptographic algorithm.
func (pka *PublicKeyAdvertisement) GetAlgorithm() string {
return pka.Algorithm
}
// GetDerivationPath returns the BIP32 derivation path.
func (pka *PublicKeyAdvertisement) GetDerivationPath() string {
return pka.DerivationPath
}
// GetKeyIndex returns the key index from the derivation path.
func (pka *PublicKeyAdvertisement) GetKeyIndex() int {
return pka.KeyIndex
}
// GetIdentityTag returns the identity tag, or nil if not present.
func (pka *PublicKeyAdvertisement) GetIdentityTag() *IdentityTag {
return pka.IdentityTag
}
// HasPurpose returns true if the key has the specified purpose.
func (pka *PublicKeyAdvertisement) HasPurpose(purpose KeyPurpose) bool {
return pka.Purpose == purpose
}
// IsSigningKey returns true if this is a signing key.
func (pka *PublicKeyAdvertisement) IsSigningKey() bool {
return pka.Purpose == KeyPurposeSigning
}
// IsEncryptionKey returns true if this is an encryption key.
func (pka *PublicKeyAdvertisement) IsEncryptionKey() bool {
return pka.Purpose == KeyPurposeEncryption
}
// IsDelegationKey returns true if this is a delegation key.
func (pka *PublicKeyAdvertisement) IsDelegationKey() bool {
return pka.Purpose == KeyPurposeDelegation
}

View File

@@ -0,0 +1,264 @@
package directory
import (
"encoding/json"
"lol.mleku.dev/chk"
"lol.mleku.dev/errorf"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/tag"
)
// RelayIdentityContent represents the JSON content of a Relay Identity
// Announcement event (Kind 39100).
type RelayIdentityContent struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Contact string `json:"contact,omitempty"`
}
// RelayIdentityAnnouncement represents a complete Relay Identity Announcement
// event with typed access to its components.
type RelayIdentityAnnouncement struct {
Event *event.E
Content *RelayIdentityContent
RelayURL string
SigningKey string
EncryptionKey string
Version string
NIP11URL string
}
// NewRelayIdentityAnnouncement creates a new Relay Identity Announcement event.
func NewRelayIdentityAnnouncement(
pubkey []byte,
name, description, contact string,
relayURL, signingKey, encryptionKey, version, nip11URL string,
) (ria *RelayIdentityAnnouncement, err error) {
// Validate required fields
if len(pubkey) != 32 {
return nil, errorf.E("pubkey must be 32 bytes")
}
if name == "" {
return nil, errorf.E("name is required")
}
if relayURL == "" {
return nil, errorf.E("relay URL is required")
}
if signingKey == "" {
return nil, errorf.E("signing key is required")
}
if encryptionKey == "" {
return nil, errorf.E("encryption key is required")
}
if version == "" {
version = "1" // Default version
}
if nip11URL == "" {
return nil, errorf.E("NIP-11 URL is required")
}
// Create content
content := &RelayIdentityContent{
Name: name,
Description: description,
Contact: contact,
}
// Marshal content to JSON
var contentBytes []byte
if contentBytes, err = json.Marshal(content); chk.E(err) {
return
}
// Create base event
ev := CreateBaseEvent(pubkey, RelayIdentityAnnouncementKind)
ev.Content = contentBytes
// Add required tags
ev.Tags.Append(tag.NewFromAny(string(DTag), "relay-identity"))
ev.Tags.Append(tag.NewFromAny(string(RelayTag), relayURL))
ev.Tags.Append(tag.NewFromAny(string(SigningKeyTag), signingKey))
ev.Tags.Append(tag.NewFromAny(string(EncryptionKeyTag), encryptionKey))
ev.Tags.Append(tag.NewFromAny(string(VersionTag), version))
ev.Tags.Append(tag.NewFromAny(string(NIP11URLTag), nip11URL))
ria = &RelayIdentityAnnouncement{
Event: ev,
Content: content,
RelayURL: relayURL,
SigningKey: signingKey,
EncryptionKey: encryptionKey,
Version: version,
NIP11URL: nip11URL,
}
return
}
// ParseRelayIdentityAnnouncement parses an event into a RelayIdentityAnnouncement
// structure with validation.
func ParseRelayIdentityAnnouncement(ev *event.E) (ria *RelayIdentityAnnouncement, err error) {
if ev == nil {
return nil, errorf.E("event cannot be nil")
}
// Validate event kind
if ev.Kind != RelayIdentityAnnouncementKind.K {
return nil, errorf.E("invalid event kind: expected %d, got %d",
RelayIdentityAnnouncementKind.K, ev.Kind)
}
// Parse content
var content RelayIdentityContent
if len(ev.Content) > 0 {
if err = json.Unmarshal(ev.Content, &content); chk.E(err) {
return nil, errorf.E("failed to parse content: %w", err)
}
}
// Extract required tags
dTag := ev.Tags.GetFirst(DTag)
if dTag == nil || string(dTag.Value()) != "relay-identity" {
return nil, errorf.E("missing or invalid d tag")
}
relayTag := ev.Tags.GetFirst(RelayTag)
if relayTag == nil {
return nil, errorf.E("missing relay tag")
}
signingKeyTag := ev.Tags.GetFirst(SigningKeyTag)
if signingKeyTag == nil {
return nil, errorf.E("missing signing_key tag")
}
encryptionKeyTag := ev.Tags.GetFirst(EncryptionKeyTag)
if encryptionKeyTag == nil {
return nil, errorf.E("missing encryption_key tag")
}
versionTag := ev.Tags.GetFirst(VersionTag)
if versionTag == nil {
return nil, errorf.E("missing version tag")
}
nip11URLTag := ev.Tags.GetFirst(NIP11URLTag)
if nip11URLTag == nil {
return nil, errorf.E("missing nip11_url tag")
}
ria = &RelayIdentityAnnouncement{
Event: ev,
Content: &content,
RelayURL: string(relayTag.Value()),
SigningKey: string(signingKeyTag.Value()),
EncryptionKey: string(encryptionKeyTag.Value()),
Version: string(versionTag.Value()),
NIP11URL: string(nip11URLTag.Value()),
}
return
}
// Validate performs comprehensive validation of a RelayIdentityAnnouncement.
func (ria *RelayIdentityAnnouncement) Validate() (err error) {
if ria == nil {
return errorf.E("RelayIdentityAnnouncement cannot be nil")
}
if ria.Event == nil {
return errorf.E("event cannot be nil")
}
// Validate event signature
if _, err = ria.Event.Verify(); chk.E(err) {
return errorf.E("invalid event signature: %w", err)
}
// Validate required fields
if ria.Content.Name == "" {
return errorf.E("name is required")
}
if ria.RelayURL == "" {
return errorf.E("relay URL is required")
}
if ria.SigningKey == "" {
return errorf.E("signing key is required")
}
if ria.EncryptionKey == "" {
return errorf.E("encryption key is required")
}
if ria.Version == "" {
return errorf.E("version is required")
}
if ria.NIP11URL == "" {
return errorf.E("NIP-11 URL is required")
}
// Validate hex-encoded keys (should be 64 characters for 32-byte keys)
if len(ria.SigningKey) != 64 {
return errorf.E("signing key must be 64 hex characters")
}
if len(ria.EncryptionKey) != 64 {
return errorf.E("encryption key must be 64 hex characters")
}
return nil
}
// GetRelayURL returns the relay WebSocket URL.
func (ria *RelayIdentityAnnouncement) GetRelayURL() string {
return ria.RelayURL
}
// GetSigningKey returns the hex-encoded signing public key.
func (ria *RelayIdentityAnnouncement) GetSigningKey() string {
return ria.SigningKey
}
// GetEncryptionKey returns the hex-encoded encryption public key.
func (ria *RelayIdentityAnnouncement) GetEncryptionKey() string {
return ria.EncryptionKey
}
// GetVersion returns the protocol version.
func (ria *RelayIdentityAnnouncement) GetVersion() string {
return ria.Version
}
// GetNIP11URL returns the NIP-11 information document URL.
func (ria *RelayIdentityAnnouncement) GetNIP11URL() string {
return ria.NIP11URL
}
// GetName returns the relay name from the content.
func (ria *RelayIdentityAnnouncement) GetName() string {
if ria.Content == nil {
return ""
}
return ria.Content.Name
}
// GetDescription returns the relay description from the content.
func (ria *RelayIdentityAnnouncement) GetDescription() string {
if ria.Content == nil {
return ""
}
return ria.Content.Description
}
// GetContact returns the relay contact information from the content.
func (ria *RelayIdentityAnnouncement) GetContact() string {
if ria.Content == nil {
return ""
}
return ria.Content.Contact
}

View File

@@ -0,0 +1,278 @@
package directory
import (
"encoding/json"
"lol.mleku.dev/chk"
"lol.mleku.dev/errorf"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/tag"
)
// ReplicationRequestContent represents the JSON content of a Directory Event
// Replication Request event (Kind 39104).
type ReplicationRequestContent struct {
Events []*event.E `json:"events"`
}
// DirectoryEventReplicationRequest represents a complete Directory Event
// Replication Request event (Kind 39104) with typed access to its components.
type DirectoryEventReplicationRequest struct {
Event *event.E
Content *ReplicationRequestContent
RequestID string
TargetRelay string
}
// NewDirectoryEventReplicationRequest creates a new Directory Event Replication
// Request event.
func NewDirectoryEventReplicationRequest(
pubkey []byte,
requestID, targetRelay string,
events []*event.E,
) (derr *DirectoryEventReplicationRequest, err error) {
// Validate required fields
if len(pubkey) != 32 {
return nil, errorf.E("pubkey must be 32 bytes")
}
if requestID == "" {
return nil, errorf.E("request ID is required")
}
if targetRelay == "" {
return nil, errorf.E("target relay is required")
}
if len(events) == 0 {
return nil, errorf.E("at least one event is required")
}
// Validate all events
for i, ev := range events {
if ev == nil {
return nil, errorf.E("event %d cannot be nil", i)
}
// Verify event signature
if _, err = ev.Verify(); chk.E(err) {
return nil, errorf.E("invalid signature for event %d: %w", i, err)
}
}
// Create content
content := &ReplicationRequestContent{
Events: events,
}
// Marshal content to JSON
var contentBytes []byte
if contentBytes, err = json.Marshal(content); chk.E(err) {
return
}
// Create base event
ev := CreateBaseEvent(pubkey, DirectoryEventReplicationRequestKind)
ev.Content = contentBytes
// Add required tags
ev.Tags.Append(tag.NewFromAny(string(RequestIDTag), requestID))
ev.Tags.Append(tag.NewFromAny(string(RelayTag), targetRelay))
derr = &DirectoryEventReplicationRequest{
Event: ev,
Content: content,
RequestID: requestID,
TargetRelay: targetRelay,
}
return
}
// ParseDirectoryEventReplicationRequest parses an event into a
// DirectoryEventReplicationRequest structure with validation.
func ParseDirectoryEventReplicationRequest(ev *event.E) (derr *DirectoryEventReplicationRequest, err error) {
if ev == nil {
return nil, errorf.E("event cannot be nil")
}
// Validate event kind
if ev.Kind != DirectoryEventReplicationRequestKind.K {
return nil, errorf.E("invalid event kind: expected %d, got %d",
DirectoryEventReplicationRequestKind.K, ev.Kind)
}
// Parse content
var content ReplicationRequestContent
if len(ev.Content) > 0 {
if err = json.Unmarshal(ev.Content, &content); chk.E(err) {
return nil, errorf.E("failed to parse content: %w", err)
}
}
// Extract required tags
requestIDTag := ev.Tags.GetFirst(RequestIDTag)
if requestIDTag == nil {
return nil, errorf.E("missing request_id tag")
}
relayTag := ev.Tags.GetFirst(RelayTag)
if relayTag == nil {
return nil, errorf.E("missing relay tag")
}
derr = &DirectoryEventReplicationRequest{
Event: ev,
Content: &content,
RequestID: string(requestIDTag.Value()),
TargetRelay: string(relayTag.Value()),
}
return
}
// Validate performs comprehensive validation of a DirectoryEventReplicationRequest.
func (derr *DirectoryEventReplicationRequest) Validate() (err error) {
if derr == nil {
return errorf.E("DirectoryEventReplicationRequest cannot be nil")
}
if derr.Event == nil {
return errorf.E("event cannot be nil")
}
// Validate event signature
if _, err = derr.Event.Verify(); chk.E(err) {
return errorf.E("invalid event signature: %w", err)
}
// Validate required fields
if derr.RequestID == "" {
return errorf.E("request ID is required")
}
if derr.TargetRelay == "" {
return errorf.E("target relay is required")
}
if derr.Content == nil {
return errorf.E("content cannot be nil")
}
if len(derr.Content.Events) == 0 {
return errorf.E("at least one event is required")
}
// Validate all events in the request
for i, ev := range derr.Content.Events {
if ev == nil {
return errorf.E("event %d cannot be nil", i)
}
// Verify event signature
if _, err = ev.Verify(); chk.E(err) {
return errorf.E("invalid signature for event %d: %w", i, err)
}
}
return nil
}
// GetRequestID returns the unique request identifier.
func (derr *DirectoryEventReplicationRequest) GetRequestID() string {
return derr.RequestID
}
// GetTargetRelay returns the target relay URL.
func (derr *DirectoryEventReplicationRequest) GetTargetRelay() string {
return derr.TargetRelay
}
// GetEvents returns the list of events to replicate.
func (derr *DirectoryEventReplicationRequest) GetEvents() []*event.E {
if derr.Content == nil {
return nil
}
return derr.Content.Events
}
// GetEventCount returns the number of events in the request.
func (derr *DirectoryEventReplicationRequest) GetEventCount() int {
if derr.Content == nil {
return 0
}
return len(derr.Content.Events)
}
// HasEvents returns true if the request contains events.
func (derr *DirectoryEventReplicationRequest) HasEvents() bool {
return derr.GetEventCount() > 0
}
// GetEventByIndex returns the event at the specified index, or nil if out of bounds.
func (derr *DirectoryEventReplicationRequest) GetEventByIndex(index int) *event.E {
events := derr.GetEvents()
if index < 0 || index >= len(events) {
return nil
}
return events[index]
}
// ContainsEventKind returns true if the request contains events of the specified kind.
func (derr *DirectoryEventReplicationRequest) ContainsEventKind(kind uint16) bool {
for _, ev := range derr.GetEvents() {
if ev.Kind == kind {
return true
}
}
return false
}
// GetEventsByKind returns all events of the specified kind.
func (derr *DirectoryEventReplicationRequest) GetEventsByKind(kind uint16) []*event.E {
var result []*event.E
for _, ev := range derr.GetEvents() {
if ev.Kind == kind {
result = append(result, ev)
}
}
return result
}
// GetDirectoryEvents returns only the directory events from the request.
func (derr *DirectoryEventReplicationRequest) GetDirectoryEvents() []*event.E {
var result []*event.E
for _, ev := range derr.GetEvents() {
if IsDirectoryEventKind(ev.Kind) {
result = append(result, ev)
}
}
return result
}
// GetNonDirectoryEvents returns only the non-directory events from the request.
func (derr *DirectoryEventReplicationRequest) GetNonDirectoryEvents() []*event.E {
var result []*event.E
for _, ev := range derr.GetEvents() {
if !IsDirectoryEventKind(ev.Kind) {
result = append(result, ev)
}
}
return result
}
// GetEventsByAuthor returns all events from the specified author.
func (derr *DirectoryEventReplicationRequest) GetEventsByAuthor(pubkey []byte) []*event.E {
var result []*event.E
for _, ev := range derr.GetEvents() {
if len(ev.Pubkey) == len(pubkey) {
match := true
for i := range pubkey {
if ev.Pubkey[i] != pubkey[i] {
match = false
break
}
}
if match {
result = append(result, ev)
}
}
}
return result
}

View File

@@ -0,0 +1,367 @@
package directory
import (
"encoding/json"
"lol.mleku.dev/chk"
"lol.mleku.dev/errorf"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/tag"
)
// EventResult represents the result of processing a single event in a
// replication request.
type EventResult struct {
EventID string `json:"event_id"`
Status ReplicationStatus `json:"status"`
Error string `json:"error,omitempty"`
}
// ReplicationResponseContent represents the JSON content of a Directory Event
// Replication Response event (Kind 39105).
type ReplicationResponseContent struct {
RequestID string `json:"request_id"`
Results []*EventResult `json:"results"`
}
// DirectoryEventReplicationResponse represents a complete Directory Event
// Replication Response event (Kind 39105) with typed access to its components.
type DirectoryEventReplicationResponse struct {
Event *event.E
Content *ReplicationResponseContent
RequestID string
Status ReplicationStatus
ErrorMsg string
SourceRelay string
}
// NewDirectoryEventReplicationResponse creates a new Directory Event Replication
// Response event.
func NewDirectoryEventReplicationResponse(
pubkey []byte,
requestID string,
status ReplicationStatus,
errorMsg, sourceRelay string,
results []*EventResult,
) (derr *DirectoryEventReplicationResponse, err error) {
// Validate required fields
if len(pubkey) != 32 {
return nil, errorf.E("pubkey must be 32 bytes")
}
if requestID == "" {
return nil, errorf.E("request ID is required")
}
if err = ValidateReplicationStatus(string(status)); chk.E(err) {
return
}
if sourceRelay == "" {
return nil, errorf.E("source relay is required")
}
// Create content
content := &ReplicationResponseContent{
RequestID: requestID,
Results: results,
}
// Marshal content to JSON
var contentBytes []byte
if contentBytes, err = json.Marshal(content); chk.E(err) {
return
}
// Create base event
ev := CreateBaseEvent(pubkey, DirectoryEventReplicationResponseKind)
ev.Content = contentBytes
// Add required tags
ev.Tags.Append(tag.NewFromAny(string(RequestIDTag), requestID))
ev.Tags.Append(tag.NewFromAny(string(StatusTag), string(status)))
ev.Tags.Append(tag.NewFromAny(string(RelayTag), sourceRelay))
// Add optional error tag
if errorMsg != "" {
ev.Tags.Append(tag.NewFromAny(string(ErrorTag), errorMsg))
}
derr = &DirectoryEventReplicationResponse{
Event: ev,
Content: content,
RequestID: requestID,
Status: status,
ErrorMsg: errorMsg,
SourceRelay: sourceRelay,
}
return
}
// ParseDirectoryEventReplicationResponse parses an event into a
// DirectoryEventReplicationResponse structure with validation.
func ParseDirectoryEventReplicationResponse(ev *event.E) (derr *DirectoryEventReplicationResponse, err error) {
if ev == nil {
return nil, errorf.E("event cannot be nil")
}
// Validate event kind
if ev.Kind != DirectoryEventReplicationResponseKind.K {
return nil, errorf.E("invalid event kind: expected %d, got %d",
DirectoryEventReplicationResponseKind.K, ev.Kind)
}
// Parse content
var content ReplicationResponseContent
if len(ev.Content) > 0 {
if err = json.Unmarshal(ev.Content, &content); chk.E(err) {
return nil, errorf.E("failed to parse content: %w", err)
}
}
// Extract required tags
requestIDTag := ev.Tags.GetFirst(RequestIDTag)
if requestIDTag == nil {
return nil, errorf.E("missing request_id tag")
}
statusTag := ev.Tags.GetFirst(StatusTag)
if statusTag == nil {
return nil, errorf.E("missing status tag")
}
relayTag := ev.Tags.GetFirst(RelayTag)
if relayTag == nil {
return nil, errorf.E("missing relay tag")
}
// Validate status
status := ReplicationStatus(statusTag.Value())
if err = ValidateReplicationStatus(string(status)); chk.E(err) {
return
}
// Extract optional error tag
var errorMsg string
errorTag := ev.Tags.GetFirst(ErrorTag)
if errorTag != nil {
errorMsg = string(errorTag.Value())
}
derr = &DirectoryEventReplicationResponse{
Event: ev,
Content: &content,
RequestID: string(requestIDTag.Value()),
Status: status,
ErrorMsg: errorMsg,
SourceRelay: string(relayTag.Value()),
}
return
}
// Validate performs comprehensive validation of a DirectoryEventReplicationResponse.
func (derr *DirectoryEventReplicationResponse) Validate() (err error) {
if derr == nil {
return errorf.E("DirectoryEventReplicationResponse cannot be nil")
}
if derr.Event == nil {
return errorf.E("event cannot be nil")
}
// Validate event signature
if _, err = derr.Event.Verify(); chk.E(err) {
return errorf.E("invalid event signature: %w", err)
}
// Validate required fields
if derr.RequestID == "" {
return errorf.E("request ID is required")
}
if err = ValidateReplicationStatus(string(derr.Status)); chk.E(err) {
return
}
if derr.SourceRelay == "" {
return errorf.E("source relay is required")
}
if derr.Content == nil {
return errorf.E("content cannot be nil")
}
// Validate that content request ID matches tag request ID
if derr.Content.RequestID != derr.RequestID {
return errorf.E("content request ID does not match tag request ID")
}
// Validate event results
for i, result := range derr.Content.Results {
if result == nil {
return errorf.E("result %d cannot be nil", i)
}
if result.EventID == "" {
return errorf.E("result %d missing event ID", i)
}
if err = ValidateReplicationStatus(string(result.Status)); chk.E(err) {
return errorf.E("result %d has invalid status: %w", i, err)
}
}
return nil
}
// NewEventResult creates a new EventResult.
func NewEventResult(eventID string, status ReplicationStatus, errorMsg string) *EventResult {
return &EventResult{
EventID: eventID,
Status: status,
Error: errorMsg,
}
}
// GetRequestID returns the request ID this response corresponds to.
func (derr *DirectoryEventReplicationResponse) GetRequestID() string {
return derr.RequestID
}
// GetStatus returns the overall replication status.
func (derr *DirectoryEventReplicationResponse) GetStatus() ReplicationStatus {
return derr.Status
}
// GetErrorMsg returns the error message, if any.
func (derr *DirectoryEventReplicationResponse) GetErrorMsg() string {
return derr.ErrorMsg
}
// GetSourceRelay returns the relay that sent this response.
func (derr *DirectoryEventReplicationResponse) GetSourceRelay() string {
return derr.SourceRelay
}
// GetResults returns the list of individual event results.
func (derr *DirectoryEventReplicationResponse) GetResults() []*EventResult {
if derr.Content == nil {
return nil
}
return derr.Content.Results
}
// GetResultCount returns the number of event results.
func (derr *DirectoryEventReplicationResponse) GetResultCount() int {
if derr.Content == nil {
return 0
}
return len(derr.Content.Results)
}
// HasResults returns true if the response contains event results.
func (derr *DirectoryEventReplicationResponse) HasResults() bool {
return derr.GetResultCount() > 0
}
// IsSuccess returns true if the overall replication was successful.
func (derr *DirectoryEventReplicationResponse) IsSuccess() bool {
return derr.Status == ReplicationStatusSuccess
}
// IsError returns true if the overall replication failed.
func (derr *DirectoryEventReplicationResponse) IsError() bool {
return derr.Status == ReplicationStatusError
}
// IsPending returns true if the replication is still pending.
func (derr *DirectoryEventReplicationResponse) IsPending() bool {
return derr.Status == ReplicationStatusPending
}
// GetSuccessfulResults returns all results with success status.
func (derr *DirectoryEventReplicationResponse) GetSuccessfulResults() []*EventResult {
var results []*EventResult
for _, result := range derr.GetResults() {
if result.Status == ReplicationStatusSuccess {
results = append(results, result)
}
}
return results
}
// GetFailedResults returns all results with error status.
func (derr *DirectoryEventReplicationResponse) GetFailedResults() []*EventResult {
var results []*EventResult
for _, result := range derr.GetResults() {
if result.Status == ReplicationStatusError {
results = append(results, result)
}
}
return results
}
// GetPendingResults returns all results with pending status.
func (derr *DirectoryEventReplicationResponse) GetPendingResults() []*EventResult {
var results []*EventResult
for _, result := range derr.GetResults() {
if result.Status == ReplicationStatusPending {
results = append(results, result)
}
}
return results
}
// GetResultByEventID returns the result for a specific event ID, or nil if not found.
func (derr *DirectoryEventReplicationResponse) GetResultByEventID(eventID string) *EventResult {
for _, result := range derr.GetResults() {
if result.EventID == eventID {
return result
}
}
return nil
}
// GetSuccessCount returns the number of successfully replicated events.
func (derr *DirectoryEventReplicationResponse) GetSuccessCount() int {
return len(derr.GetSuccessfulResults())
}
// GetFailureCount returns the number of failed event replications.
func (derr *DirectoryEventReplicationResponse) GetFailureCount() int {
return len(derr.GetFailedResults())
}
// GetPendingCount returns the number of pending event replications.
func (derr *DirectoryEventReplicationResponse) GetPendingCount() int {
return len(derr.GetPendingResults())
}
// GetSuccessRate returns the success rate as a percentage (0-100).
func (derr *DirectoryEventReplicationResponse) GetSuccessRate() float64 {
total := derr.GetResultCount()
if total == 0 {
return 0
}
return float64(derr.GetSuccessCount()) / float64(total) * 100
}
// EventResult methods
// IsSuccess returns true if this event result was successful.
func (er *EventResult) IsSuccess() bool {
return er.Status == ReplicationStatusSuccess
}
// IsError returns true if this event result failed.
func (er *EventResult) IsError() bool {
return er.Status == ReplicationStatusError
}
// IsPending returns true if this event result is pending.
func (er *EventResult) IsPending() bool {
return er.Status == ReplicationStatusPending
}
// HasError returns true if this event result has an error message.
func (er *EventResult) HasError() bool {
return er.Error != ""
}

View File

@@ -0,0 +1,378 @@
package directory
import (
"strconv"
"strings"
"time"
"lol.mleku.dev/chk"
"lol.mleku.dev/errorf"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/tag"
)
// TrustAct represents a complete Trust Act event (Kind 39101)
// with typed access to its components.
type TrustAct struct {
Event *event.E
TargetPubkey string
TrustLevel TrustLevel
RelayURL string
Expiry *time.Time
Reason TrustReason
ReplicationKinds []uint16
IdentityTag *IdentityTag
}
// IdentityTag represents the I tag with npub identity and proof-of-control.
type IdentityTag struct {
NPubIdentity string
Nonce string
Signature string
}
// NewTrustAct creates a new Trust Act event.
func NewTrustAct(
pubkey []byte,
targetPubkey string,
trustLevel TrustLevel,
relayURL string,
expiry *time.Time,
reason TrustReason,
replicationKinds []uint16,
identityTag *IdentityTag,
) (ta *TrustAct, err error) {
// Validate required fields
if len(pubkey) != 32 {
return nil, errorf.E("pubkey must be 32 bytes")
}
if targetPubkey == "" {
return nil, errorf.E("target pubkey is required")
}
if len(targetPubkey) != 64 {
return nil, errorf.E("target pubkey must be 64 hex characters")
}
if err = ValidateTrustLevel(string(trustLevel)); chk.E(err) {
return
}
if relayURL == "" {
return nil, errorf.E("relay URL is required")
}
// Create base event
ev := CreateBaseEvent(pubkey, TrustActKind)
// Add required tags
ev.Tags.Append(tag.NewFromAny(string(PubkeyTag), targetPubkey))
ev.Tags.Append(tag.NewFromAny(string(TrustLevelTag), string(trustLevel)))
ev.Tags.Append(tag.NewFromAny(string(RelayTag), relayURL))
// Add optional expiry
if expiry != nil {
ev.Tags.Append(tag.NewFromAny(string(ExpiryTag), strconv.FormatInt(expiry.Unix(), 10)))
}
// Add reason
if reason != "" {
ev.Tags.Append(tag.NewFromAny(string(ReasonTag), string(reason)))
}
// Add replication kinds (K tag)
if len(replicationKinds) > 0 {
var kindStrings []string
for _, k := range replicationKinds {
kindStrings = append(kindStrings, strconv.FormatUint(uint64(k), 10))
}
ev.Tags.Append(tag.NewFromAny(string(KTag), strings.Join(kindStrings, ",")))
}
// Add identity tag if provided
if identityTag != nil {
if err = identityTag.Validate(); chk.E(err) {
return
}
ev.Tags.Append(tag.NewFromAny(string(ITag),
identityTag.NPubIdentity,
identityTag.Nonce,
identityTag.Signature))
}
ta = &TrustAct{
Event: ev,
TargetPubkey: targetPubkey,
TrustLevel: trustLevel,
RelayURL: relayURL,
Expiry: expiry,
Reason: reason,
ReplicationKinds: replicationKinds,
IdentityTag: identityTag,
}
return
}
// ParseTrustAct parses an event into a TrustAct structure
// with validation.
func ParseTrustAct(ev *event.E) (ta *TrustAct, err error) {
if ev == nil {
return nil, errorf.E("event cannot be nil")
}
// Validate event kind
if ev.Kind != TrustActKind.K {
return nil, errorf.E("invalid event kind: expected %d, got %d",
TrustActKind.K, ev.Kind)
}
// Extract required tags
pTag := ev.Tags.GetFirst(PubkeyTag)
if pTag == nil {
return nil, errorf.E("missing p tag")
}
trustLevelTag := ev.Tags.GetFirst(TrustLevelTag)
if trustLevelTag == nil {
return nil, errorf.E("missing trust_level tag")
}
relayTag := ev.Tags.GetFirst(RelayTag)
if relayTag == nil {
return nil, errorf.E("missing relay tag")
}
// Validate trust level
trustLevel := TrustLevel(trustLevelTag.Value())
if err = ValidateTrustLevel(string(trustLevel)); chk.E(err) {
return
}
// Parse optional expiry
var expiry *time.Time
expiryTag := ev.Tags.GetFirst(ExpiryTag)
if expiryTag != nil {
var expiryUnix int64
if expiryUnix, err = strconv.ParseInt(string(expiryTag.Value()), 10, 64); chk.E(err) {
return nil, errorf.E("invalid expiry timestamp: %w", err)
}
expiryTime := time.Unix(expiryUnix, 0)
expiry = &expiryTime
}
// Parse optional reason
var reason TrustReason
reasonTag := ev.Tags.GetFirst(ReasonTag)
if reasonTag != nil {
reason = TrustReason(reasonTag.Value())
}
// Parse replication kinds (K tag)
var replicationKinds []uint16
kTag := ev.Tags.GetFirst(KTag)
if kTag != nil {
kindStrings := strings.Split(string(kTag.Value()), ",")
for _, kindStr := range kindStrings {
kindStr = strings.TrimSpace(kindStr)
if kindStr == "" {
continue
}
var kind uint64
if kind, err = strconv.ParseUint(kindStr, 10, 16); chk.E(err) {
return nil, errorf.E("invalid kind in K tag: %s", kindStr)
}
replicationKinds = append(replicationKinds, uint16(kind))
}
}
// Parse identity tag (I tag)
var identityTag *IdentityTag
iTag := ev.Tags.GetFirst(ITag)
if iTag != nil {
if identityTag, err = ParseIdentityTag(iTag); chk.E(err) {
return
}
}
ta = &TrustAct{
Event: ev,
TargetPubkey: string(pTag.Value()),
TrustLevel: trustLevel,
RelayURL: string(relayTag.Value()),
Expiry: expiry,
Reason: reason,
ReplicationKinds: replicationKinds,
IdentityTag: identityTag,
}
return
}
// ParseIdentityTag parses an I tag into an IdentityTag structure.
func ParseIdentityTag(t *tag.T) (it *IdentityTag, err error) {
if t == nil {
return nil, errorf.E("tag cannot be nil")
}
if t.Len() < 4 {
return nil, errorf.E("I tag must have at least 4 elements")
}
// First element should be "I"
if string(t.T[0]) != "I" {
return nil, errorf.E("invalid I tag key")
}
it = &IdentityTag{
NPubIdentity: string(t.T[1]),
Nonce: string(t.T[2]),
Signature: string(t.T[3]),
}
if err = it.Validate(); chk.E(err) {
return nil, err
}
return it, nil
}
// Validate performs validation of an IdentityTag.
func (it *IdentityTag) Validate() (err error) {
if it == nil {
return errorf.E("IdentityTag cannot be nil")
}
if it.NPubIdentity == "" {
return errorf.E("npub identity is required")
}
if !strings.HasPrefix(it.NPubIdentity, "npub1") {
return errorf.E("identity must be npub-encoded")
}
if it.Nonce == "" {
return errorf.E("nonce is required")
}
if len(it.Nonce) < 32 { // Minimum 16 bytes hex-encoded
return errorf.E("nonce must be at least 16 bytes (32 hex characters)")
}
if it.Signature == "" {
return errorf.E("signature is required")
}
if len(it.Signature) != 128 { // 64 bytes hex-encoded
return errorf.E("signature must be 64 bytes (128 hex characters)")
}
return nil
}
// Validate performs comprehensive validation of a TrustAct.
func (ta *TrustAct) Validate() (err error) {
if ta == nil {
return errorf.E("TrustAct cannot be nil")
}
if ta.Event == nil {
return errorf.E("event cannot be nil")
}
// Validate event signature
if _, err = ta.Event.Verify(); chk.E(err) {
return errorf.E("invalid event signature: %w", err)
}
// Validate required fields
if ta.TargetPubkey == "" {
return errorf.E("target pubkey is required")
}
if len(ta.TargetPubkey) != 64 {
return errorf.E("target pubkey must be 64 hex characters")
}
if err = ValidateTrustLevel(string(ta.TrustLevel)); chk.E(err) {
return
}
if ta.RelayURL == "" {
return errorf.E("relay URL is required")
}
// Validate expiry if present
if ta.Expiry != nil && ta.Expiry.Before(time.Now()) {
return errorf.E("trust act has expired")
}
// Validate identity tag if present
if ta.IdentityTag != nil {
if err = ta.IdentityTag.Validate(); chk.E(err) {
return
}
}
return nil
}
// IsExpired returns true if the trust act has expired.
func (ta *TrustAct) IsExpired() bool {
return ta.Expiry != nil && ta.Expiry.Before(time.Now())
}
// HasReplicationKind returns true if the act includes the specified
// kind for replication.
func (ta *TrustAct) HasReplicationKind(kind uint16) bool {
for _, k := range ta.ReplicationKinds {
if k == kind {
return true
}
}
return false
}
// ShouldReplicate returns true if an event of the given kind should be
// replicated based on this trust act.
func (ta *TrustAct) ShouldReplicate(kind uint16) bool {
// Directory events are always replicated
if IsDirectoryEventKind(kind) {
return true
}
// Check if kind is in the replication list
return ta.HasReplicationKind(kind)
}
// GetTargetPubkey returns the target relay's public key.
func (ta *TrustAct) GetTargetPubkey() string {
return ta.TargetPubkey
}
// GetTrustLevel returns the trust level.
func (ta *TrustAct) GetTrustLevel() TrustLevel {
return ta.TrustLevel
}
// GetRelayURL returns the target relay's URL.
func (ta *TrustAct) GetRelayURL() string {
return ta.RelayURL
}
// GetExpiry returns the expiry time, or nil if no expiry is set.
func (ta *TrustAct) GetExpiry() *time.Time {
return ta.Expiry
}
// GetReason returns the reason for the trust relationship.
func (ta *TrustAct) GetReason() TrustReason {
return ta.Reason
}
// GetReplicationKinds returns the list of event kinds to replicate.
func (ta *TrustAct) GetReplicationKinds() []uint16 {
return ta.ReplicationKinds
}
// GetIdentityTag returns the identity tag, or nil if not present.
func (ta *TrustAct) GetIdentityTag() *IdentityTag {
return ta.IdentityTag
}

View File

@@ -0,0 +1,205 @@
// Package directory provides data structures and validation for the distributed
// directory consensus protocol as defined in NIP-XX.
//
// This package implements message encoding and validation for the following
// event kinds:
// - 39100: Relay Identity Announcement
// - 39101: Trust Act
// - 39102: Group Tag Act
// - 39103: Public Key Advertisement
// - 39104: Directory Event Replication Request
// - 39105: Directory Event Replication Response
//
// # Legal Concept of Acts
//
// The term "act" in this protocol draws from legal terminology, where an act
// represents a formal declaration or testimony that has legal significance.
// Similar to legal instruments such as:
//
// - Deed Poll: A legal document binding one party to a particular course of action
// - Witness Testimony: A formal statement given under oath as evidence
// - Affidavit: A written statement confirmed by oath for use as evidence
//
// In the context of this protocol, acts serve as cryptographically signed
// declarations that establish trust relationships, group memberships, or other
// formal statements within the relay consortium. Like their legal counterparts,
// these acts:
//
// - Are formally structured with specific required elements
// - Carry the authority and responsibility of the signing party
// - Create binding relationships or obligations within the consortium
// - Can be verified for authenticity through cryptographic signatures
// - May have expiration dates or other temporal constraints
//
// This legal framework provides a conceptual foundation for understanding the
// formal nature and binding character of consortium declarations.
package directory
import (
"crypto/rand"
"encoding/hex"
"time"
"lol.mleku.dev/chk"
"lol.mleku.dev/errorf"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/kind"
"next.orly.dev/pkg/encoders/tag"
)
// Event kinds for the distributed directory consensus protocol
var (
RelayIdentityAnnouncementKind = kind.New(39100)
TrustActKind = kind.New(39101)
GroupTagActKind = kind.New(39102)
PublicKeyAdvertisementKind = kind.New(39103)
DirectoryEventReplicationRequestKind = kind.New(39104)
DirectoryEventReplicationResponseKind = kind.New(39105)
)
// Common tag names used across directory protocol messages
var (
DTag = []byte("d")
RelayTag = []byte("relay")
SigningKeyTag = []byte("signing_key")
EncryptionKeyTag = []byte("encryption_key")
VersionTag = []byte("version")
NIP11URLTag = []byte("nip11_url")
PubkeyTag = []byte("p")
TrustLevelTag = []byte("trust_level")
ExpiryTag = []byte("expiry")
ReasonTag = []byte("reason")
KTag = []byte("K")
ITag = []byte("I")
GroupTagTag = []byte("group_tag")
ActorTag = []byte("actor")
ConfidenceTag = []byte("confidence")
PurposeTag = []byte("purpose")
AlgorithmTag = []byte("algorithm")
DerivationPathTag = []byte("derivation_path")
KeyIndexTag = []byte("key_index")
RequestIDTag = []byte("request_id")
EventIDTag = []byte("event_id")
StatusTag = []byte("status")
ErrorTag = []byte("error")
)
// Trust levels for trust acts
type TrustLevel string
const (
TrustLevelHigh TrustLevel = "high"
TrustLevelMedium TrustLevel = "medium"
TrustLevelLow TrustLevel = "low"
)
// Reason types for trust establishment
type TrustReason string
const (
TrustReasonManual TrustReason = "manual"
TrustReasonAutomatic TrustReason = "automatic"
TrustReasonInherited TrustReason = "inherited"
)
// Key purposes for public key advertisements
type KeyPurpose string
const (
KeyPurposeSigning KeyPurpose = "signing"
KeyPurposeEncryption KeyPurpose = "encryption"
KeyPurposeDelegation KeyPurpose = "delegation"
)
// Replication status codes
type ReplicationStatus string
const (
ReplicationStatusSuccess ReplicationStatus = "success"
ReplicationStatusError ReplicationStatus = "error"
ReplicationStatusPending ReplicationStatus = "pending"
)
// GenerateNonce creates a cryptographically secure random nonce for use in
// identity tags and other protocol messages.
func GenerateNonce(size int) (nonce []byte, err error) {
if size <= 0 {
size = 16 // Default to 16 bytes
}
nonce = make([]byte, size)
if _, err = rand.Read(nonce); chk.E(err) {
return
}
return
}
// GenerateNonceHex creates a hex-encoded nonce of the specified byte size.
func GenerateNonceHex(size int) (nonceHex string, err error) {
var nonce []byte
if nonce, err = GenerateNonce(size); chk.E(err) {
return
}
nonceHex = hex.EncodeToString(nonce)
return
}
// IsDirectoryEventKind returns true if the given kind is a directory event
// that should always be replicated among consortium members.
//
// Directory events include:
// - Kind 0: User Metadata
// - Kind 3: Follow Lists
// - Kind 5: Event Deletion Requests
// - Kind 1984: Reporting
// - Kind 10002: Relay List Metadata
// - Kind 10000: Mute Lists
// - Kind 10050: DM Relay Lists
func IsDirectoryEventKind(k uint16) (isDirectory bool) {
switch k {
case 0, 3, 5, 1984, 10002, 10000, 10050:
return true
default:
return false
}
}
// ValidateTrustLevel checks if the provided trust level is valid.
func ValidateTrustLevel(level string) (err error) {
switch TrustLevel(level) {
case TrustLevelHigh, TrustLevelMedium, TrustLevelLow:
return nil
default:
return errorf.E("invalid trust level: %s", level)
}
}
// ValidateKeyPurpose checks if the provided key purpose is valid.
func ValidateKeyPurpose(purpose string) (err error) {
switch KeyPurpose(purpose) {
case KeyPurposeSigning, KeyPurposeEncryption, KeyPurposeDelegation:
return nil
default:
return errorf.E("invalid key purpose: %s", purpose)
}
}
// ValidateReplicationStatus checks if the provided replication status is valid.
func ValidateReplicationStatus(status string) (err error) {
switch ReplicationStatus(status) {
case ReplicationStatusSuccess, ReplicationStatusError, ReplicationStatusPending:
return nil
default:
return errorf.E("invalid replication status: %s", status)
}
}
// CreateBaseEvent creates a basic event structure with common fields set.
func CreateBaseEvent(pubkey []byte, k *kind.K) (ev *event.E) {
return &event.E{
Pubkey: pubkey,
CreatedAt: time.Now().Unix(),
Kind: k.K,
Tags: tag.NewS(),
Content: []byte(""),
}
}

View File

@@ -0,0 +1,359 @@
package directory
import (
"crypto/sha256"
"encoding/hex"
"net/url"
"regexp"
"strings"
"time"
"lol.mleku.dev/chk"
"lol.mleku.dev/errorf"
"next.orly.dev/pkg/crypto/ec/schnorr"
"next.orly.dev/pkg/crypto/ec/secp256k1"
"next.orly.dev/pkg/encoders/bech32encoding"
"next.orly.dev/pkg/encoders/event"
)
// Validation constants
const (
MaxKeyDelegations = 512
KeyExpirationDays = 30
MinNonceSize = 16 // bytes
MaxContentLength = 65536 // bytes
)
// Regular expressions for validation
var (
hexKeyRegex = regexp.MustCompile(`^[0-9a-fA-F]{64}$`)
npubRegex = regexp.MustCompile(`^npub1[0-9a-z]+$`)
wsURLRegex = regexp.MustCompile(`^wss?://[a-zA-Z0-9.-]+(?::[0-9]+)?(?:/.*)?$`)
)
// ValidateHexKey validates that a string is a valid 64-character hex key.
func ValidateHexKey(key string) (err error) {
if !hexKeyRegex.MatchString(key) {
return errorf.E("invalid hex key format: must be 64 hex characters")
}
return nil
}
// ValidateNPub validates that a string is a valid npub-encoded public key.
func ValidateNPub(npub string) (err error) {
if !npubRegex.MatchString(npub) {
return errorf.E("invalid npub format")
}
// Try to decode to verify it's valid
if _, err = bech32encoding.NpubToBytes(npub); chk.E(err) {
return errorf.E("invalid npub encoding: %w", err)
}
return nil
}
// ValidateWebSocketURL validates that a string is a valid WebSocket URL.
func ValidateWebSocketURL(wsURL string) (err error) {
if !wsURLRegex.MatchString(wsURL) {
return errorf.E("invalid WebSocket URL format")
}
// Parse URL for additional validation
var u *url.URL
if u, err = url.Parse(wsURL); chk.E(err) {
return errorf.E("invalid URL: %w", err)
}
if u.Scheme != "ws" && u.Scheme != "wss" {
return errorf.E("URL must use ws:// or wss:// scheme")
}
if u.Host == "" {
return errorf.E("URL must have a host")
}
return nil
}
// ValidateNonce validates that a nonce meets minimum security requirements.
func ValidateNonce(nonce string) (err error) {
if len(nonce) < MinNonceSize*2 { // hex-encoded, so double the byte length
return errorf.E("nonce must be at least %d bytes (%d hex characters)",
MinNonceSize, MinNonceSize*2)
}
// Verify it's valid hex
if _, err = hex.DecodeString(nonce); chk.E(err) {
return errorf.E("nonce must be valid hex: %w", err)
}
return nil
}
// ValidateSignature validates that a signature is properly formatted.
func ValidateSignature(sig string) (err error) {
if len(sig) != 128 { // 64 bytes hex-encoded
return errorf.E("signature must be 64 bytes (128 hex characters)")
}
// Verify it's valid hex
if _, err = hex.DecodeString(sig); chk.E(err) {
return errorf.E("signature must be valid hex: %w", err)
}
return nil
}
// ValidateDerivationPath validates a BIP32 derivation path for this protocol.
func ValidateDerivationPath(path string) (err error) {
// Expected format: m/39103'/1237'/identity'/usage/index
if !strings.HasPrefix(path, "m/39103'/1237'/") {
return errorf.E("derivation path must start with m/39103'/1237'/")
}
parts := strings.Split(path, "/")
if len(parts) != 6 {
return errorf.E("derivation path must have 6 components")
}
// Validate hardened components
if parts[1] != "39103'" || parts[2] != "1237'" {
return errorf.E("invalid hardened components in derivation path")
}
// Identity component should be hardened (end with ')
if !strings.HasSuffix(parts[3], "'") {
return errorf.E("identity component must be hardened")
}
return nil
}
// ValidateEventContent validates that event content is within size limits.
func ValidateEventContent(content []byte) (err error) {
if len(content) > MaxContentLength {
return errorf.E("content exceeds maximum length of %d bytes", MaxContentLength)
}
return nil
}
// ValidateTimestamp validates that a timestamp is reasonable (not too far in past/future).
func ValidateTimestamp(ts int64) (err error) {
now := time.Now().Unix()
// Allow up to 1 hour in the future
if ts > now+3600 {
return errorf.E("timestamp too far in the future")
}
// Allow up to 1 year in the past
if ts < now-31536000 {
return errorf.E("timestamp too far in the past")
}
return nil
}
// VerifyIdentityTagSignature verifies the signature in an identity tag.
func VerifyIdentityTagSignature(
identityTag *IdentityTag,
delegatePubkey []byte,
) (valid bool, err error) {
if identityTag == nil {
return false, errorf.E("identity tag cannot be nil")
}
// Decode npub to get identity public key
var identityPubkey []byte
if identityPubkey, err = bech32encoding.NpubToBytes(identityTag.NPubIdentity); chk.E(err) {
return false, errorf.E("failed to decode npub: %w", err)
}
// Decode nonce and signature
var nonce, signature []byte
if nonce, err = hex.DecodeString(identityTag.Nonce); chk.E(err) {
return false, errorf.E("invalid nonce hex: %w", err)
}
if signature, err = hex.DecodeString(identityTag.Signature); chk.E(err) {
return false, errorf.E("invalid signature hex: %w", err)
}
// Create message to verify: nonce + delegate_pubkey_hex + identity_pubkey_hex
message := make([]byte, 0, len(nonce)+64+64)
message = append(message, nonce...)
message = append(message, []byte(hex.EncodeToString(delegatePubkey))...)
message = append(message, []byte(hex.EncodeToString(identityPubkey))...)
// Hash the message
hash := sha256.Sum256(message)
// Parse signature and verify
var sig *schnorr.Signature
if sig, err = schnorr.ParseSignature(signature); chk.E(err) {
return false, errorf.E("failed to parse signature: %w", err)
}
// Parse public key
var pubKey *secp256k1.PublicKey
if pubKey, err = schnorr.ParsePubKey(identityPubkey); chk.E(err) {
return false, errorf.E("failed to parse public key: %w", err)
}
return sig.Verify(hash[:], pubKey), nil
}
// ValidateEventKindForReplication validates that an event kind is appropriate
// for replication in the directory consensus protocol.
func ValidateEventKindForReplication(kind uint16) (err error) {
// Directory events are always valid
if IsDirectoryEventKind(kind) {
return nil
}
// Protocol events (39100-39105) should not be replicated as regular events
if kind >= 39100 && kind <= 39105 {
return errorf.E("protocol events should not be replicated as directory events")
}
// Ephemeral events (20000-29999) should not be stored
if kind >= 20000 && kind <= 29999 {
return errorf.E("ephemeral events should not be replicated")
}
return nil
}
// ValidateRelayIdentityBinding verifies that a relay identity announcement
// is properly bound to its network address through NIP-11 signature verification.
func ValidateRelayIdentityBinding(
announcement *RelayIdentityAnnouncement,
nip11Pubkey, nip11Nonce, nip11Sig, relayAddress string,
) (valid bool, err error) {
if announcement == nil {
return false, errorf.E("announcement cannot be nil")
}
// Verify the announcement event pubkey matches the NIP-11 pubkey
announcementPubkeyHex := hex.EncodeToString(announcement.Event.Pubkey)
if announcementPubkeyHex != nip11Pubkey {
return false, errorf.E("announcement pubkey does not match NIP-11 pubkey")
}
// Verify NIP-11 signature format
if err = ValidateHexKey(nip11Pubkey); chk.E(err) {
return false, errorf.E("invalid NIP-11 pubkey: %w", err)
}
if err = ValidateNonce(nip11Nonce); chk.E(err) {
return false, errorf.E("invalid NIP-11 nonce: %w", err)
}
if err = ValidateSignature(nip11Sig); chk.E(err) {
return false, errorf.E("invalid NIP-11 signature: %w", err)
}
// Decode components
var pubkey, signature []byte
if pubkey, err = hex.DecodeString(nip11Pubkey); chk.E(err) {
return false, errorf.E("failed to decode NIP-11 pubkey: %w", err)
}
if signature, err = hex.DecodeString(nip11Sig); chk.E(err) {
return false, errorf.E("failed to decode NIP-11 signature: %w", err)
}
// Create message: pubkey + nonce + relay_address
message := nip11Pubkey + nip11Nonce + relayAddress
hash := sha256.Sum256([]byte(message))
// Parse signature and verify
var sig *schnorr.Signature
if sig, err = schnorr.ParseSignature(signature); chk.E(err) {
return false, errorf.E("failed to parse signature: %w", err)
}
// Parse public key
var pubKey *secp256k1.PublicKey
if pubKey, err = schnorr.ParsePubKey(pubkey); chk.E(err) {
return false, errorf.E("failed to parse public key: %w", err)
}
return sig.Verify(hash[:], pubKey), nil
}
// ValidateConsortiumEvent performs comprehensive validation of any consortium
// protocol event, including signature verification and protocol-specific checks.
func ValidateConsortiumEvent(ev *event.E) (err error) {
if ev == nil {
return errorf.E("event cannot be nil")
}
// Verify basic event signature
if _, err = ev.Verify(); chk.E(err) {
return errorf.E("invalid event signature: %w", err)
}
// Validate timestamp
if err = ValidateTimestamp(ev.CreatedAt); chk.E(err) {
return errorf.E("invalid timestamp: %w", err)
}
// Validate content size
if err = ValidateEventContent(ev.Content); chk.E(err) {
return errorf.E("invalid content: %w", err)
}
// Protocol-specific validation based on event kind
switch ev.Kind {
case RelayIdentityAnnouncementKind.K:
var ria *RelayIdentityAnnouncement
if ria, err = ParseRelayIdentityAnnouncement(ev); chk.E(err) {
return errorf.E("failed to parse relay identity announcement: %w", err)
}
return ria.Validate()
case TrustActKind.K:
var ta *TrustAct
if ta, err = ParseTrustAct(ev); chk.E(err) {
return errorf.E("failed to parse trust act: %w", err)
}
return ta.Validate()
case GroupTagActKind.K:
var gta *GroupTagAct
if gta, err = ParseGroupTagAct(ev); chk.E(err) {
return errorf.E("failed to parse group tag act: %w", err)
}
return gta.Validate()
case PublicKeyAdvertisementKind.K:
var pka *PublicKeyAdvertisement
if pka, err = ParsePublicKeyAdvertisement(ev); chk.E(err) {
return errorf.E("failed to parse public key advertisement: %w", err)
}
return pka.Validate()
case DirectoryEventReplicationRequestKind.K:
var derr *DirectoryEventReplicationRequest
if derr, err = ParseDirectoryEventReplicationRequest(ev); chk.E(err) {
return errorf.E("failed to parse replication request: %w", err)
}
return derr.Validate()
case DirectoryEventReplicationResponseKind.K:
var derr *DirectoryEventReplicationResponse
if derr, err = ParseDirectoryEventReplicationResponse(ev); chk.E(err) {
return errorf.E("failed to parse replication response: %w", err)
}
return derr.Validate()
default:
return errorf.E("unknown consortium event kind: %d", ev.Kind)
}
}
// IsConsortiumEvent returns true if the event is a consortium protocol event.
func IsConsortiumEvent(ev *event.E) bool {
if ev == nil {
return false
}
return ev.Kind >= 39100 && ev.Kind <= 39105
}