Enhance Directory Client Library for NIP-XX Protocol

- Introduced a TypeScript client library for the Distributed Directory Consensus Protocol (NIP-XX), providing a high-level API for managing directory events, identity resolution, and trust calculations.
- Implemented core functionalities including event parsing, trust score aggregation, and replication filtering, mirroring the Go implementation.
- Added comprehensive documentation and development guides for ease of use and integration.
- Updated the `.gitignore` to include additional dependencies and build artifacts for the TypeScript client.
- Enhanced validation mechanisms for group tag names and trust levels, ensuring robust input handling and security.
- Created a new `bun.lock` file to manage package dependencies effectively.
This commit is contained in:
2025-10-25 14:12:09 +01:00
parent 5652cec845
commit 8e15ca7e2f
24 changed files with 7882 additions and 87 deletions

View File

@@ -2,6 +2,7 @@ package directory
import (
"strconv"
"time"
"lol.mleku.dev/chk"
"lol.mleku.dev/errorf"
@@ -18,7 +19,49 @@ type GroupTagAct struct {
TagValue string
Actor string
Confidence int
Owners *OwnershipSpec
Created *time.Time
Description string
IdentityTag *IdentityTag
}
// OwnershipSpec defines the ownership control for a group tag.
type OwnershipSpec struct {
Scheme SignatureScheme
Owners []string // Public keys of owners
}
// SignatureScheme defines the type of signature requirement.
type SignatureScheme string
const (
SchemeSingle SignatureScheme = "single"
Scheme2of3 SignatureScheme = "2-of-3"
Scheme3of5 SignatureScheme = "3-of-5"
)
// ValidateSignatureScheme checks if a signature scheme is valid.
func ValidateSignatureScheme(scheme SignatureScheme) error {
switch scheme {
case SchemeSingle, Scheme2of3, Scheme3of5:
return nil
default:
return errorf.E("invalid signature scheme: %s", scheme)
}
}
// RequiredSignatures returns the number of signatures required for the scheme.
func (s SignatureScheme) RequiredSignatures() int {
switch s {
case SchemeSingle:
return 1
case Scheme2of3:
return 2
case Scheme3of5:
return 3
default:
return 0
}
}
// NewGroupTagAct creates a new Group Tag Act event.
@@ -26,7 +69,9 @@ func NewGroupTagAct(
pubkey []byte,
groupID, tagName, tagValue, actor string,
confidence int,
owners *OwnershipSpec,
description string,
identityTag *IdentityTag,
) (gta *GroupTagAct, err error) {
// Validate required fields
@@ -36,6 +81,12 @@ func NewGroupTagAct(
if groupID == "" {
return nil, errorf.E("group ID is required")
}
// Validate group ID is URL-safe
if err = ValidateGroupTagName(groupID); chk.E(err) {
return nil, errorf.E("invalid group ID: %w", err)
}
if tagName == "" {
return nil, errorf.E("tag name is required")
}
@@ -52,6 +103,31 @@ func NewGroupTagAct(
return nil, errorf.E("confidence must be between 0 and 100")
}
// Validate ownership spec if provided
if owners != nil {
if err = ValidateSignatureScheme(owners.Scheme); chk.E(err) {
return
}
if len(owners.Owners) == 0 {
return nil, errorf.E("at least one owner is required")
}
// Validate owner count matches scheme
switch owners.Scheme {
case SchemeSingle:
if len(owners.Owners) != 1 {
return nil, errorf.E("single scheme requires exactly 1 owner")
}
case Scheme2of3:
if len(owners.Owners) != 3 {
return nil, errorf.E("2-of-3 scheme requires exactly 3 owners")
}
case Scheme3of5:
if len(owners.Owners) != 5 {
return nil, errorf.E("3-of-5 scheme requires exactly 5 owners")
}
}
}
// Create base event
ev := CreateBaseEvent(pubkey, GroupTagActKind)
ev.Content = []byte(description)
@@ -62,6 +138,26 @@ func NewGroupTagAct(
ev.Tags.Append(tag.NewFromAny(string(ActorTag), actor))
ev.Tags.Append(tag.NewFromAny(string(ConfidenceTag), strconv.Itoa(confidence)))
// Add ownership tag if provided
if owners != nil {
ownersTagParts := make([]any, 0, len(owners.Owners)+2)
ownersTagParts = append(ownersTagParts, string(OwnersTag), string(owners.Scheme))
for _, owner := range owners.Owners {
ownersTagParts = append(ownersTagParts, owner)
}
ev.Tags.Append(tag.NewFromAny(ownersTagParts...))
}
// Add created timestamp
created := time.Now()
ev.Tags.Append(tag.NewFromAny(string(CreatedTag), strconv.FormatInt(created.Unix(), 10)))
// Add identity tag if provided
if identityTag != nil {
iTag := tag.NewFromAny(string(ITag), identityTag.NPubIdentity, identityTag.Nonce, identityTag.Signature)
ev.Tags.Append(iTag)
}
gta = &GroupTagAct{
Event: ev,
GroupID: groupID,
@@ -69,7 +165,10 @@ func NewGroupTagAct(
TagValue: tagValue,
Actor: actor,
Confidence: confidence,
Owners: owners,
Created: &created,
Description: description,
IdentityTag: identityTag,
}
return
@@ -124,6 +223,44 @@ func ParseGroupTagAct(ev *event.E) (gta *GroupTagAct, err error) {
return nil, errorf.E("confidence must be between 0 and 100")
}
// Parse optional ownership tag
var owners *OwnershipSpec
ownersTag := ev.Tags.GetFirst(OwnersTag)
if ownersTag != nil && ownersTag.Len() >= 3 {
scheme := SignatureScheme(ownersTag.T[1])
if err = ValidateSignatureScheme(scheme); chk.E(err) {
return nil, errorf.E("invalid signature scheme: %w", err)
}
ownerPubkeys := make([]string, 0, ownersTag.Len()-2)
for i := 2; i < ownersTag.Len(); i++ {
ownerPubkeys = append(ownerPubkeys, string(ownersTag.T[i]))
}
owners = &OwnershipSpec{
Scheme: scheme,
Owners: ownerPubkeys,
}
}
// Parse optional created timestamp
var created *time.Time
createdTag := ev.Tags.GetFirst(CreatedTag)
if createdTag != nil {
var timestamp int64
if timestamp, err = strconv.ParseInt(string(createdTag.Value()), 10, 64); err == nil {
t := time.Unix(timestamp, 0)
created = &t
}
}
// Parse optional identity tag
var identityTag *IdentityTag
iTag := ev.Tags.GetFirst(ITag)
if iTag != nil {
if identityTag, err = ParseIdentityTag(iTag); chk.E(err) {
return
}
}
gta = &GroupTagAct{
Event: ev,
GroupID: string(dTag.Value()),
@@ -131,7 +268,10 @@ func ParseGroupTagAct(ev *event.E) (gta *GroupTagAct, err error) {
TagValue: string(groupTagTag.T[2]),
Actor: string(actorTag.Value()),
Confidence: confidence,
Owners: owners,
Created: created,
Description: string(ev.Content),
IdentityTag: identityTag,
}
return

View File

@@ -168,19 +168,22 @@ func (tc *TrustCalculator) GetTrustLevel(pubkey string) TrustLevel {
return act.GetTrustLevel()
}
}
return TrustLevel("")
return TrustLevelNone // Return 0 for no trust
}
// CalculateInheritedTrust calculates inherited trust through the web of trust.
// With numeric trust levels, inherited trust is calculated by multiplying
// the trust percentages at each hop, reducing trust over distance.
func (tc *TrustCalculator) CalculateInheritedTrust(
fromPubkey, toPubkey string,
) TrustLevel {
// Direct trust
if directTrust := tc.GetTrustLevel(toPubkey); directTrust != "" {
if directTrust := tc.GetTrustLevel(toPubkey); directTrust > 0 {
return directTrust
}
// Look for inherited trust through intermediate nodes
var maxInheritedTrust TrustLevel = 0
for intermediatePubkey, act := range tc.acts {
if act.IsExpired() {
continue
@@ -188,43 +191,35 @@ func (tc *TrustCalculator) CalculateInheritedTrust(
// Check if we trust the intermediate node
intermediateLevel := tc.GetTrustLevel(intermediatePubkey)
if intermediateLevel == "" {
if intermediateLevel == 0 {
continue
}
// Check if intermediate node trusts the target
targetLevel := tc.GetTrustLevel(toPubkey)
if targetLevel == "" {
if targetLevel == 0 {
continue
}
// Calculate inherited trust level
return tc.combinesTrustLevels(intermediateLevel, targetLevel)
// Calculate inherited trust level (multiply percentages)
inheritedLevel := tc.combinesTrustLevels(intermediateLevel, targetLevel)
if inheritedLevel > maxInheritedTrust {
maxInheritedTrust = inheritedLevel
}
}
return TrustLevel("")
return maxInheritedTrust
}
// combinesTrustLevels combines two trust levels to calculate inherited trust.
// With numeric trust levels (0-100), inherited trust is calculated by
// multiplying the two percentages: (level1 * level2) / 100
// This naturally reduces trust over distance.
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("")
// Multiply percentages: (level1% * level2%) = (level1 * level2) / 100
// Example: 75% trust * 50% trust = 37.5% inherited trust
combined := (uint16(level1) * uint16(level2)) / 100
return TrustLevel(combined)
}
// ReplicationFilter helps determine which events should be replicated.

View File

@@ -1,6 +1,7 @@
package directory
import (
"crypto/rand"
"strconv"
"strings"
"time"
@@ -53,7 +54,7 @@ func NewTrustAct(
if len(targetPubkey) != 64 {
return nil, errorf.E("target pubkey must be 64 hex characters")
}
if err = ValidateTrustLevel(string(trustLevel)); chk.E(err) {
if err = ValidateTrustLevel(trustLevel); chk.E(err) {
return
}
if relayURL == "" {
@@ -65,7 +66,7 @@ func NewTrustAct(
// 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(TrustLevelTag), strconv.FormatUint(uint64(trustLevel), 10)))
ev.Tags.Append(tag.NewFromAny(string(RelayTag), relayURL))
// Add optional expiry
@@ -142,8 +143,12 @@ func ParseTrustAct(ev *event.E) (ta *TrustAct, err error) {
}
// Validate trust level
trustLevel := TrustLevel(trustLevelTag.Value())
if err = ValidateTrustLevel(string(trustLevel)); chk.E(err) {
var trustLevelValue uint64
if trustLevelValue, err = strconv.ParseUint(string(trustLevelTag.Value()), 10, 8); chk.E(err) {
return nil, errorf.E("invalid trust level: %w", err)
}
trustLevel := TrustLevel(trustLevelValue)
if err = ValidateTrustLevel(trustLevel); chk.E(err) {
return
}
@@ -291,7 +296,7 @@ func (ta *TrustAct) Validate() (err error) {
return errorf.E("target pubkey must be 64 hex characters")
}
if err = ValidateTrustLevel(string(ta.TrustLevel)); chk.E(err) {
if err = ValidateTrustLevel(ta.TrustLevel); chk.E(err) {
return
}
@@ -342,6 +347,39 @@ func (ta *TrustAct) ShouldReplicate(kind uint16) bool {
return ta.HasReplicationKind(kind)
}
// ShouldReplicateEvent determines whether a specific event should be replicated
// based on the trust level using partial replication (random dice-throw).
// This function uses crypto/rand for cryptographically secure randomness.
func (ta *TrustAct) ShouldReplicateEvent(kind uint16) (shouldReplicate bool, err error) {
// Check if kind is eligible for replication
if !ta.ShouldReplicate(kind) {
return false, nil
}
// Trust level of 100 means always replicate
if ta.TrustLevel == TrustLevelFull {
return true, nil
}
// Trust level of 0 means never replicate
if ta.TrustLevel == TrustLevelNone {
return false, nil
}
// Generate cryptographically secure random number 0-100
var randomBytes [1]byte
if _, err = rand.Read(randomBytes[:]); chk.E(err) {
return false, errorf.E("failed to generate random number: %w", err)
}
// Scale byte value (0-255) to 0-100 range
randomValue := uint8((uint16(randomBytes[0]) * 101) / 256)
// Replicate if random value is less than or equal to trust level
shouldReplicate = randomValue <= uint8(ta.TrustLevel)
return
}
// GetTargetPubkey returns the target relay's public key.
func (ta *TrustAct) GetTargetPubkey() string {
return ta.TargetPubkey

View File

@@ -55,42 +55,65 @@ var (
PublicKeyAdvertisementKind = kind.New(39103)
DirectoryEventReplicationRequestKind = kind.New(39104)
DirectoryEventReplicationResponseKind = kind.New(39105)
GroupTagTransferKind = kind.New(39106)
EscrowWitnessCompletionActKind = kind.New(39107)
)
// 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")
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")
OwnersTag = []byte("owners")
CreatedTag = []byte("created")
FromOwnersTag = []byte("from_owners")
ToOwnersTag = []byte("to_owners")
TransferDateTag = []byte("transfer_date")
SignaturesTag = []byte("signatures")
EscrowIDTag = []byte("escrow_id")
SellerWitnessTag = []byte("seller_witness")
BuyerWitnessTag = []byte("buyer_witness")
ConditionsTag = []byte("conditions")
WitnessRoleTag = []byte("witness_role")
CompletionStatusTag = []byte("completion_status")
VerificationHashTag = []byte("verification_hash")
TimestampTag = []byte("timestamp")
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
// TrustLevel represents the replication percentage (0-100) indicating
// the probability that any given event will be replicated.
// This implements partial replication via random selection.
type TrustLevel uint8
// Suggested trust level ranges
const (
TrustLevelHigh TrustLevel = "high"
TrustLevelMedium TrustLevel = "medium"
TrustLevelLow TrustLevel = "low"
TrustLevelNone TrustLevel = 0 // No replication
TrustLevelMinimal TrustLevel = 10 // Minimal sampling (10%)
TrustLevelLow TrustLevel = 25 // Low partial replication (25%)
TrustLevelMedium TrustLevel = 50 // Medium partial replication (50%)
TrustLevelHigh TrustLevel = 75 // High partial replication (75%)
TrustLevelFull TrustLevel = 100 // Full replication (100%)
)
// Reason types for trust establishment
@@ -163,14 +186,12 @@ func IsDirectoryEventKind(k uint16) (isDirectory bool) {
}
}
// 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)
// ValidateTrustLevel checks if the provided trust level is valid (0-100).
func ValidateTrustLevel(level TrustLevel) (err error) {
if level > 100 {
return errorf.E("invalid trust level: %d (must be 0-100)", level)
}
return nil
}
// ValidateKeyPurpose checks if the provided key purpose is valid.

View File

@@ -26,11 +26,34 @@ const (
// 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]+)?(?:/.*)?$`)
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]+)?(?:/.*)?$`)
groupTagNameRegex = regexp.MustCompile(`^[a-zA-Z0-9._~-]+$`) // RFC 3986 URL-safe characters
)
// ValidateGroupTagName validates that a group tag name is URL-safe (RFC 3986).
func ValidateGroupTagName(name string) (err error) {
if len(name) < 1 {
return errorf.E("group tag name cannot be empty")
}
if len(name) > 255 {
return errorf.E("group tag name cannot exceed 255 characters")
}
// Check for reserved prefixes
if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_") {
return errorf.E("group tag names starting with '.' or '_' are reserved for system use")
}
// Validate URL-safe character set
if !groupTagNameRegex.MatchString(name) {
return errorf.E("group tag name must contain only URL-safe characters (a-z, A-Z, 0-9, -, ., _, ~)")
}
return nil
}
// ValidateHexKey validates that a string is a valid 64-character hex key.
func ValidateHexKey(key string) (err error) {
if !hexKeyRegex.MatchString(key) {