noirc launch

This commit is contained in:
jeremyd
2025-09-18 10:52:11 -07:00
committed by jeremyd
parent 68faf82787
commit 7c8784ecdc
28 changed files with 2327 additions and 680 deletions
+331 -253
View File
@@ -24,6 +24,7 @@ import (
"github.com/ergochat/ergo/irc/email"
"github.com/ergochat/ergo/irc/migrations"
"github.com/ergochat/ergo/irc/modes"
"github.com/ergochat/ergo/irc/nostr"
"github.com/ergochat/ergo/irc/oauth2"
"github.com/ergochat/ergo/irc/passwd"
"github.com/ergochat/ergo/irc/utils"
@@ -48,6 +49,7 @@ const (
keyAccountSuspended = "account.suspended %s" // client realname stored as string
keyAccountPwReset = "account.pwreset %s"
keyAccountEmailChange = "account.emailchange %s"
keyAccountNostrIdentifier = "account.nostridentifier %s" // stores the nostr identifier used during registration
// for an always-on client, a map of channel names they're in to their current modes
// (not to be confused with their amodes, which a non-always-on client can have):
keyAccountChannelToModes = "account.channeltomodes %s"
@@ -400,7 +402,6 @@ func (am *AccountManager) Register(client *Client, account string, callbackNames
credentialsKey := fmt.Sprintf(keyAccountCredentials, casefoldedAccount)
verificationCodeKey := fmt.Sprintf(keyAccountVerificationCode, casefoldedAccount)
settingsKey := fmt.Sprintf(keyAccountSettings, casefoldedAccount)
certFPKey := fmt.Sprintf(keyCertToAccount, certfp)
var creds AccountCredentials
creds.Version = 1
@@ -446,13 +447,13 @@ func (am *AccountManager) Register(client *Client, account string, callbackNames
}
_, err = am.loadRawAccount(tx, casefoldedAccount)
if err != errAccountDoesNotExist {
return errAccountAlreadyRegistered
if err != nil && err != errAccountDoesNotExist {
return err
}
if certfp != "" {
// make sure certfp doesn't already exist because that'd be silly
_, err := tx.Get(certFPKey)
_, err := tx.Get(fmt.Sprintf(keyCertToAccount, certfp))
if err != buntdb.ErrNotFound {
return errCertfpAlreadyExists
}
@@ -463,9 +464,6 @@ func (am *AccountManager) Register(client *Client, account string, callbackNames
tx.Set(registeredTimeKey, registeredTimeStr, setOptions)
tx.Set(credentialsKey, credStr, setOptions)
tx.Set(settingsKey, settingsStr, setOptions)
if certfp != "" {
tx.Set(certFPKey, casefoldedAccount, setOptions)
}
return nil
})
}()
@@ -751,6 +749,26 @@ func (am *AccountManager) loadPushSubscriptions(account string) (result []stored
}
}
func (am *AccountManager) saveNostrIdentifier(account string, nostrIdentifier string) {
key := fmt.Sprintf(keyAccountNostrIdentifier, account)
am.server.logger.Info("nostr-hostname", "Saving nostr identifier:", nostrIdentifier, "for account:", account, "with key:", key)
am.server.store.Update(func(tx *buntdb.Tx) error {
tx.Set(key, nostrIdentifier, nil)
return nil
})
}
func (am *AccountManager) loadNostrIdentifier(account string) (nostrIdentifier string) {
key := fmt.Sprintf(keyAccountNostrIdentifier, account)
am.server.logger.Info("nostr-hostname", "Loading nostr identifier for account:", account, "with key:", key)
am.server.store.View(func(tx *buntdb.Tx) error {
nostrIdentifier, _ = tx.Get(key)
return nil
})
am.server.logger.Info("nostr-hostname", "Loaded nostr identifier:", nostrIdentifier, "for account:", account)
return
}
func (am *AccountManager) addRemoveCertfp(account, certfp string, add bool, hasPrivs bool) (err error) {
certfp, err = utils.NormalizeCertfp(certfp)
if err != nil {
@@ -801,20 +819,18 @@ func (am *AccountManager) addRemoveCertfp(account, certfp string, add bool, hasP
return err
}
certfpKey := fmt.Sprintf(keyCertToAccount, certfp)
err = am.server.store.Update(func(tx *buntdb.Tx) error {
curCredStr, err := tx.Get(credKey)
if credStr != curCredStr {
return errCASFailed
}
if add {
_, err = tx.Get(certfpKey)
_, err = tx.Get(fmt.Sprintf(keyCertToAccount, certfp))
if err != buntdb.ErrNotFound {
return errCertfpAlreadyExists
}
tx.Set(certfpKey, cfAccount, nil)
} else {
tx.Delete(certfpKey)
tx.Delete(fmt.Sprintf(keyCertToAccount, certfp))
}
_, _, err = tx.Set(credKey, newCredStr, nil)
return err
@@ -828,6 +844,8 @@ func (am *AccountManager) dispatchCallback(client *Client, account string, callb
return "", nil
} else if callbackNamespace == "mailto" {
return am.dispatchMailtoCallback(client, account, callbackValue)
} else if callbackNamespace == "nostr" {
return am.dispatchNostrCallback(client, account, callbackValue)
} else {
return "", fmt.Errorf("Callback not implemented: %s", callbackNamespace)
}
@@ -839,7 +857,7 @@ func (am *AccountManager) dispatchMailtoCallback(client *Client, account string,
subject := config.VerifyMessageSubject
if subject == "" {
subject = fmt.Sprintf(client.t("Verify your account on %s"), am.server.name)
subject = fmt.Sprintf(client.t("Verify your account on %s"), am.server.Config().Server.Name)
}
message := email.ComposeMail(config, callbackValue, subject)
@@ -859,6 +877,35 @@ func (am *AccountManager) dispatchMailtoCallback(client *Client, account string,
return
}
func (am *AccountManager) dispatchNostrCallback(_ *Client, account string, callbackValue string) (code string, err error) {
config := am.server.Config().Accounts.Registration.NostrVerification
if !config.Enabled {
return "", fmt.Errorf("Nostr verification is not enabled")
}
code = utils.GenerateSecretToken()
// Create DM config from server config
dmConfig := nostr.DMConfig{
PrivateKey: config.PrivateKey,
DefaultRelays: config.DefaultRelays,
Timeout: time.Duration(config.Timeout),
UserAgent: fmt.Sprintf("Ergo IRC Server %s", am.server.Config().Server.Name),
}
// Send the verification DM
err = nostr.SendVerificationDM(callbackValue, account, code, am.server.Config().Server.Name, dmConfig)
if err != nil {
am.server.logger.Error("internal", "Failed to dispatch nostr DM to", callbackValue, err.Error())
return "", err
}
// Save the nostr identifier for hostname generation
am.saveNostrIdentifier(account, callbackValue)
return code, nil
}
func (am *AccountManager) Verify(client *Client, account string, code string, admin bool) error {
casefoldedAccount, err := CasefoldName(account)
var skeleton string
@@ -949,15 +996,6 @@ func (am *AccountManager) Verify(client *Client, account string, code string, ad
tx.Set(credentialsKey, raw.Credentials, nil)
tx.Set(settingsKey, raw.Settings, nil)
var creds AccountCredentials
// XXX we shouldn't do (de)serialization inside the txn,
// but this is like 2 usec on my system
json.Unmarshal([]byte(raw.Credentials), &creds)
for _, cert := range creds.Certfps {
certFPKey := fmt.Sprintf(keyCertToAccount, cert)
tx.Set(certFPKey, casefoldedAccount, nil)
}
return nil
})
@@ -993,7 +1031,7 @@ func (am *AccountManager) Verify(client *Client, account string, code string, ad
_, method := am.EnforcementStatus(casefoldedAccount, skeleton)
if method == NickEnforcementStrict {
currentClient := am.server.clients.Get(casefoldedAccount)
if currentClient != nil && currentClient != client && currentClient.Account() != casefoldedAccount {
if currentClient != nil && currentClient.AlwaysOn() {
am.server.RandomlyRename(currentClient)
}
}
@@ -1038,6 +1076,7 @@ func (am *AccountManager) NsSetEmail(client *Client, emailAddr string) (err erro
recordKey := fmt.Sprintf(keyAccountEmailChange, casefoldedAccount)
recordBytes, _ := json.Marshal(record)
recordVal := string(recordBytes)
am.server.store.Update(func(tx *buntdb.Tx) error {
tx.Set(recordKey, recordVal, nil)
return nil
@@ -1049,8 +1088,8 @@ func (am *AccountManager) NsSetEmail(client *Client, emailAddr string) (err erro
message := email.ComposeMail(config.Accounts.Registration.EmailVerification,
emailAddr,
fmt.Sprintf(client.t("Verify your change of e-mail address on %s"), am.server.name))
message.WriteString(fmt.Sprintf(client.t("To confirm your change of e-mail address on %s, issue the following command:"), am.server.name))
fmt.Sprintf(client.t("Verify your change of e-mail address on %s"), am.server.Config().Server.Name))
message.WriteString(fmt.Sprintf(client.t("To confirm your change of e-mail address on %s, issue the following command:"), am.server.Config().Server.Name))
message.WriteString("\r\n")
fmt.Fprintf(&message, "/MSG NickServ VERIFYEMAIL %s\r\n", record.Code)
@@ -1153,9 +1192,9 @@ func (am *AccountManager) NsSendpass(client *Client, accountName string) (err er
return
}
subject := fmt.Sprintf(client.t("Reset your password on %s"), am.server.name)
subject := fmt.Sprintf(client.t("Reset your password on %s"), am.server.Config().Server.Name)
message := email.ComposeMail(config.Accounts.Registration.EmailVerification, account.Settings.Email, subject)
fmt.Fprintf(&message, client.t("We received a request to reset your password on %[1]s for account: %[2]s"), am.server.name, account.Name)
fmt.Fprintf(&message, client.t("We received a request to reset your password on %[1]s for account: %[2]s"), am.server.Config().Server.Name, account.Name)
message.WriteString("\r\n")
message.WriteString(client.t("If you did not initiate this request, you can safely ignore this message."))
message.WriteString("\r\n")
@@ -1181,7 +1220,7 @@ func (am *AccountManager) NsResetpass(client *Client, accountName, code, passwor
}
account, err := am.LoadAccount(accountName)
if err != nil {
return
return err
}
if !account.Verified {
return errAccountUnverified
@@ -1842,6 +1881,7 @@ func (am *AccountManager) Rename(oldName, newName string) (err error) {
tx.Set(key, newName, nil)
return nil
})
if err != nil {
return err
}
@@ -1880,6 +1920,7 @@ func (am *AccountManager) Unregister(account string, erase bool) error {
pwResetKey := fmt.Sprintf(keyAccountPwReset, casefoldedAccount)
emailChangeKey := fmt.Sprintf(keyAccountEmailChange, casefoldedAccount)
pushSubscriptionsKey := fmt.Sprintf(keyAccountPushSubscriptions, casefoldedAccount)
nostrIdentifierKey := fmt.Sprintf(keyAccountNostrIdentifier, casefoldedAccount)
var clients []*Client
defer func() {
@@ -1939,22 +1980,25 @@ func (am *AccountManager) Unregister(account string, erase bool) error {
tx.Delete(pwResetKey)
tx.Delete(emailChangeKey)
tx.Delete(pushSubscriptionsKey)
tx.Delete(nostrIdentifierKey)
return nil
})
if err == nil {
var creds AccountCredentials
if err := json.Unmarshal([]byte(credText), &creds); err == nil {
for _, cert := range creds.Certfps {
certFPKey := fmt.Sprintf(keyCertToAccount, cert)
am.server.store.Update(func(tx *buntdb.Tx) error {
if account, err := tx.Get(certFPKey); err == nil && account == casefoldedAccount {
tx.Delete(certFPKey)
}
return nil
})
}
if err != nil {
return err
}
var creds AccountCredentials
if err := json.Unmarshal([]byte(credText), &creds); err == nil {
for _, cert := range creds.Certfps {
certFPKey := fmt.Sprintf(keyCertToAccount, cert)
am.server.store.Update(func(tx *buntdb.Tx) error {
if account, err := tx.Get(certFPKey); err == nil && account == casefoldedAccount {
tx.Delete(certFPKey)
}
return nil
})
}
}
@@ -1991,229 +2035,40 @@ func unmarshalRegisteredChannels(channelsStr string) (result []string) {
return
}
func (am *AccountManager) AuthenticateByCertificate(client *Client, certfp string, peerCerts []*x509.Certificate, authzid string) (err error) {
if certfp == "" {
return errAccountInvalidCredentials
}
var clientAccount ClientAccount
defer func() {
if err != nil {
return
} else if !clientAccount.Verified {
err = errAccountUnverified
return
} else if clientAccount.Suspended != nil {
err = errAccountSuspended
return
}
// TODO(#1109) clean this check up?
if client.registered {
if clientAlready := am.server.clients.Get(clientAccount.Name); clientAlready != nil && clientAlready.AlwaysOn() {
err = errNickAccountMismatch
return
}
}
am.Login(client, clientAccount)
return
}()
config := am.server.Config()
if config.Accounts.AuthScript.Enabled {
var output AuthScriptOutput
output, err = CheckAuthScript(am.server.semaphores.AuthScript, config.Accounts.AuthScript.ScriptConfig,
AuthScriptInput{Certfp: certfp, IP: client.IP().String(), peerCerts: peerCerts})
if err != nil {
am.server.logger.Error("internal", "failed shell auth invocation", err.Error())
} else if output.Success && output.AccountName != "" {
clientAccount, err = am.loadWithAutocreation(output.AccountName, config.Accounts.AuthScript.Autocreate)
return
}
}
var account string
certFPKey := fmt.Sprintf(keyCertToAccount, certfp)
err = am.server.store.View(func(tx *buntdb.Tx) error {
account, _ = tx.Get(certFPKey)
if account == "" {
return errAccountInvalidCredentials
}
return nil
})
if err != nil {
return err
}
if authzid != "" {
if cfAuthzid, err := CasefoldName(authzid); err != nil || cfAuthzid != account {
return errAuthzidAuthcidMismatch
}
}
// ok, we found an account corresponding to their certificate
clientAccount, err = am.LoadAccount(account)
return err
}
type settingsMunger func(input AccountSettings) (output AccountSettings, err error)
func (am *AccountManager) ModifyAccountSettings(account string, munger settingsMunger) (newSettings AccountSettings, err error) {
casefoldedAccount, err := CasefoldName(account)
if err != nil {
return newSettings, errAccountDoesNotExist
}
// TODO implement this in general via a compare-and-swap API
accountData, err := am.LoadAccount(casefoldedAccount)
if err != nil {
return
} else if !accountData.Verified {
return newSettings, errAccountUnverified
}
newSettings, err = munger(accountData.Settings)
if err != nil {
return
}
text, err := json.Marshal(newSettings)
if err != nil {
return
}
key := fmt.Sprintf(keyAccountSettings, casefoldedAccount)
serializedValue := string(text)
err = am.server.store.Update(func(tx *buntdb.Tx) (err error) {
_, _, err = tx.Set(key, serializedValue, nil)
return
})
if err != nil {
err = errAccountUpdateFailed
return
}
// success, push new settings into the client objects
am.Lock()
defer am.Unlock()
for _, client := range am.accountToClients[casefoldedAccount] {
client.SetAccountSettings(newSettings)
}
return
}
// represents someone's status in hostserv
type VHostInfo struct {
ApprovedVHost string
Enabled bool
}
// callback type implementing the actual business logic of vhost operations
type vhostMunger func(input VHostInfo) (output VHostInfo, err error)
func (am *AccountManager) VHostSet(account string, vhost string) (result VHostInfo, err error) {
munger := func(input VHostInfo) (output VHostInfo, err error) {
output = input
output.Enabled = true
output.ApprovedVHost = vhost
return
}
return am.performVHostChange(account, munger)
}
func (am *AccountManager) VHostSetEnabled(client *Client, enabled bool) (result VHostInfo, err error) {
munger := func(input VHostInfo) (output VHostInfo, err error) {
if input.ApprovedVHost == "" {
err = errNoVhost
return
}
output = input
output.Enabled = enabled
return
}
return am.performVHostChange(client.Account(), munger)
}
func (am *AccountManager) performVHostChange(account string, munger vhostMunger) (result VHostInfo, err error) {
account, err = CasefoldName(account)
if err != nil || account == "" {
err = errAccountDoesNotExist
return
}
if am.server.Defcon() <= 3 {
err = errFeatureDisabled
return
}
clientAccount, err := am.LoadAccount(account)
if err != nil {
err = errAccountDoesNotExist
return
} else if !clientAccount.Verified {
err = errAccountUnverified
return
}
result, err = munger(clientAccount.VHost)
if err != nil {
return
}
vhtext, err := json.Marshal(result)
if err != nil {
err = errAccountUpdateFailed
return
}
vhstr := string(vhtext)
key := fmt.Sprintf(keyAccountVHost, account)
err = am.server.store.Update(func(tx *buntdb.Tx) error {
_, _, err := tx.Set(key, vhstr, nil)
return err
})
if err != nil {
err = errAccountUpdateFailed
return
}
am.applyVhostToClients(account, result)
return result, nil
}
func (am *AccountManager) applyVHostInfo(client *Client, info VHostInfo) {
// if hostserv is disabled in config, then don't grant vhosts
// that were previously approved while it was enabled
if !am.server.Config().Accounts.VHosts.Enabled {
return
}
func (am *AccountManager) Login(client *Client, account ClientAccount) {
client.Login(account)
vhost := ""
if info.Enabled {
vhost = info.ApprovedVHost
if account.VHost.Enabled {
vhost = account.VHost.ApprovedVHost
}
oldNickmask := client.NickMaskString()
updated := client.SetVHost(vhost)
// Set nostr hostname if enabled and no vhost is set
config := am.server.Config()
if vhost == "" && config.Server.Cloaks.EnabledForAlwaysOn {
am.server.logger.Info("nostr-hostname", "Login - checking nostr hostname for account:", account.Name)
var cloakedHostname string
if config.Server.Cloaks.NostrHostnames {
am.server.logger.Info("nostr-hostname", "NostrHostnames enabled, computing nostr hostname")
cloakedHostname = am.ComputeNostrHostname(account.Name)
} else {
am.server.logger.Info("nostr-hostname", "NostrHostnames disabled in config")
}
if cloakedHostname == "" {
am.server.logger.Info("nostr-hostname", "No nostr hostname, using regular account cloak")
cloakedHostname = config.Server.Cloaks.ComputeAccountCloak(account.Name)
}
am.server.logger.Info("nostr-hostname", "Setting cloaked hostname:", cloakedHostname, "for account:", account.Name)
client.setCloakedHostname(cloakedHostname)
updated = true
}
if updated && client.Registered() {
// TODO: doing I/O here is kind of a kludge
client.sendChghost(oldNickmask, client.Hostname())
}
}
func (am *AccountManager) applyVhostToClients(account string, result VHostInfo) {
am.RLock()
clients := am.accountToClients[account]
am.RUnlock()
for _, client := range clients {
am.applyVHostInfo(client, result)
}
}
func (am *AccountManager) Login(client *Client, account ClientAccount) {
client.Login(account)
am.applyVHostInfo(client, account.VHost)
casefoldedAccount := client.Account()
am.Lock()
@@ -2461,6 +2316,12 @@ type ClientAccount struct {
Settings AccountSettings
}
// represents someone's status in hostserv
type VHostInfo struct {
ApprovedVHost string
Enabled bool
}
// convenience for passing around raw serialized account data
type rawClientAccount struct {
Name string
@@ -2472,3 +2333,220 @@ type rawClientAccount struct {
Settings string
Suspended string
}
// ComputeNostrHostname generates a nostr-based hostname for an account if available
func (am *AccountManager) ComputeNostrHostname(accountName string) string {
config := am.server.Config()
am.server.logger.Info("nostr-hostname", "ComputeNostrHostname called for account:", accountName)
if !config.Server.Cloaks.NostrHostnames {
am.server.logger.Info("nostr-hostname", "NostrHostnames disabled in config")
return ""
}
nostrIdentifier := am.loadNostrIdentifier(accountName)
am.server.logger.Info("nostr-hostname", "Loaded nostr identifier:", nostrIdentifier, "for account:", accountName)
if nostrIdentifier == "" {
am.server.logger.Info("nostr-hostname", "No nostr identifier found for account:", accountName)
return ""
}
hostname := config.Server.Cloaks.ComputeNostrHostname(nostrIdentifier)
am.server.logger.Info("nostr-hostname", "Generated hostname:", hostname, "from identifier:", nostrIdentifier)
return hostname
}
func (am *AccountManager) AuthenticateByCertificate(client *Client, certfp string, peerCerts []*x509.Certificate, authzid string) (err error) {
if certfp == "" {
return errAccountInvalidCredentials
}
var clientAccount ClientAccount
defer func() {
if err == nil {
am.Login(client, clientAccount)
}
}()
config := am.server.Config()
if config.Accounts.AuthScript.Enabled {
var output AuthScriptOutput
output, err = CheckAuthScript(am.server.semaphores.AuthScript, config.Accounts.AuthScript.ScriptConfig,
AuthScriptInput{Certfp: certfp, IP: client.IP().String(), peerCerts: peerCerts})
if err != nil {
am.server.logger.Error("internal", "failed shell auth invocation", err.Error())
} else if output.Success && output.AccountName != "" {
clientAccount, err = am.loadWithAutocreation(output.AccountName, config.Accounts.AuthScript.Autocreate)
return
}
}
var account string
certFPKey := fmt.Sprintf(keyCertToAccount, certfp)
err = am.server.store.View(func(tx *buntdb.Tx) error {
account, _ = tx.Get(certFPKey)
if account == "" {
return errAccountInvalidCredentials
}
return nil
})
if err != nil {
return err
}
if authzid != "" {
if cfAuthzid, err := CasefoldName(authzid); err != nil || cfAuthzid != account {
return errAuthzidAuthcidMismatch
}
}
// ok, we found an account corresponding to their certificate
clientAccount, err = am.LoadAccount(account)
return err
}
type settingsMunger func(input AccountSettings) (output AccountSettings, err error)
func (am *AccountManager) ModifyAccountSettings(account string, munger settingsMunger) (newSettings AccountSettings, err error) {
casefoldedAccount, err := CasefoldName(account)
if err != nil {
return newSettings, errAccountDoesNotExist
}
// TODO implement this in general via a compare-and-swap API
accountData, err := am.LoadAccount(casefoldedAccount)
if err != nil {
return
} else if !accountData.Verified {
return newSettings, errAccountUnverified
}
newSettings, err = munger(accountData.Settings)
if err != nil {
return
}
text, err := json.Marshal(newSettings)
if err != nil {
return
}
key := fmt.Sprintf(keyAccountSettings, casefoldedAccount)
serializedValue := string(text)
err = am.server.store.Update(func(tx *buntdb.Tx) (err error) {
_, _, err = tx.Set(key, serializedValue, nil)
return
})
if err != nil {
err = errAccountUpdateFailed
return
}
// success, push new settings into the client objects
am.Lock()
defer am.Unlock()
for _, client := range am.accountToClients[casefoldedAccount] {
client.SetAccountSettings(newSettings)
}
return
}
// callback type implementing the actual business logic of vhost operations
type vhostMunger func(input VHostInfo) (output VHostInfo, err error)
func (am *AccountManager) VHostSet(account string, vhost string) (result VHostInfo, err error) {
munger := func(input VHostInfo) (output VHostInfo, err error) {
output = input
output.Enabled = true
output.ApprovedVHost = vhost
return
}
return am.performVHostChange(account, munger)
}
func (am *AccountManager) VHostSetEnabled(client *Client, enabled bool) (result VHostInfo, err error) {
munger := func(input VHostInfo) (output VHostInfo, err error) {
if input.ApprovedVHost == "" {
err = errNoVhost
return
}
output = input
output.Enabled = enabled
return
}
return am.performVHostChange(client.Account(), munger)
}
func (am *AccountManager) performVHostChange(account string, munger vhostMunger) (result VHostInfo, err error) {
account, err = CasefoldName(account)
if err != nil || account == "" {
err = errAccountDoesNotExist
return
}
if am.server.Defcon() <= 3 {
err = errFeatureDisabled
return
}
clientAccount, err := am.LoadAccount(account)
if err != nil {
err = errAccountDoesNotExist
return
} else if !clientAccount.Verified {
err = errAccountUnverified
return
}
result, err = munger(clientAccount.VHost)
if err != nil {
return
}
vhtext, err := json.Marshal(result)
if err != nil {
err = errAccountUpdateFailed
return
}
vhstr := string(vhtext)
key := fmt.Sprintf(keyAccountVHost, account)
err = am.server.store.Update(func(tx *buntdb.Tx) error {
_, _, err = tx.Set(key, vhstr, nil)
return err
})
if err != nil {
err = errAccountUpdateFailed
return
}
am.applyVhostToClients(account, result)
return result, nil
}
func (am *AccountManager) applyVhostToClients(account string, result VHostInfo) {
// if hostserv is disabled in config, then don't grant vhosts
// that were previously approved while it was enabled
if !am.server.Config().Accounts.VHosts.Enabled {
return
}
vhost := ""
if result.Enabled {
vhost = result.ApprovedVHost
}
am.RLock()
clients := am.accountToClients[account]
am.RUnlock()
for _, client := range clients {
oldNickmask := client.NickMaskString()
updated := client.SetVHost(vhost)
if updated && client.Registered() {
// TODO: doing I/O here is kind of a kludge
client.sendChghost(oldNickmask, client.Hostname())
}
}
}
+13 -1
View File
@@ -436,7 +436,19 @@ func (server *Server) AddAlwaysOnClient(account ClientAccount, channelToStatus m
rawHostname, cloakedHostname := server.name, ""
if config.Server.Cloaks.EnabledForAlwaysOn {
cloakedHostname = config.Server.Cloaks.ComputeAccountCloak(account.Name)
// Try nostr hostname first, fallback to regular account cloak
server.logger.Info("nostr-hostname", "AddAlwaysOnClient - checking nostr hostname for account:", account.Name)
if config.Server.Cloaks.NostrHostnames {
server.logger.Info("nostr-hostname", "NostrHostnames enabled, computing nostr hostname")
cloakedHostname = server.accounts.ComputeNostrHostname(account.Name)
} else {
server.logger.Info("nostr-hostname", "NostrHostnames disabled in config")
}
if cloakedHostname == "" {
server.logger.Info("nostr-hostname", "No nostr hostname, using regular account cloak")
cloakedHostname = config.Server.Cloaks.ComputeAccountCloak(account.Name)
}
server.logger.Info("nostr-hostname", "Setting cloaked hostname:", cloakedHostname, "for always-on account:", account.Name)
}
username := "~u"
+54
View File
@@ -5,9 +5,11 @@ package cloaks
import (
"fmt"
"net"
"strings"
"crypto/sha3"
"github.com/ergochat/ergo/irc/nostr"
"github.com/ergochat/ergo/irc/utils"
)
@@ -19,6 +21,7 @@ type CloakConfig struct {
CidrLenIPv6 int `yaml:"cidr-len-ipv6"`
NumBits int `yaml:"num-bits"`
LegacySecretValue string `yaml:"secret"`
NostrHostnames bool `yaml:"nostr-hostnames"` // enable nostr-based hostnames for accounts registered with nostr
secret string
numBytes int
@@ -93,3 +96,54 @@ func (config *CloakConfig) ComputeAccountCloak(accountName string) string {
copy(paddedAccountName[16:], accountName[:])
return config.macAndCompose(paddedAccountName)
}
// ComputeNostrHostname generates a readable hostname from a nostr identifier
func (config *CloakConfig) ComputeNostrHostname(nostrIdentifier string) string {
fmt.Printf("DEBUG: ComputeNostrHostname called with identifier: '%s'\n", nostrIdentifier)
if nostrIdentifier == "" {
fmt.Printf("DEBUG: Empty nostr identifier, returning netname: %s\n", config.Netname)
return config.Netname
}
// Handle NIP-05 addresses (alice@example.com -> alice@example.com)
if strings.Contains(nostrIdentifier, "@") {
parts := strings.SplitN(nostrIdentifier, "@", 2)
if len(parts) == 2 {
// Return the full NIP-05 address as the hostname
hostname := nostrIdentifier
fmt.Printf("DEBUG: NIP-05 hostname generated: %s\n", hostname)
return hostname
}
}
// Handle npub format (npub1abc123... -> npub1abc123....nostr)
if strings.HasPrefix(nostrIdentifier, "npub1") {
// Use the full npub as hostname
hostname := fmt.Sprintf("%s.nostr", nostrIdentifier)
fmt.Printf("DEBUG: npub hostname generated: %s\n", hostname)
return hostname
}
// Handle hex pubkey (3bf0c63f... -> convert to npub and use full npub.nostr)
if len(nostrIdentifier) == 64 {
// Convert hex pubkey to npub format
npub, err := nostr.HexToNpub(nostrIdentifier)
if err != nil {
fmt.Printf("DEBUG: Failed to convert hex to npub: %v, using fallback\n", err)
// Fallback to truncated hex if conversion fails
truncated := nostrIdentifier[:8]
hostname := fmt.Sprintf("%s.nostr", truncated)
fmt.Printf("DEBUG: hex pubkey fallback hostname generated: %s\n", hostname)
return hostname
}
hostname := fmt.Sprintf("%s.nostr", npub)
fmt.Printf("DEBUG: hex pubkey converted to npub hostname: %s\n", hostname)
return hostname
}
// Fallback to regular account cloak
fallback := config.ComputeAccountCloak(nostrIdentifier)
fmt.Printf("DEBUG: Using fallback account cloak: %s\n", fallback)
return fallback
}
+1 -1
View File
@@ -399,7 +399,7 @@ func init() {
minParams: 1,
},
"WHOIS": {
handler: whoisHandler,
handler: whoHandler,
minParams: 1,
},
"WHOWAS": {
+26
View File
@@ -369,6 +369,13 @@ type AccountRegistrationConfig struct {
Throttling ThrottleConfig
// new-style (v2.4 email verification config):
EmailVerification email.MailtoConfig `yaml:"email-verification"`
// nostr-based account verification, where we send a DM with verification code
NostrVerification struct {
Enabled bool `yaml:"enabled"`
PrivateKey string `yaml:"private-key"`
DefaultRelays []string `yaml:"default-relays"`
Timeout time.Duration `yaml:"timeout"`
} `yaml:"nostr-verification"`
// old-style email verification config, with "callbacks":
LegacyEnabledCallbacks []string `yaml:"enabled-callbacks"`
LegacyCallbacks struct {
@@ -665,6 +672,14 @@ type Config struct {
Accounts AccountConfig
NostrVerification struct {
Enabled bool `yaml:"enabled"`
PrivateKey string `yaml:"private-key"`
DefaultRelays []string `yaml:"default-relays"`
Timeout time.Duration `yaml:"timeout"`
}
Channels struct {
DefaultModes *string `yaml:"default-modes"`
defaultModes modes.Modes
@@ -1974,3 +1989,14 @@ func normalizeCommandAliases(aliases map[string]string) (normalizedAliases map[s
}
return normalizedAliases, nil
}
type CloakConfig struct {
Enabled bool `yaml:"enabled"`
EnabledForAlwaysOn bool `yaml:"enabled-for-always-on"`
Netname string `yaml:"netname"`
CidrLenIPv4 int `yaml:"cidr-len-ipv4"`
CidrLenIPv6 int `yaml:"cidr-len-ipv6"`
NumBits int `yaml:"num-bits"`
SecretValue string `yaml:"secret"`
NostrHostnames bool `yaml:"nostr-hostnames"` // enable nostr-based hostnames for accounts registered with nostr
}
+68 -63
View File
@@ -15,69 +15,74 @@ import (
// Runtime Errors
var (
errAccountAlreadyRegistered = errors.New(`Account already exists`)
errAccountAlreadyUnregistered = errors.New(`That account name was registered previously and can't be reused`)
errAccountAlreadyVerified = errors.New(`Account is already verified`)
errAccountCantDropPrimaryNick = errors.New("Can't unreserve primary nickname")
errAccountCreation = errors.New("Account could not be created")
errAccountDoesNotExist = errors.New("Account does not exist")
errAccountInvalidCredentials = errors.New("Invalid account credentials")
errAccountBadPassphrase = errors.New(`Passphrase contains forbidden characters or is otherwise invalid`)
errAccountNickReservationFailed = errors.New("Could not (un)reserve nick")
errAccountNotLoggedIn = errors.New("You're not logged into an account")
errAccountAlreadyLoggedIn = errors.New("You're already logged into an account")
errAccountTooManyNicks = errors.New("Account has too many reserved nicks")
errAccountUnverified = errors.New(`Account is not yet verified`)
errAccountSuspended = errors.New(`Account has been suspended`)
errAccountVerificationFailed = errors.New("Account verification failed")
errAccountVerificationInvalidCode = errors.New("Invalid account verification code")
errAccountUpdateFailed = errors.New(`Error while updating your account information`)
errAccountMustHoldNick = errors.New(`You must hold that nickname in order to register it`)
errAuthRequired = errors.New("You must be logged into an account to do this")
errAuthzidAuthcidMismatch = errors.New(`authcid and authzid must be the same`)
errCertfpAlreadyExists = errors.New(`An account already exists for your certificate fingerprint`)
errChannelNotOwnedByAccount = errors.New("Channel not owned by the specified account")
errChannelTransferNotOffered = errors.New(`You weren't offered ownership of that channel`)
errChannelAlreadyRegistered = errors.New("Channel is already registered")
errChannelNotRegistered = errors.New("Channel is not registered")
errChannelNameInUse = errors.New(`Channel name in use`)
errInvalidChannelName = errors.New(`Invalid channel name`)
errMonitorLimitExceeded = errors.New("Monitor limit exceeded")
errNickMissing = errors.New("nick missing")
errNicknameInvalid = errors.New("invalid nickname")
errNicknameInUse = errors.New("nickname in use")
errInsecureReattach = errors.New("insecure reattach")
errNicknameReserved = errors.New("nickname is reserved")
errNickAccountMismatch = errors.New(`Your nickname must match your account name; try logging out and logging back in with SASL`)
errNoExistingBan = errors.New("Ban does not exist")
errNoSuchChannel = errors.New(`No such channel`)
errChannelPurged = errors.New(`This channel was purged by the server operators and cannot be used`)
errChannelPurgedAlready = errors.New(`This channel was already purged and cannot be purged again`)
errConfusableIdentifier = errors.New("This identifier is confusable with one already in use")
errInsufficientPrivs = errors.New("Insufficient privileges")
errInvalidUsername = errors.New("Invalid username")
errFeatureDisabled = errors.New(`That feature is disabled`)
errBanned = errors.New("IP or nickmask banned")
errInvalidParams = utils.ErrInvalidParams
errNoVhost = errors.New(`You do not have an approved vhost`)
errLimitExceeded = errors.New("Limit exceeded")
errNoop = errors.New("Action was a no-op")
errCASFailed = errors.New("Compare-and-swap update of database value failed")
errEmptyCredentials = errors.New("No more credentials are approved")
errCredsExternallyManaged = errors.New("Credentials are externally managed and cannot be changed here")
errNoSCRAMCredentials = errors.New("SCRAM credentials are not initialized for this account; consult the user guide")
errInvalidMultilineBatch = errors.New("Invalid multiline batch")
errTimedOut = errors.New("Operation timed out")
errInvalidUtf8 = errors.New("Message rejected for invalid utf8")
errClientDestroyed = errors.New("Client was already destroyed")
errTooManyChannels = errors.New("You have joined too many channels")
errWrongChannelKey = errors.New("Cannot join password-protected channel without the password")
errInviteOnly = errors.New("Cannot join invite-only channel without an invite")
errRegisteredOnly = errors.New("Cannot join registered-only channel without an account")
errValidEmailRequired = errors.New("A valid email address is required for account registration")
errInvalidAccountRename = errors.New("Account renames can only change the casefolding of the account name")
errNameReserved = errors.New(`Name reserved due to a prior registration`)
errInvalidBearerTokenType = errors.New("invalid bearer token type")
errAccountAlreadyRegistered = errors.New("Account already exists")
errAccountAlreadyUnregistered = errors.New("Account was already unregistered")
errAccountAlreadyVerified = errors.New("Account is already verified")
errAccountCantDropPrimaryNick = errors.New("Can't unreserve primary nickname")
errAccountCreation = errors.New("Account could not be created")
errAccountDoesNotExist = errors.New("Account does not exist")
errAccountInvalidCredentials = errors.New("Invalid account credentials")
errAccountBadPassphrase = errors.New("Passphrase contains forbidden characters or is otherwise invalid")
errAccountNickReservationFailed = errors.New("Could not (un)reserve nick")
errAccountNotLoggedIn = errors.New("You're not logged into an account")
errAccountAlreadyLoggedIn = errors.New("You're already logged into an account")
errAccountPasswordInvalid = errors.New("Password incorrect")
errAccountTooManyNicks = errors.New("Account has too many reserved nicks")
errAccountUnverified = errors.New("Account is not yet verified")
errAccountSuspended = errors.New("Account has been suspended")
errAccountVerificationFailed = errors.New("Account verification failed")
errAccountVerificationInvalidCode = errors.New("Invalid verification code")
errAccountUpdateFailed = errors.New("Error while updating your account information")
errAccountMustHoldNick = errors.New("You must hold that nickname in order to register it")
errAccountBadUnregisterCredentials = errors.New("Invalid credentials for unregistering account")
errAccountBadSetting = errors.New("Invalid account setting")
errAuthRequired = errors.New("You must be logged into an account to do this")
errAuthzidAuthcidMismatch = errors.New("authcid and authzid must be the same")
errCertfpAlreadyExists = errors.New("An account already exists for your certificate fingerprint")
errChannelNotOwnedByAccount = errors.New("Channel not owned by the specified account")
errChannelTransferNotOffered = errors.New("You weren't offered ownership of that channel")
errChannelAlreadyRegistered = errors.New("Channel is already registered")
errChannelNotRegistered = errors.New("Channel is not registered")
errChannelNameInUse = errors.New("Channel name in use")
errInvalidChannelName = errors.New("Invalid channel name")
errMonitorLimitExceeded = errors.New("Monitor limit exceeded")
errNickMissing = errors.New("nick missing")
errNicknameInvalid = errors.New("invalid nickname")
errNicknameInUse = errors.New("nickname in use")
errInsecureReattach = errors.New("insecure reattach")
errNicknameReserved = errors.New("nickname is reserved")
errNickAccountMismatch = errors.New("Your nickname must match your account name; try logging out and logging back in with SASL")
errNoExistingBan = errors.New("Ban does not exist")
errNoSuchChannel = errors.New("No such channel")
errChannelPurged = errors.New("This channel was purged by the server operators and cannot be used")
errChannelPurgedAlready = errors.New("This channel was already purged and cannot be purged again")
errConfusableIdentifier = errors.New("This identifier is confusable with one already in use")
errInsufficientPrivs = errors.New("Insufficient privileges")
errInvalidUsername = errors.New("Invalid username")
errFeatureDisabled = errors.New("That feature is disabled")
errBanned = errors.New("IP or nickmask banned")
errInvalidParams = utils.ErrInvalidParams
errNoVhost = errors.New("You do not have an approved vhost")
errLimitExceeded = errors.New("Limit exceeded")
errNoop = errors.New("Action was a no-op")
errCASFailed = errors.New("Compare-and-swap update of database value failed")
errEmptyCredentials = errors.New("No more credentials are approved")
errCredsExternallyManaged = errors.New("Credentials are externally managed and cannot be changed here")
errNoSCRAMCredentials = errors.New("SCRAM credentials are not initialized for this account; consult the user guide")
errInvalidMultilineBatch = errors.New("Invalid multiline batch")
errTimedOut = errors.New("Operation timed out")
errInvalidUtf8 = errors.New("Message rejected for invalid utf8")
errClientDestroyed = errors.New("Client was already destroyed")
errTooManyChannels = errors.New("You have joined too many channels")
errWrongChannelKey = errors.New("Cannot join password-protected channel without the password")
errInviteOnly = errors.New("Cannot join invite-only channel without an invite")
errRegisteredOnly = errors.New("Cannot join registered-only channel without an account")
errValidEmailRequired = errors.New("A valid e-mail address is required")
errValidNostrIdentifierRequired = errors.New("A valid nostr identifier is required")
errUnsupportedCallbackNamespace = errors.New("Unsupported callback namespace")
errInvalidAccountRename = errors.New("Account renames can only change the casefolding of the account name")
errNameReserved = errors.New("Name reserved due to a prior registration")
errInvalidBearerTokenType = errors.New("invalid bearer token type")
)
// String Errors
+70 -97
View File
@@ -32,6 +32,7 @@ import (
"github.com/ergochat/ergo/irc/history"
"github.com/ergochat/ergo/irc/jwt"
"github.com/ergochat/ergo/irc/modes"
"github.com/ergochat/ergo/irc/nostr"
"github.com/ergochat/ergo/irc/oauth2"
"github.com/ergochat/ergo/irc/sno"
"github.com/ergochat/ergo/irc/utils"
@@ -42,25 +43,42 @@ import (
func parseCallback(spec string, config *Config) (callbackNamespace string, callbackValue string, err error) {
// XXX if we don't require verification, ignore any callback that was passed here
// (to avoid confusion in the case where the ircd has no mail server configured)
if !config.Accounts.Registration.EmailVerification.Enabled {
if !config.Accounts.Registration.EmailVerification.Enabled && !config.Accounts.Registration.NostrVerification.Enabled {
callbackNamespace = "*"
return
}
callback := strings.ToLower(spec)
if colonIndex := strings.IndexByte(callback, ':'); colonIndex != -1 {
callbackNamespace, callbackValue = callback[:colonIndex], callback[colonIndex+1:]
if colonIndex := strings.IndexByte(spec, ':'); colonIndex != -1 {
callbackNamespace, callbackValue = strings.ToLower(spec[:colonIndex]), spec[colonIndex+1:]
} else {
// "If a callback namespace is not ... provided, the IRC server MUST use mailto""
callbackNamespace = "mailto"
callbackValue = callback
// Auto-detect callback type based on format
if nostr.IsNostrIdentifier(spec) {
callbackNamespace = "nostr"
callbackValue = spec
} else {
// "If a callback namespace is not ... provided, the IRC server MUST use mailto""
callbackNamespace = "mailto"
callbackValue = strings.ToLower(spec)
}
}
if config.Accounts.Registration.EmailVerification.Enabled {
if callbackNamespace != "mailto" {
err = errValidEmailRequired
} else if strings.IndexByte(callbackValue, '@') < 1 {
err = errValidEmailRequired
if callbackNamespace == "mailto" {
if config.Accounts.Registration.EmailVerification.Enabled {
if strings.IndexByte(callbackValue, '@') < 1 {
err = errValidEmailRequired
}
} else {
err = errUnsupportedCallbackNamespace
}
} else if callbackNamespace == "nostr" {
if config.Accounts.Registration.NostrVerification.Enabled {
if !nostr.IsValidNostrIdentifier(callbackValue) {
err = errValidNostrIdentifierRequired
}
} else {
err = errUnsupportedCallbackNamespace
}
} else if callbackNamespace != "admin" && callbackNamespace != "none" && callbackNamespace != "*" {
err = errUnsupportedCallbackNamespace
}
return
@@ -131,7 +149,20 @@ func sendSuccessfulAccountAuth(service *ircService, client *Client, rb *Response
if rb.session.isTor {
config := client.server.Config()
if config.Server.Cloaks.EnabledForAlwaysOn {
cloakedHostname := config.Server.Cloaks.ComputeAccountCloak(details.accountName)
// Try nostr hostname first, fallback to regular account cloak
var cloakedHostname string
client.server.logger.Info("nostr-hostname", "Authentication success - checking nostr hostname for account:", details.accountName)
if config.Server.Cloaks.NostrHostnames {
client.server.logger.Info("nostr-hostname", "NostrHostnames enabled, computing nostr hostname")
cloakedHostname = client.server.accounts.ComputeNostrHostname(details.accountName)
} else {
client.server.logger.Info("nostr-hostname", "NostrHostnames disabled in config")
}
if cloakedHostname == "" {
client.server.logger.Info("nostr-hostname", "No nostr hostname, using regular account cloak")
cloakedHostname = config.Server.Cloaks.ComputeAccountCloak(details.accountName)
}
client.server.logger.Info("nostr-hostname", "Setting cloaked hostname:", cloakedHostname, "for account:", details.accountName)
client.setCloakedHostname(cloakedHostname)
if client.registered {
client.sendChghost(details.nickMask, client.Hostname())
@@ -216,7 +247,7 @@ func authenticateHandler(server *Server, client *Client, msg ircmsg.Message, rb
// and I don't want to break working clients that use PLAIN or EXTERNAL
// and violate this MUST (e.g. by sending CAP END too early).
if client.registered && !(mechanism == "PLAIN" || mechanism == "EXTERNAL") {
rb.Add(nil, server.name, ERR_SASLFAIL, details.nick, client.t("SASL is only allowed before connection registration"))
rb.Add(nil, server.name, ERR_SASLFAIL, client.Nick(), client.t("SASL is only allowed before connection registration"))
return false
}
@@ -275,12 +306,12 @@ func authPlainHandler(server *Server, client *Client, session *Session, value []
if len(splitValue) == 3 {
authzid, authcid = string(splitValue[0]), string(splitValue[1])
if authzid != "" && authcid != authzid {
rb.Add(nil, server.name, ERR_SASLFAIL, client.Nick(), client.t("SASL authentication failed: authcid and authzid should be the same"))
if authzid != "" && authzid != authcid {
rb.Add(nil, server.name, ERR_SASLFAIL, client.nick, client.t("SASL authentication failed: authcid and authzid should be the same"))
return false
}
} else {
rb.Add(nil, server.name, ERR_SASLFAIL, client.Nick(), client.t("SASL authentication failed: Invalid auth blob"))
rb.Add(nil, server.name, ERR_SASLFAIL, client.nick, client.t("SASL authentication failed: Invalid auth blob"))
return false
}
@@ -979,7 +1010,7 @@ func dlineHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respon
// check oper permissions
oper := client.Oper()
if !oper.HasRoleCapab("ban") {
rb.Add(nil, server.name, ERR_NOPRIVS, client.nick, msg.Command, client.t("Insufficient oper privs"))
rb.Add(nil, server.name, ERR_NOPRIVS, client.Nick(), msg.Command, client.t("Insufficient oper privs"))
return false
}
@@ -1024,10 +1055,6 @@ func dlineHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respon
}
// get host
if len(msg.Params) < currentArg+1 {
rb.Add(nil, server.name, ERR_NEEDMOREPARAMS, client.nick, msg.Command, client.t("Not enough parameters"))
return false
}
hostString := msg.Params[currentArg]
currentArg++
@@ -1367,6 +1394,10 @@ func joinHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respons
if len(keys) > i {
key = keys[i]
}
// Check if this is a nostr feed channel
// (nostr relay feeds removed)
err, forward := server.channels.Join(client, name, key, false, rb)
if err != nil {
if forward != "" {
@@ -1529,7 +1560,7 @@ func killHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respons
rb.Add(nil, client.server.name, ERR_NOSUCHNICK, client.Nick(), utils.SafeErrorParam(nickname), client.t("No such nick"))
return false
} else if target.AlwaysOn() {
rb.Add(nil, client.server.name, ERR_UNKNOWNERROR, client.Nick(), "KILL", fmt.Sprintf(client.t("Client %s is always-on and cannot be fully removed by /KILL; consider /UBAN ADD instead"), target.Nick()))
rb.Add(nil, client.server.name, ERR_UNKNOWNERROR, client.Nick(), client.t("Client %s is always-on and cannot be fully removed by /KILL; consider /UBAN ADD instead"), target.Nick())
}
quitMsg := "Killed"
@@ -1812,6 +1843,8 @@ func listHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respons
rplList(channel)
}
}
// (nostr relay feeds removed)
} else {
// limit regular users to only listing one channel
if !clientIsOp {
@@ -2224,7 +2257,7 @@ func absorbBatchedMessage(server *Server, client *Client, msg ircmsg.Message, ba
var failParams []string
defer func() {
if failParams != nil {
if histType != history.Notice {
if histType != history.Privmsg {
params := make([]string, 1+len(failParams))
params[0] = "BATCH"
copy(params[1:], failParams)
@@ -2342,6 +2375,8 @@ func dispatchMessageToTarget(client *Client, tags map[string]string, histType hi
if len(target) == 0 {
return
} else if target[0] == '#' {
// (nostr relay feeds removed)
channel := server.channels.Get(target)
if channel == nil {
if histType != history.Notice {
@@ -2367,7 +2402,7 @@ func dispatchMessageToTarget(client *Client, tags map[string]string, histType hi
tnick := tClient.Nick()
for _, session := range tClient.Sessions() {
session.sendSplitMsgFromClientInternal(false, nickMaskString, accountName, isBot, nil, command, tnick, message)
session.sendFromClientInternal(false, time.Time{}, "", nickMaskString, accountName, isBot, tags, command, tnick)
}
}
}
@@ -2484,7 +2519,7 @@ func dispatchMessageToTarget(client *Client, tags map[string]string, histType hi
time: message.Time,
})
} else {
server.logger.Error("internal", "can't serialize push message", err.Error())
server.logger.Error("internal", "couldn't serialize push message", err.Error())
}
}
}
@@ -2541,7 +2576,7 @@ func npcaHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respons
// OPER <name> [password]
func operHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool {
if client.HasMode(modes.Operator) {
rb.Add(nil, server.name, ERR_UNKNOWNERROR, client.Nick(), "OPER", client.t("You're already opered-up!"))
rb.Add(nil, server.name, ERR_UNKNOWNERROR, client.Nick(), client.t("You're already opered-up!"))
return false
}
@@ -2666,6 +2701,9 @@ func partHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respons
if chname == "" {
continue // #679
}
// (nostr relay feeds removed)
err := server.channels.Part(client, chname, reason, rb)
if err == errNoSuchChannel {
rb.Add(nil, server.name, ERR_NOSUCHCHANNEL, client.nick, utils.SafeErrorParam(chname), client.t("No such channel"))
@@ -2801,6 +2839,7 @@ func redactHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respo
isBot := client.HasMode(modes.Bot)
if target[0] == '#' {
// (nostr relay feeds removed)
channel := server.channels.Get(target)
if channel == nil {
rb.Add(nil, server.name, ERR_NOSUCHCHANNEL, client.Nick(), utils.SafeErrorParam(target), client.t("No such channel"))
@@ -2852,7 +2891,7 @@ func redactHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respo
if err != nil {
client.server.logger.Error("internal", fmt.Sprintf("Private message %s is not deletable by %s from their own buffer's even though we just deleted it from %s's. This is a bug, please report it in details.", targetmsgid, client.Nick(), target), client.Nick())
isOper := client.HasRoleCapabs("history")
isOper := client.HasMode(modes.Operator)
if isOper {
rb.Add(nil, server.name, "FAIL", "REDACT", "REDACT_FORBIDDEN", utils.SafeErrorParam(target), utils.SafeErrorParam(targetmsgid), fmt.Sprintf(client.t("Error deleting message: %v"), err))
} else {
@@ -3232,8 +3271,8 @@ func metadataRegisteredHandler(client *Client, config *Config, subcommand string
return
}
batchId := rb.StartNestedBatch("metadata", target)
defer rb.EndNestedBatch(batchId)
batchID := rb.StartNestedBatch("metadata", target)
defer rb.EndNestedBatch(batchID)
for _, key := range params[2:] {
if metadataKeyIsEvil(key) {
@@ -3255,11 +3294,6 @@ func metadataRegisteredHandler(client *Client, config *Config, subcommand string
playMetadataList(rb, client.Nick(), target, targetObj.ListMetadata())
case "clear":
if !metadataCanIEditThisTarget(client, targetObj) {
noKeyPerms("*")
return
}
values := targetObj.ClearMetadata()
playMetadataList(rb, client.Nick(), target, values)
@@ -4240,7 +4274,7 @@ func whoHandler(server *Server, client *Client, msg ircmsg.Message, rb *Response
// successfully parsed query, ensure we send the success response:
defer func() {
rb.Add(nil, server.name, RPL_ENDOFWHO, client.Nick(), origMask, client.t("End of WHO list"))
rb.Add(nil, server.name, RPL_ENDOFWHO, client.Nick(), origMask, client.t("End of /WHO list"))
}()
// XXX #1730: https://datatracker.ietf.org/doc/html/rfc1459#section-4.5.1
@@ -4312,67 +4346,6 @@ func whoHandler(server *Server, client *Client, msg ircmsg.Message, rb *Response
return false
}
// WHOIS [<target>] <mask>{,<mask>}
func whoisHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool {
var masksString string
//var target string
if len(msg.Params) > 1 {
//target = msg.Params[0]
masksString = msg.Params[1]
} else {
masksString = msg.Params[0]
}
handleService := func(nick string) bool {
cfnick, _ := CasefoldName(nick)
service, ok := ErgoServices[cfnick]
hostname := "localhost"
config := server.Config()
if config.Server.OverrideServicesHostname != "" {
hostname = config.Server.OverrideServicesHostname
}
if !ok {
return false
}
clientNick := client.Nick()
rb.Add(nil, client.server.name, RPL_WHOISUSER, clientNick, service.Name, service.Name, hostname, "*", service.Realname(client))
// #1080:
rb.Add(nil, client.server.name, RPL_WHOISOPERATOR, clientNick, service.Name, client.t("is a network service"))
// hehe
if client.HasMode(modes.TLS) {
rb.Add(nil, client.server.name, RPL_WHOISSECURE, clientNick, service.Name, client.t("is using a secure connection"))
}
return true
}
hasPrivs := client.HasRoleCapabs("samode")
if hasPrivs {
for _, mask := range strings.Split(masksString, ",") {
matches := server.clients.FindAll(mask)
if len(matches) == 0 && !handleService(mask) {
rb.Add(nil, client.server.name, ERR_NOSUCHNICK, client.Nick(), utils.SafeErrorParam(mask), client.t("No such nick"))
continue
}
for mclient := range matches {
client.getWhoisOf(mclient, hasPrivs, rb)
}
}
} else {
// only get the first request; also require a nick, not a mask
nick := strings.Split(masksString, ",")[0]
mclient := server.clients.Get(nick)
if mclient != nil {
client.getWhoisOf(mclient, hasPrivs, rb)
} else if !handleService(nick) {
rb.Add(nil, client.server.name, ERR_NOSUCHNICK, client.Nick(), utils.SafeErrorParam(masksString), client.t("No such nick"))
}
// fall through, ENDOFWHOIS is always sent
}
rb.Add(nil, server.name, RPL_ENDOFWHOIS, client.nick, utils.SafeErrorParam(masksString), client.t("End of /WHOIS list"))
return false
}
// WHOWAS <nickname> [<count> [<server>]]
func whowasHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool {
nicknames := strings.Split(msg.Params[0], ",")
+11 -4
View File
@@ -135,11 +135,18 @@ INFO gives you information about the given (or your own) user account.`,
handler: nsRegisterHandler,
// TODO: "email" is an oversimplification here; it's actually any callback, e.g.,
// person@example.com, mailto:person@example.com, tel:16505551234.
help: `Syntax: $bREGISTER <password> [email]$b
help: `Syntax: $bREGISTER <password> [nostr-identifier]$b
REGISTER lets you register your current nickname as a user account. If the
server allows anonymous registration, you can omit the e-mail address.
REGISTER lets you register your current nickname as a user account using nostr
verification. You can provide a nostr identifier in one of these formats:
• NIP-05 address: alice@example.com
• npub key: npub1abc123def456...
• hex pubkey: 3bf0c63fcb93c5ef2f068d70b8d70d963b649d75...
The server will contact you via Nostr DM (nip17 or nip04) with verification code.
If the server allows anonymous registration, you can omit the nostr identifier.
If you are currently logged in with a TLS client certificate and wish to use
it instead of a password to log in, send * as the password.`,
helpShort: `$bREGISTER$b lets you register a user account.`,
@@ -1016,7 +1023,7 @@ func nsRegisterHandler(service *ircService, server *Server, client *Client, comm
callbackNamespace, callbackValue, validationErr := parseCallback(email, config)
if validationErr != nil {
service.Notice(rb, client.t("Registration requires a valid e-mail address"))
service.Notice(rb, client.t("Registration invalid, see help"))
return
}
+380
View File
@@ -0,0 +1,380 @@
// Copyright (c) 2024 Ergo Contributors
// released under the MIT license
package nostr
import (
"context"
"encoding/json"
"fmt"
"log"
"strings"
"time"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip04"
"github.com/nbd-wtf/go-nostr/nip17"
"github.com/nbd-wtf/go-nostr/nip44"
)
// DMConfig holds configuration for DM operations
type DMConfig struct {
PrivateKey string // Hex-encoded private key for signing
DefaultRelays []string // Fallback relays if none discovered
Timeout time.Duration // Timeout for relay operations
UserAgent string // User agent for HTTP requests
}
// SimpleKeyer implements nostr.Keyer interface for NIP-17
type SimpleKeyer struct {
privateKey string
}
func (k *SimpleKeyer) GetPublicKey(ctx context.Context) (string, error) {
return nostr.GetPublicKey(k.privateKey)
}
func (k *SimpleKeyer) SignEvent(ctx context.Context, event *nostr.Event) error {
return event.Sign(k.privateKey)
}
func (k *SimpleKeyer) Encrypt(ctx context.Context, plaintext, recipientPubkey string) (string, error) {
// Generate conversation key using NIP-44
conversationKey, err := nip44.GenerateConversationKey(recipientPubkey, k.privateKey)
if err != nil {
return "", err
}
return nip44.Encrypt(plaintext, conversationKey)
}
func (k *SimpleKeyer) Decrypt(ctx context.Context, ciphertext, senderPubkey string) (string, error) {
// Generate conversation key using NIP-44
conversationKey, err := nip44.GenerateConversationKey(senderPubkey, k.privateKey)
if err != nil {
return "", err
}
return nip44.Decrypt(ciphertext, conversationKey)
}
// CreateNIP04DM creates a NIP-04 DM event with verification code
func CreateNIP04DM(recipientPubkey, senderPrivkey, account, code, serverName string) (*nostr.Event, error) {
// Create the message content
message := fmt.Sprintf("Account verification for %s\n\nAccount: %s\nVerification code: %s\n\nTo verify your account, issue the following command:\n/MSG NickServ VERIFY %s %s",
serverName, account, code, account, code)
// Compute shared secret for NIP-04 encryption
sharedSecret, err := nip04.ComputeSharedSecret(recipientPubkey, senderPrivkey)
if err != nil {
return nil, fmt.Errorf("%w: failed to compute shared secret: %v", ErrDMEncryptionFailed, err)
}
// Encrypt the message using NIP-04
encryptedContent, err := nip04.Encrypt(message, sharedSecret)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrDMEncryptionFailed, err)
}
// Get sender pubkey from private key
senderPubkey, err := nostr.GetPublicKey(senderPrivkey)
if err != nil {
return nil, fmt.Errorf("failed to derive public key: %w", err)
}
// Create the event
event := &nostr.Event{
PubKey: senderPubkey,
CreatedAt: nostr.Timestamp(time.Now().Unix()),
Kind: 4, // NIP-04 DM event kind
Tags: nostr.Tags{
{"p", recipientPubkey},
},
Content: encryptedContent,
}
// Sign the event
if err := event.Sign(senderPrivkey); err != nil {
return nil, err
}
// Detailed debug logging for comparison
log.Printf("[NOSTR DEBUG] Created NIP-04 DM event: %+v", event)
jsonEvent, err := json.MarshalIndent(event, "", " ")
if err != nil {
log.Printf("[NOSTR DEBUG] Failed to marshal event to JSON: %v", err)
} else {
log.Printf("[NOSTR DEBUG] NIP-04 DM event JSON: %s", jsonEvent)
}
return event, nil
}
// CreateNIP17DM creates a NIP-17 DM event with verification code
func CreateNIP17DM(recipientPubkey, senderPrivkey, account, code, serverName string) (*nostr.Event, error) {
// Create the message content
message := fmt.Sprintf("Account verification for %s\n\nAccount: %s\nVerification code: %s\n\nTo verify your account, issue the following command:\n/MSG NickServ VERIFY %s %s",
serverName, account, code, account, code)
log.Printf("[NOSTR DEBUG] Creating NIP-17 DM")
log.Printf("[NOSTR DEBUG] Recipient pubkey: %s", recipientPubkey)
log.Printf("[NOSTR DEBUG] Message content: %s", message)
// Create a SimpleKeyer instance
keyer := &SimpleKeyer{privateKey: senderPrivkey}
// Get sender pubkey for logging
senderPubkey, err := nostr.GetPublicKey(senderPrivkey)
if err != nil {
log.Printf("[NOSTR DEBUG] Failed to derive sender pubkey: %v", err)
return nil, fmt.Errorf("failed to derive public key: %w", err)
}
log.Printf("[NOSTR DEBUG] Sender pubkey: %s", senderPubkey)
// Use nip17.PrepareMessage to create properly gift-wrapped events
ctx := context.Background()
toUs, toThem, err := nip17.PrepareMessage(
ctx,
message,
nostr.Tags{}, // empty tags, the function will add the "p" tag
keyer,
recipientPubkey,
nil, // no modify function
)
if err != nil {
log.Printf("[NOSTR DEBUG] Failed to prepare NIP-17 message: %v", err)
return nil, fmt.Errorf("%w: %v", ErrDMEncryptionFailed, err)
}
log.Printf("[NOSTR DEBUG] NIP-17 events prepared successfully")
log.Printf("[NOSTR DEBUG] toUs event - Kind: %d, ID: %s", toUs.Kind, toUs.ID)
log.Printf("[NOSTR DEBUG] toThem event - Kind: %d, ID: %s", toThem.Kind, toThem.ID)
// Marshal complete event to JSON for detailed inspection
jsonEvent, err := json.MarshalIndent(toThem, "", " ")
if err != nil {
log.Printf("[NOSTR DEBUG] Failed to marshal NIP-17 event to JSON: %v", err)
} else {
log.Printf("[NOSTR DEBUG] Complete NIP-17 event JSON: %s", jsonEvent)
}
// Return the toThem event (the one that goes to the recipient)
return &toThem, nil
}
// SendVerificationDM sends a verification DM to a user
func SendVerificationDM(identifier, account, code, serverName string, config DMConfig) error {
if config.PrivateKey == "" {
return ErrNostrKeyNotConfigured
}
ctx, cancel := context.WithTimeout(context.Background(), config.Timeout)
defer cancel()
// Resolve the identifier to a pubkey
nip05Config := NIP05Config{
Timeout: config.Timeout,
UserAgent: config.UserAgent,
}
pubkey, nip05Relays, err := ResolvePubkey(identifier, nip05Config)
if err != nil {
return fmt.Errorf("failed to resolve pubkey: %w", err)
}
// Discover inbox relays
relayConfig := RelayConfig{
DefaultRelays: config.DefaultRelays,
Timeout: config.Timeout,
MaxRelays: 10,
}
// Combine NIP-05 relays with discovered relays
allRelays := append(nip05Relays, config.DefaultRelays...)
if len(allRelays) > 0 {
relayConfig.DefaultRelays = allRelays
}
// Check if user has private relays (NIP-50 kind 10050)
privateRelays, hasPrivateRelays, err := CheckPrivateRelays(ctx, pubkey, relayConfig)
if err != nil {
// Continue with regular flow if private relay check fails
hasPrivateRelays = false
}
var dmEvent *nostr.Event
var targetRelays []string
if hasPrivateRelays && len(privateRelays) > 0 {
log.Printf("Using NIP-17 DM for user with private relays")
// Use NIP-17 DMs for users with private relays
dmEvent, err = CreateNIP17DM(pubkey, config.PrivateKey, account, code, serverName)
if err != nil {
return fmt.Errorf("failed to create NIP-17 DM: %w", err)
}
targetRelays = privateRelays
log.Printf("Targeting private relays: %v", targetRelays)
} else {
log.Printf("Using NIP-04 DM for regular user")
// Use NIP-04 DMs for regular users
dmEvent, err = CreateNIP04DM(pubkey, config.PrivateKey, account, code, serverName)
if err != nil {
return fmt.Errorf("failed to create NIP-04 DM: %w", err)
}
// Discover inbox relays for NIP-04
inboxRelays, err := DiscoverInboxRelays(pubkey, relayConfig)
if err != nil {
return fmt.Errorf("failed to discover inbox relays: %w", err)
}
targetRelays = inboxRelays
log.Printf("Targeting inbox relays: %v", targetRelays)
}
// Send to target relays with retry logic
var lastErr error
successCount := 0
for _, relayURL := range targetRelays {
// Try connecting and sending
err := sendToRelayWithRetry(ctx, relayURL, *dmEvent, config.PrivateKey)
if err != nil {
lastErr = err
continue
}
successCount++
}
if successCount == 0 {
if lastErr != nil {
return fmt.Errorf("%w: %v", ErrDMSendFailed, lastErr)
}
return ErrDMSendFailed
}
return nil
}
// sendToRelayWithRetry attempts to send a DM to a relay with retry logic for auth failures
func sendToRelayWithRetry(ctx context.Context, relayURL string, event nostr.Event, privkey string) error {
log.Printf("[NOSTR DEBUG] Connecting to relay: %s", relayURL)
// Create a separate timeout context for this relay connection (15 seconds)
relayCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
// First attempt without auth
relay, err := nostr.RelayConnect(relayCtx, relayURL)
if err != nil {
log.Printf("[NOSTR DEBUG] Failed to connect to %s: %v", relayURL, err)
return fmt.Errorf("failed to connect to relay %s: %w", relayURL, err)
}
defer relay.Close()
log.Printf("[NOSTR DEBUG] Attempting to publish to %s without auth", relayURL)
// Try sending without auth first
err = relay.Publish(relayCtx, event)
if err == nil {
log.Printf("[NOSTR DEBUG] Successfully published to %s without auth", relayURL)
// Log the event that was successfully published
eventJSON, jsonErr := json.MarshalIndent(event, "", " ")
if jsonErr != nil {
log.Printf("[NOSTR DEBUG] Failed to marshal published event: %v", jsonErr)
} else {
log.Printf("[NOSTR DEBUG] Published event JSON: %s", eventJSON)
}
return nil // Success!
}
log.Printf("[NOSTR DEBUG] Publish failed on %s: %v", relayURL, err)
// If we get an auth error, try with authentication
if strings.Contains(err.Error(), "auth-required") || strings.Contains(err.Error(), "you must auth") {
log.Printf("[NOSTR DEBUG] Auth required for %s, reconnecting with authentication", relayURL)
// Close and reconnect with auth
relay.Close()
// Create a new timeout context for the auth connection
authCtx, authCancel := context.WithTimeout(context.Background(), 15*time.Second)
defer authCancel()
relay, err = ConnectToRelay(authCtx, relayURL, privkey)
if err != nil {
log.Printf("[NOSTR DEBUG] Failed to connect with auth to %s: %v", relayURL, err)
return fmt.Errorf("failed to connect with auth to relay %s: %w", relayURL, err)
}
defer relay.Close()
log.Printf("[NOSTR DEBUG] Attempting to publish to %s with auth", relayURL)
// Try sending again after auth
err = relay.Publish(authCtx, event)
if err != nil {
log.Printf("[NOSTR DEBUG] Failed to publish to authenticated %s: %v", relayURL, err)
return fmt.Errorf("failed to publish to authenticated relay %s: %w", relayURL, err)
}
log.Printf("[NOSTR DEBUG] Successfully published to %s with auth", relayURL)
// Log the event that was successfully published
eventJSON, jsonErr := json.MarshalIndent(event, "", " ")
if jsonErr != nil {
log.Printf("[NOSTR DEBUG] Failed to marshal published event: %v", jsonErr)
} else {
log.Printf("[NOSTR DEBUG] Published event JSON: %s", eventJSON)
}
return nil
}
// Other error, return as-is
log.Printf("[NOSTR DEBUG] Non-auth error on %s: %v", relayURL, err)
return fmt.Errorf("failed to publish to relay %s: %w", relayURL, err)
}
// SendNIP04DM sends a NIP-04 DM to specific relays
func SendNIP04DM(ctx context.Context, event *nostr.Event, relays []string, privkey string) error {
var lastErr error
successCount := 0
for _, relayURL := range relays {
// Try connecting and sending
err := sendToRelayWithRetry(ctx, relayURL, *event, privkey)
if err != nil {
lastErr = err
continue
}
successCount++
}
if successCount == 0 {
if lastErr != nil {
return fmt.Errorf("%w: %v", ErrDMSendFailed, lastErr)
}
return ErrDMSendFailed
}
return nil
}
// SendNIP17DM sends a NIP-17 DM to private relays
func SendNIP17DM(ctx context.Context, event *nostr.Event, privateRelays []string, privkey string) error {
var lastErr error
successCount := 0
for _, relayURL := range privateRelays {
// Try connecting and sending
err := sendToRelayWithRetry(ctx, relayURL, *event, privkey)
if err != nil {
lastErr = err
continue
}
successCount++
}
if successCount == 0 {
if lastErr != nil {
return fmt.Errorf("%w: %v", ErrDMSendFailed, lastErr)
}
return ErrDMSendFailed
}
return nil
}
+37
View File
@@ -0,0 +1,37 @@
// Copyright (c) 2024 Ergo Contributors
// released under the MIT license
package nostr
import "errors"
var (
// Identifier validation errors
ErrInvalidNostrIdentifier = errors.New("invalid nostr identifier format")
ErrInvalidPubkeyFormat = errors.New("invalid pubkey format")
ErrInvalidPubkeyLength = errors.New("invalid pubkey length")
ErrInvalidHexFormat = errors.New("invalid hex format")
ErrInvalidPrivkeyLength = errors.New("invalid private key length")
ErrInvalidNpubPrefix = errors.New("invalid npub prefix")
// NIP-05 resolution errors
ErrNIP05ResolutionFailed = errors.New("NIP-05 resolution failed")
ErrNIP05NotFound = errors.New("NIP-05 address not found")
ErrNIP05InvalidResponse = errors.New("invalid NIP-05 response")
ErrNIP05HTTPError = errors.New("HTTP error during NIP-05 resolution")
ErrNIP05PubkeyNotFound = errors.New("pubkey not found in NIP-05 response")
// Relay discovery errors
ErrRelayDiscoveryFailed = errors.New("relay discovery failed")
ErrNoInboxRelaysFound = errors.New("no inbox relays found")
ErrRelayConnectionFailed = errors.New("relay connection failed")
ErrRelayAuthFailed = errors.New("relay authentication failed")
// DM sending errors
ErrDMSendFailed = errors.New("DM send failed")
ErrDMEncryptionFailed = errors.New("DM encryption failed")
ErrNostrKeyNotConfigured = errors.New("nostr private key not configured")
// Decoding errors
ErrNpubDecodingFailed = errors.New("npub decoding failed")
)
+115
View File
@@ -0,0 +1,115 @@
// Copyright (c) 2024 Ergo Contributors
// released under the MIT license
package nostr
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
// NIP05Response represents the JSON response from a .well-known/nostr.json endpoint
type NIP05Response struct {
Names map[string]string `json:"names"`
Relays map[string][]string `json:"relays,omitempty"`
}
// NIP05Config holds configuration for NIP-05 resolution
type NIP05Config struct {
Timeout time.Duration
UserAgent string
}
// ResolveNIP05 resolves a NIP-05 identifier to a pubkey
func ResolveNIP05(identifier string, config NIP05Config) (pubkey string, relays []string, err error) {
if !IsNIP05(identifier) {
return "", nil, ErrInvalidPubkeyFormat
}
parts := strings.Split(identifier, "@")
if len(parts) != 2 {
return "", nil, ErrInvalidPubkeyFormat
}
name, domain := parts[0], parts[1]
// Construct the well-known URL
url := fmt.Sprintf("https://%s/.well-known/nostr.json?name=%s", domain, name)
// Create HTTP client with timeout
client := &http.Client{
Timeout: config.Timeout,
}
// Make the request
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return "", nil, fmt.Errorf("%w: %v", ErrNIP05HTTPError, err)
}
if config.UserAgent != "" {
req.Header.Set("User-Agent", config.UserAgent)
}
resp, err := client.Do(req)
if err != nil {
return "", nil, fmt.Errorf("%w: %v", ErrNIP05HTTPError, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", nil, fmt.Errorf("%w: HTTP %d", ErrNIP05HTTPError, resp.StatusCode)
}
// Parse the JSON response
var nip05Resp NIP05Response
if err := json.NewDecoder(resp.Body).Decode(&nip05Resp); err != nil {
return "", nil, fmt.Errorf("%w: %v", ErrNIP05InvalidResponse, err)
}
// Look up the pubkey for this name
pubkey, exists := nip05Resp.Names[name]
if !exists {
return "", nil, ErrNIP05PubkeyNotFound
}
// Validate the returned pubkey
if err := ValidateHexPubkey(pubkey); err != nil {
return "", nil, fmt.Errorf("%w: invalid pubkey in response", ErrNIP05InvalidResponse)
}
// Get relays if available
if nip05Resp.Relays != nil {
relays = nip05Resp.Relays[pubkey]
}
return pubkey, relays, nil
}
// ResolvePubkey resolves any nostr identifier to a hex pubkey
func ResolvePubkey(identifier string, config NIP05Config) (pubkey string, relays []string, err error) {
// Debug logging to see what identifier we're trying to resolve
fmt.Printf("[DEBUG] ResolvePubkey called with identifier: '%s'\n", identifier)
fmt.Printf("[DEBUG] IsNIP05: %v, IsPubkey: %v, IsNpub: %v, IsHexPubkey: %v\n",
IsNIP05(identifier), IsPubkey(identifier), IsNpub(identifier), IsHexPubkey(identifier))
if IsNIP05(identifier) {
fmt.Printf("[DEBUG] Resolving as NIP-05 identifier\n")
return ResolveNIP05(identifier, config)
} else if IsPubkey(identifier) {
fmt.Printf("[DEBUG] Resolving as pubkey, normalizing...\n")
normalizedPubkey, err := NormalizePubkey(identifier)
if err != nil {
fmt.Printf("[DEBUG] NormalizePubkey failed: %v\n", err)
return "", nil, err
}
fmt.Printf("[DEBUG] Normalized pubkey: %s\n", normalizedPubkey)
return normalizedPubkey, nil, nil
}
fmt.Printf("[DEBUG] Identifier doesn't match any known format\n")
return "", nil, ErrInvalidPubkeyFormat
}
+160
View File
@@ -0,0 +1,160 @@
// Copyright (c) 2024 Ergo Contributors
// released under the MIT license
package nostr
import (
"encoding/hex"
"fmt"
"regexp"
"strings"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip19"
)
var (
// NIP-05 identifier format: name@domain.tld
nip05Regex = regexp.MustCompile(`^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
// npub format (bech32 encoded pubkey)
npubRegex = regexp.MustCompile(`^npub1[a-zA-Z0-9]{58}$`)
// hex pubkey format (64 hex characters)
hexPubkeyRegex = regexp.MustCompile(`^[a-fA-F0-9]{64}$`)
)
// IsNostrIdentifier checks if a string could be a nostr identifier (NIP-05 or pubkey)
func IsNostrIdentifier(identifier string) bool {
return IsNIP05(identifier) || IsPubkey(identifier)
}
// IsValidNostrIdentifier validates a nostr identifier format
func IsValidNostrIdentifier(identifier string) bool {
return IsNostrIdentifier(identifier)
}
// IsNIP05 checks if the identifier is a NIP-05 address
func IsNIP05(identifier string) bool {
return nip05Regex.MatchString(identifier)
}
// IsPubkey checks if the identifier is a pubkey (npub or hex)
func IsPubkey(identifier string) bool {
return IsNpub(identifier) || IsHexPubkey(identifier)
}
// IsNpub checks if the identifier is an npub (bech32) format
func IsNpub(identifier string) bool {
return npubRegex.MatchString(identifier)
}
// IsHexPubkey checks if the identifier is a hex pubkey
func IsHexPubkey(identifier string) bool {
return hexPubkeyRegex.MatchString(identifier)
}
// NormalizePubkey converts npub to hex format, returns hex pubkey as-is
func NormalizePubkey(pubkey string) (string, error) {
fmt.Printf("[DEBUG] NormalizePubkey called with: '%s' (len=%d)\n", pubkey, len(pubkey))
fmt.Printf("[DEBUG] IsHexPubkey: %v, IsNpub: %v\n", IsHexPubkey(pubkey), IsNpub(pubkey))
if IsHexPubkey(pubkey) {
fmt.Printf("[DEBUG] Treating as hex pubkey\n")
return strings.ToLower(pubkey), nil
}
if IsNpub(pubkey) {
fmt.Printf("[DEBUG] Treating as npub, decoding...\n")
// Use go-nostr to decode npub
prefix, data, err := nip19.Decode(pubkey)
if err != nil {
fmt.Printf("[DEBUG] nip19.Decode failed: %v\n", err)
return "", ErrInvalidPubkeyFormat
}
fmt.Printf("[DEBUG] nip19.Decode success - prefix: %s, data type: %T\n", prefix, data)
// Handle both string and []byte return types from nip19.Decode
switch v := data.(type) {
case []byte:
if len(v) == 32 {
hexResult := hex.EncodeToString(v)
fmt.Printf("[DEBUG] Successfully converted npub bytes to hex: %s\n", hexResult)
return hexResult, nil
}
case string:
if len(v) == 64 {
// Already a hex string, validate it
if err := ValidateHexPubkey(v); err != nil {
fmt.Printf("[DEBUG] Invalid hex pubkey from npub: %v\n", err)
return "", ErrInvalidPubkeyFormat
}
fmt.Printf("[DEBUG] Successfully got hex string from npub: %s\n", v)
return strings.ToLower(v), nil
}
}
fmt.Printf("[DEBUG] Unexpected data format from nip19.Decode: %T, value: %v\n", data, data)
return "", ErrInvalidPubkeyFormat
}
fmt.Printf("[DEBUG] Pubkey doesn't match hex or npub format\n")
return "", ErrInvalidPubkeyFormat
}
// ValidateHexPubkey ensures a hex string is a valid pubkey
func ValidateHexPubkey(hexStr string) error {
if len(hexStr) != 64 {
return ErrInvalidPubkeyLength
}
_, err := hex.DecodeString(hexStr)
if err != nil {
return ErrInvalidHexFormat
}
return nil
}
// ValidatePrivateKey validates a hex private key
func ValidatePrivateKey(privkeyHex string) error {
if len(privkeyHex) != 64 {
return ErrInvalidPubkeyLength
}
_, err := hex.DecodeString(privkeyHex)
if err != nil {
return ErrInvalidHexFormat
}
return nil
}
// GetPubkeyFromPrivkey derives public key from private key using go-nostr
func GetPubkeyFromPrivkey(privkeyHex string) (string, error) {
if len(privkeyHex) != 64 {
return "", ErrInvalidPrivkeyLength
}
pubkey, err := nostr.GetPublicKey(privkeyHex)
if err != nil {
return "", err
}
return pubkey, nil
}
// HexToNpub converts a hex pubkey to npub format
func HexToNpub(hexPubkey string) (string, error) {
if !IsHexPubkey(hexPubkey) {
return "", ErrInvalidPubkeyFormat
}
// Encode as npub using nip19 (it expects hex string, not bytes)
npub, err := nip19.EncodePublicKey(hexPubkey)
if err != nil {
return "", err
}
return npub, nil
}
+271
View File
@@ -0,0 +1,271 @@
// Copyright (c) 2024 Ergo Contributors
// released under the MIT license
package nostr
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"time"
"github.com/nbd-wtf/go-nostr"
)
// RelayConfig holds configuration for relay operations
type RelayConfig struct {
DefaultRelays []string
Timeout time.Duration
MaxRelays int
}
// RelayInfo represents the NIP-11 relay information document
type RelayInfo struct {
Name string `json:"name"`
Description string `json:"description"`
PubKey string `json:"pubkey"`
Contact string `json:"contact"`
Limitation struct {
AuthRequired bool `json:"auth_required"`
} `json:"limitation"`
}
// DiscoverInboxRelays discovers a user's inbox relays using NIP-65
func DiscoverInboxRelays(pubkey string, config RelayConfig) ([]string, error) {
ctx, cancel := context.WithTimeout(context.Background(), config.Timeout)
defer cancel()
// Try to get relays from NIP-65 (kind 10002 events)
inboxRelays, err := queryRelayListMetadata(ctx, pubkey, config)
if err == nil && len(inboxRelays) > 0 {
return inboxRelays, nil
}
// Fallback to default relays if no inbox relays found
if len(config.DefaultRelays) > 0 {
return config.DefaultRelays, nil
}
return nil, ErrNoInboxRelaysFound
}
// queryRelayListMetadata queries for NIP-65 relay list metadata events
func queryRelayListMetadata(ctx context.Context, pubkey string, config RelayConfig) ([]string, error) {
var inboxRelays []string
// Connect to default relays to query for relay list metadata
for _, relayURL := range config.DefaultRelays {
relay, err := nostr.RelayConnect(ctx, relayURL)
if err != nil {
continue // Try next relay
}
// Query for kind 10002 events (NIP-65 relay list metadata)
filters := []nostr.Filter{{
Authors: []string{pubkey},
Kinds: []int{10002},
Limit: 1,
}}
sub, err := relay.Subscribe(ctx, filters)
if err != nil {
relay.Close()
continue
}
// Wait for events with timeout
done := false
select {
case event := <-sub.Events:
relays := parseRelayListEvent(*event)
inboxRelays = append(inboxRelays, relays...)
case <-time.After(5 * time.Second):
// Timeout waiting for event
case <-ctx.Done():
done = true
}
sub.Unsub()
relay.Close()
if done {
break
}
if len(inboxRelays) > 0 {
break // Found relays, no need to query more
}
}
if len(inboxRelays) == 0 {
return nil, ErrRelayDiscoveryFailed
}
return inboxRelays, nil
}
// parseRelayListEvent parses a NIP-65 relay list metadata event
func parseRelayListEvent(event nostr.Event) []string {
var inboxRelays []string
for _, tag := range event.Tags {
if len(tag) >= 2 && tag[0] == "r" {
relayURL := tag[1]
// Check if this is an inbox relay (read capability)
// If no marker is specified, assume both read and write
isInbox := true
if len(tag) >= 3 {
marker := tag[2]
isInbox = marker == "read" || marker == ""
}
if isInbox {
inboxRelays = append(inboxRelays, relayURL)
}
}
}
return inboxRelays
}
// checkRelayRequiresAuth checks if a relay requires authentication via NIP-11
func checkRelayRequiresAuth(url string) bool {
httpURL := strings.Replace(strings.Replace(url, "ws://", "http://", 1), "wss://", "https://", 1)
client := &http.Client{
Timeout: time.Second * 5,
}
req, err := http.NewRequest("GET", httpURL, nil)
if err != nil {
return false
}
req.Header.Set("Accept", "application/nostr+json")
resp, err := client.Do(req)
if err != nil {
return false
}
defer resp.Body.Close()
var info RelayInfo
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
return false
}
return info.Limitation.AuthRequired
}
// ConnectToRelay establishes a connection to a nostr relay with optional NIP-42 auth
func ConnectToRelay(ctx context.Context, url string, privkey string) (*nostr.Relay, error) {
log.Printf("Connecting to relay: %s\n", url)
relay, err := nostr.RelayConnect(ctx, url)
if err != nil {
log.Printf("Failed to connect to relay: %s, error: %v\n", url, err)
return nil, fmt.Errorf("%w: %v", ErrRelayConnectionFailed, err)
}
log.Printf("Connected to relay: %s\n", url)
// Check if relay requires auth before attempting authentication
if privkey != "" && checkRelayRequiresAuth(url) {
log.Printf("Relay requires authentication: %s\n", url)
err = relay.Auth(ctx, func(authEvent *nostr.Event) error {
// Validate challenge tag is present and not empty
challengeTag := authEvent.Tags.Find("challenge")
if len(challengeTag) < 2 || challengeTag[1] == "" || challengeTag[1] == " " {
return fmt.Errorf("invalid or missing challenge in auth event")
}
return authEvent.Sign(privkey)
})
if err != nil {
log.Printf("Failed to authenticate with relay: %s, error: %v\n", url, err)
// Don't fail connection on auth error - some operations might still work
// The relay will reject operations that require auth if needed
} else {
log.Printf("Authenticated with relay: %s\n", url)
}
}
return relay, nil
}
// SendEventToRelay sends a nostr event to a relay
func SendEventToRelay(ctx context.Context, relay *nostr.Relay, event nostr.Event) error {
err := relay.Publish(ctx, event)
if err != nil {
return fmt.Errorf("%w: %v", ErrDMSendFailed, err)
}
return nil
}
// CheckPrivateRelays checks if a user has private relays (NIP-50 kind 10050)
func CheckPrivateRelays(ctx context.Context, pubkey string, config RelayConfig) ([]string, bool, error) {
var privateRelays []string
// Connect to default relays to query for private relay list
for _, relayURL := range config.DefaultRelays {
relay, err := nostr.RelayConnect(ctx, relayURL)
if err != nil {
continue
}
// Query for kind 10050 events (private relay list)
filters := []nostr.Filter{{
Authors: []string{pubkey},
Kinds: []int{10050},
Limit: 1,
}}
sub, err := relay.Subscribe(ctx, filters)
if err != nil {
relay.Close()
continue
}
// Wait for events
done := false
select {
case event := <-sub.Events:
privateRelays = parsePrivateRelayList(*event)
case <-time.After(5 * time.Second):
// Timeout
case <-ctx.Done():
done = true
}
sub.Unsub()
relay.Close()
if done {
break
}
if len(privateRelays) > 0 {
return privateRelays, true, nil
}
}
return nil, false, nil
}
// parsePrivateRelayList parses a NIP-50 private relay list event
func parsePrivateRelayList(event nostr.Event) []string {
var privateRelays []string
for _, tag := range event.Tags {
if len(tag) >= 2 && tag[0] == "relay" {
privateRelays = append(privateRelays, tag[1])
}
}
return privateRelays
}
+3
View File
@@ -103,6 +103,7 @@ type Server struct {
apiHandler http.Handler // always initialized
apiListener *utils.ReloadableListener
apiServer *http.Server // nil if API is not enabled
}
// NewServer returns a new Oragono server.
@@ -135,6 +136,7 @@ func NewServer(config *Config, logger *logger.Manager) (*Server, error) {
return nil, err
}
// Attempt to clean up when receiving these signals.
signal.Notify(server.exitSignals, utils.ServerExitSignals...)
signal.Notify(server.rehashSignal, syscall.SIGHUP)
@@ -161,6 +163,7 @@ func (server *Server) Shutdown() {
// flush data associated with always-on clients:
server.performAlwaysOnMaintenance(false, true)
if err := server.store.Close(); err != nil {
server.logger.Error("shutdown", fmt.Sprintln("Could not close datastore:", err))
}