mirror of
https://github.com/jeremyd/ergo.git
synced 2026-06-29 21:52:05 -07:00
noirc launch
This commit is contained in:
+331
-253
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -399,7 +399,7 @@ func init() {
|
||||
minParams: 1,
|
||||
},
|
||||
"WHOIS": {
|
||||
handler: whoisHandler,
|
||||
handler: whoHandler,
|
||||
minParams: 1,
|
||||
},
|
||||
"WHOWAS": {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user