summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorraven <citrons@mondecitronne.com>2025-10-22 16:28:22 -0500
committerraven <citrons@mondecitronne.com>2026-02-09 13:13:55 -0600
commitff0f6ba724748dbe340187fdd831a4d4f7f0ae5e (patch)
treeab27a0c9f40d803c2bf130ef7c82cdf6540e9018
parent4b54a1d11fd0fa355b244637612a3fd0af18c60c (diff)
passwords
-rw-r--r--client/application.go100
-rw-r--r--client/cmd_window.go8
-rw-r--r--client/command.go9
-rw-r--r--client/login_prompt.go96
-rw-r--r--client/ui.go8
-rw-r--r--proto/strfail.go10
-rw-r--r--server/passwords/passwords.go38
-rw-r--r--server/server/command.go57
-rw-r--r--server/session/session.go1
-rw-r--r--server/user/command.go37
-rw-r--r--server/user/user.go51
-rw-r--r--server/validate/validate.go12
-rw-r--r--tui/text_input.go19
13 files changed, 400 insertions, 46 deletions
diff --git a/client/application.go b/client/application.go
index d396f24..640abd6 100644
--- a/client/application.go
+++ b/client/application.go
@@ -145,37 +145,109 @@ func (a *application) GetInfo(id string, callback func(*proto.Object)) {
})
}
-func (a *application) auth(name string, authCallback func(success bool)) {
- callback := func(response proto.Command) {
+func (a *application) authAnon(as string, callback func(ok bool, uid string)) {
+ cb := func(response proto.Command) {
+ switch response.Kind {
+ case "you-are":
+ if len(response.Args) == 0 {
+ callback(false, "")
+ break
+ }
+ me := response.Args[0]
+ a.onAuth(me.Id)
+ if callback != nil {
+ callback(true, me.Id)
+ }
+ case "fail":
+ var uid string
+ if len(response.Args) != 0 {
+ if response.Args[0].Kind == "name-taken" &&
+ response.Args[0].Fields["anonymous"] == "no" {
+ uid = response.Args[0].Fields["id"]
+ } else {
+ a.cmdWindow.err(proto.Strfail(response.Args[0]))
+ }
+ }
+ if callback != nil {
+ callback(false, uid)
+ }
+ }
+ }
+ a.Request(proto.NewCmd("auth", "", proto.Object {
+ "anonymous", "", map[string]string {"": as},
+ }), cb)
+}
+
+func (a *application) authPassword(
+ uid string, password string, callback func(ok bool)) {
+ cb := func(response proto.Command) {
switch response.Kind {
case "you-are":
if len(response.Args) == 0 {
- authCallback(false)
+ callback(false)
break
}
me := response.Args[0]
- a.authenticated = true
- a.uid = me.Id
- a.cache.Watch(a.uid)
- a.onAuth()
- if authCallback != nil {
- authCallback(true)
+ a.onAuth(me.Id)
+ if callback != nil {
+ callback(true)
}
case "fail":
if len(response.Args) != 0 {
a.cmdWindow.err(proto.Strfail(response.Args[0]))
}
- if authCallback != nil {
- authCallback(false)
+ if callback != nil {
+ callback(false)
}
}
}
a.Request(proto.NewCmd("auth", "", proto.Object {
- "anonymous", "", map[string]string {"": name},
- }), callback)
+ "password", "", map[string]string {"": password, "id": uid},
+ }), cb)
+}
+
+func (a *application) setPassword(password string, callback func(ok bool)) {
+ cb := func(response proto.Command) {
+ switch response.Kind {
+ case "ok":
+ if callback != nil {
+ callback(true)
+ }
+ case "fail":
+ if len(response.Args) != 0 &&
+ response.Args[0].Kind == "password-required" {
+ lp := newLoginPrompt("")
+ lp.uid = a.uid
+ lp.customPrompt = "current password"
+ lp.callback = func(ok bool) {
+ if ok {
+ a.setPassword(password, callback)
+ } else {
+ if callback != nil {
+ callback(false)
+ }
+ }
+ }
+ a.pushPrompt(lp)
+ } else {
+ if len(response.Args) != 0 {
+ a.cmdWindow.err(proto.Strfail(response.Args[0]))
+ }
+ if callback != nil {
+ callback(false)
+ }
+ }
+ }
+ }
+ a.Request(proto.NewCmd("auth-update", a.uid, proto.Object {
+ "password", "", map[string]string {"": password},
+ }), cb)
}
-func (a *application) onAuth() {
+func (a *application) onAuth(uid string) {
+ a.authenticated = true
+ a.uid = uid
+ a.cache.Watch(uid)
a.Request(proto.NewCmd("channels", ""), func(response proto.Command) {
if response.Kind == "channels" {
previousChannels := make(channelList, len(a.channelList))
diff --git a/client/cmd_window.go b/client/cmd_window.go
index dccbaba..f849560 100644
--- a/client/cmd_window.go
+++ b/client/cmd_window.go
@@ -26,6 +26,7 @@ const (
logInfo = iota
logErr
logCmd
+ logWarn
)
func (m logMsg) Id() string {
@@ -39,6 +40,8 @@ func (m logMsg) Show(odd bool) {
style = &tui.Style {Bg: colorErr[odd], Fg: tui.White}
case logCmd:
style = &tui.Style {Bg: colorCmd[odd], Fg: tui.White}
+ case logWarn:
+ style = &tui.Style {Bg: colorDefault[odd], Fg: 229}
}
tui.Push("", tui.Box {
@@ -138,6 +141,11 @@ func (w *cmdWindow) info(f string, a ...any) {
w.Buf.Add(logMsg {lastIndex, fmt.Sprintf(f, a...), logInfo})
}
+func (w *cmdWindow) warn(f string, a ...any) {
+ lastIndex++
+ w.Buf.Add(logMsg {lastIndex, fmt.Sprintf(f, a...), logWarn})
+}
+
func (w *cmdWindow) err(f string, a ...any) {
lastIndex++
w.Buf.Add(logMsg {lastIndex, fmt.Sprintf(f, a...), logErr})
diff --git a/client/command.go b/client/command.go
index 5ceb45b..64f8cd8 100644
--- a/client/command.go
+++ b/client/command.go
@@ -179,9 +179,14 @@ func (a *application) doCommand(command string, args []string, text string) {
})
return
case "create":
- if a.authenticated {
- a.createChannel(text)
+ a.createChannel(text)
+ return
+ case "password":
+ if text == "" {
+ a.cmdWindow.err("password cannot be empty")
+ return
}
+ a.pushPrompt(&passwordChangePrompt {password: text})
return
case "debug":
a.goTo(debugWindowLocation{})
diff --git a/client/login_prompt.go b/client/login_prompt.go
index c19d987..ec5b36b 100644
--- a/client/login_prompt.go
+++ b/client/login_prompt.go
@@ -7,7 +7,11 @@ import (
type loginPrompt struct {
input tui.TextInput
+ uid string
username string
+ customPrompt string
+ waiting bool
+ callback func(ok bool)
}
func newLoginPrompt(completeName string) *loginPrompt {
@@ -21,28 +25,96 @@ func newLoginPrompt(completeName string) *loginPrompt {
}
func (p *loginPrompt) Input() *tui.TextInput {
- return &p.input
+ if !p.waiting {
+ return &p.input
+ } else {
+ return &tui.TextInput {}
+ }
}
func (p *loginPrompt) Send(text string) {
- if p.username != "" {
- return
- }
- p.username = text
- globalApp.auth(text, func(success bool) {
- if success {
+ if p.uid == "" {
+ p.username = text
+ globalApp.authAnon(text, func(success bool, uid string) {
+ if success {
+ globalApp.removePrompt(p)
+ globalApp.cmdWindow.warn(
+ "you have created a temporary account. " +
+ "set a password to keep it.",
+ )
+ } else if uid == "" {
+ p.username = ""
+ } else {
+ p.uid = uid
+ p.input.SetText("")
+ p.input.Private = true
+ }
+ })
+ } else {
+ p.input.SetText("")
+ p.waiting = true
+ globalApp.authPassword(p.uid, text, func(success bool) {
+ p.waiting = false
+ if !globalApp.authenticated {
+ p.uid = ""
+ p.input.SetText(p.username)
+ p.input.Private = false
+ return
+ }
globalApp.removePrompt(p)
+ if p.callback != nil {
+ p.callback(success)
+ }
+ })
+ }
+}
+
+func (p *loginPrompt) ShowStatusLine() {
+ tui.Text("[", nil)
+ tui.Text("login", &tui.Style {
+ Bg: tui.White, Fg: tui.Blue, Bold: true,
+ })
+ tui.Text("] ", nil)
+ if p.customPrompt == "" {
+ if p.uid == "" {
+ tui.Text("username", nil)
} else {
- p.username = ""
+ tui.Text("password", nil)
}
- })
+ } else {
+ tui.Text(p.customPrompt, nil)
+ }
+ tui.Text(":", nil)
}
-func (p *loginPrompt) ShowStatusLine() {
+type passwordChangePrompt struct {
+ input tui.TextInput
+ password string
+}
+
+func (p *passwordChangePrompt) Input() *tui.TextInput {
+ p.input.Private = true
+ return &p.input
+}
+
+func (p *passwordChangePrompt) Send(text string) {
+ if text != p.password {
+ globalApp.cmdWindow.err("passwords do not match")
+ } else {
+ globalApp.setPassword(text, func(ok bool) {
+ if ok {
+ globalApp.cmdWindow.info("password changed")
+ }
+ })
+ }
+ globalApp.removePrompt(p)
+}
+
+func (p *passwordChangePrompt) ShowStatusLine() {
tui.Text("[", nil)
tui.Text("login", &tui.Style {
Bg: tui.White, Fg: tui.Blue, Bold: true,
})
- tui.Text("]", nil)
- tui.Text(" username:", nil)
+ tui.Text("] ", nil)
+ tui.Text("confirm new password:", nil)
}
diff --git a/client/ui.go b/client/ui.go
index e5917fa..6ea9fa5 100644
--- a/client/ui.go
+++ b/client/ui.go
@@ -190,7 +190,13 @@ func (a *application) showWindow() {
Width: tui.Fill, Height: tui.Children, Dir: tui.Right,
})
a.showNickBox()
- prompt.Input().Show("input")
+ input := prompt.Input()
+ priv := input.Private
+ if strings.HasPrefix(input.Text(), "/") {
+ input.Private = strings.HasPrefix(input.Text(), "/password ")
+ }
+ input.Show("input")
+ input.Private = priv
tui.Pop()
tui.Pop()
diff --git a/proto/strfail.go b/proto/strfail.go
index 1ce160c..477df1a 100644
--- a/proto/strfail.go
+++ b/proto/strfail.go
@@ -19,8 +19,14 @@ func Strfail(fail Object) string {
case "not-in-channel":
return "you are not a member of this channel: " + fail.Fields[""]
case "bad-reply":
- return "this is not a message you can reply to"
+ return "cannot reply to message (or message not found)"
+ case "incorrect-password":
+ return "incorrect username or password"
+ case "password-required":
+ return "you must log in with a password to do that"
+ case "bad-auth-id":
+ return "cannot login as this user (or user not found)"
default:
- return "unknown error"
+ return "unknown error (" + fail.Kind + ")"
}
}
diff --git a/server/passwords/passwords.go b/server/passwords/passwords.go
new file mode 100644
index 0000000..0524f24
--- /dev/null
+++ b/server/passwords/passwords.go
@@ -0,0 +1,38 @@
+package passwords
+
+import (
+ "golang.org/x/crypto/argon2"
+ "crypto/subtle"
+ "crypto/rand"
+ "bytes"
+ "log"
+)
+
+const version = 0
+
+func doHash(ver int, password string, salt []byte) []byte {
+ return argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32)
+}
+
+func Hash(password string) []byte {
+ var buf bytes.Buffer
+ buf.WriteByte(version)
+
+ salt := make([]byte, 32)
+ _, err := rand.Read(salt)
+ if err != nil {
+ log.Fatal("error generating password hash:", err)
+ }
+ buf.Write(doHash(version, password, salt))
+ buf.Write(salt)
+
+ return buf.Bytes()
+}
+
+func Check(password string, hash []byte) bool {
+ ver := int(hash[0])
+ hashData := hash[1:33]
+ salt := hash[33:]
+ check := doHash(ver, password, salt)
+ return subtle.ConstantTimeCompare(hashData, check) == 1
+}
diff --git a/server/server/command.go b/server/server/command.go
index 75f3bc0..9c57fc1 100644
--- a/server/server/command.go
+++ b/server/server/command.go
@@ -11,28 +11,71 @@ func (s *server) SendRequest(r session.Request) {
switch (r.Cmd.Kind) {
case "auth":
- if r.From.UserId != "" {
- r.ReplyInvalid()
- return
- }
if len(r.Cmd.Args) != 1 {
r.ReplyInvalid()
return
}
auth := r.Cmd.Args[0]
switch auth.Kind {
+
case "anonymous":
+ if r.From.UserId != "" {
+ r.ReplyInvalid()
+ return
+ }
if (auth.Fields[""] == "") {
r.ReplyInvalid()
return
}
- user, err := s.userKind.CreateUser(auth.Fields[""])
+ u, err := s.userKind.CreateUser(auth.Fields[""])
if err != nil {
r.Reply(err.Cmd())
return
}
- r.Reply(proto.NewCmd("you-are", "", user.InfoFor(r.From.UserId)))
- r.From.UserId = user.Id()
+ r.Reply(proto.NewCmd("you-are", "", u.InfoFor(u.Id())))
+ r.From.UserId = u.Id()
+ r.From.Subscribe(&u.PrivateStream)
+
+ case "password":
+ var id, password string
+ for k, v := range auth.Fields {
+ switch k {
+ case "":
+ password = v
+ case "id":
+ id = v
+ default:
+ r.ReplyInvalid()
+ return
+ }
+ }
+ if id == "" || password == "" {
+ r.ReplyInvalid()
+ return
+ }
+ if r.From.UserId != "" && id != r.From.UserId {
+ r.Reply(proto.Fail{"bad-auth-id", "", nil}.Cmd())
+ return
+ }
+
+ switch u := s.world.GetObject(id).(type) {
+ case *user.User:
+ if u.IsAnonymous() {
+ r.Reply(proto.Fail{"bad-auth-id", "", nil}.Cmd())
+ return
+ }
+ if u.CheckPassword(password) {
+ r.Reply(proto.NewCmd("you-are", "", u.InfoFor(u.Id())))
+ r.From.UserId = u.Id()
+ r.From.PasswordAuthed = true
+ r.From.Subscribe(&u.PrivateStream)
+ } else {
+ r.Reply(proto.Fail{"incorrect-password", "", nil}.Cmd())
+ }
+ default:
+ r.Reply(proto.Fail{"bad-auth-id", "", nil}.Cmd())
+ }
+
default:
r.ReplyInvalid()
}
diff --git a/server/session/session.go b/server/session/session.go
index df7ab65..bb6749a 100644
--- a/server/session/session.go
+++ b/server/session/session.go
@@ -7,6 +7,7 @@ import (
type Session struct {
send chan<- proto.Line
UserId string
+ PasswordAuthed bool
subscribedTo map[*Stream]bool
sendError error
}
diff --git a/server/user/command.go b/server/user/command.go
index 47a3efb..b4c6d5f 100644
--- a/server/user/command.go
+++ b/server/user/command.go
@@ -51,6 +51,43 @@ func (u *User) SendRequest(r session.Request) {
u.Stream.Event(r.Cmd)
r.ReplyOk()
+ case "auth-update":
+ if r.From.UserId != u.Id() {
+ r.Reply(proto.Fail{"forbidden", "", nil}.Cmd())
+ return
+ }
+ if !u.anonymous && !r.From.PasswordAuthed {
+ r.Reply(proto.Fail{"password-required", "", nil}.Cmd())
+ return
+ }
+ if len(r.Cmd.Args) != 1 {
+ r.ReplyInvalid()
+ return
+ }
+ upd := r.Cmd.Args[0]
+ switch upd.Kind {
+ case "password":
+ var password string
+ for k, v := range upd.Fields {
+ switch k {
+ case "":
+ password = v
+ default:
+ r.ReplyInvalid()
+ }
+ }
+ if password == "" {
+ r.ReplyInvalid()
+ return
+ }
+ u.SetPassword(password)
+ r.From.PasswordAuthed = true
+ r.ReplyOk()
+ default:
+ r.ReplyInvalid()
+ return
+ }
+
case "i":
r.Reply(proto.NewCmd("i", "", u.InfoFor(r.From.UserId)))
diff --git a/server/user/user.go b/server/user/user.go
index 311a1b8..d2d5724 100644
--- a/server/user/user.go
+++ b/server/user/user.go
@@ -4,6 +4,7 @@ import (
"citrons.xyz/talk/server/object"
"citrons.xyz/talk/server/session"
"citrons.xyz/talk/server/validate"
+ "citrons.xyz/talk/server/passwords"
"citrons.xyz/talk/proto"
bolt "go.etcd.io/bbolt"
"log"
@@ -30,9 +31,16 @@ func Kind(world *object.World) *UserKind {
}
func (us *UserKind) CreateUser(name string) (*User, *proto.Fail) {
- if us.ByName(name) != nil {
+ existing := us.ByName(name)
+ if existing != nil {
+ anon := "no"
+ if existing.anonymous {
+ anon = "yes"
+ }
return nil, &proto.Fail {
- "name-taken", "", map[string]string {"": name},
+ "name-taken", "", map[string]string {
+ "": name, "anonymous": anon, "id": existing.Id(),
+ },
}
}
if !validate.Name(name) {
@@ -157,6 +165,8 @@ func (u *User) Delete() {
anons.Delete([]byte(u.id))
channels, _ := tx.CreateBucketIfNotExists([]byte("user channels"))
channels.DeleteBucket([]byte(u.id))
+ auth, _ := tx.CreateBucketIfNotExists([]byte("auth"))
+ auth.DeleteBucket([]byte(u.id))
return nil
})
if err != nil {
@@ -180,3 +190,40 @@ func (u *User) InfoFor(uid string) proto.Object {
func (u *User) IsAnonymous() bool {
return u.anonymous
}
+
+func (u *User) CheckPassword(password string) bool {
+ var hash []byte
+ err := u.kind.db.View(func(tx *bolt.Tx) error {
+ auth := tx.Bucket([]byte("auth"))
+ if auth == nil {
+ return nil
+ }
+ userData := auth.Bucket([]byte(u.id))
+ if userData == nil {
+ return nil
+ }
+ hash = userData.Get([]byte("password hash"))
+ return nil
+ })
+ if err != nil {
+ log.Fatal("error reading database: ", err)
+ }
+ if hash == nil {
+ return false
+ }
+ return passwords.Check(password, hash)
+}
+
+func (u *User) SetPassword(password string) {
+ err := u.kind.db.Update(func(tx *bolt.Tx) error {
+ auth, _ := tx.CreateBucketIfNotExists([]byte("auth"))
+ userData, _ := auth.CreateBucketIfNotExists([]byte(u.id))
+ userData.Put([]byte("password hash"), passwords.Hash(password))
+ return nil
+ })
+ if err != nil {
+ log.Fatal("error updating database: ", err)
+ }
+ u.anonymous = false
+ u.Save()
+}
diff --git a/server/validate/validate.go b/server/validate/validate.go
index 7aa7db0..7251895 100644
--- a/server/validate/validate.go
+++ b/server/validate/validate.go
@@ -21,12 +21,20 @@ func Fold(s string) string {
var sb strings.Builder
var wasSpace bool
for _, r := range s {
- for r < unicode.SimpleFold(r) {
- r = unicode.SimpleFold(r)
+ for {
+ f := unicode.SimpleFold(r)
+ if f <= r {
+ r = f
+ break
+ }
+ r = f
}
+ r = unicode.ToLower(r)
+
if !unicode.IsPrint(r) {
continue
}
+
if r == ' ' {
if wasSpace {
continue
diff --git a/tui/text_input.go b/tui/text_input.go
index 7f8e539..24792bc 100644
--- a/tui/text_input.go
+++ b/tui/text_input.go
@@ -13,8 +13,11 @@ type TextInput struct {
selection string
selectionBefore bool
afterCursor string
+ Private bool
}
+var selectedStyle = Style {Bg: Blue, Fg: White, selected: true}
+
func (t *TextInput) Text() string {
return t.beforeCursor + t.selection + t.afterCursor
}
@@ -225,19 +228,27 @@ func (t *TextInput) Update(ev Event) (usedKeybind bool) {
return true
}
+func (t *TextInput) showText(text string, style *Style) {
+ if !t.Private {
+ Text(text, style)
+ } else {
+ Text(strings.Repeat("*", len([]rune(text))), style)
+ }
+}
+
func (t *TextInput) Show(id string) {
t.id = id
Push(id, Box {Width: Fill, Height: TextSize})
- Text(t.beforeCursor, nil)
+ t.showText(t.beforeCursor, nil)
if t.selectionBefore {
- Text(t.selection, &Style {Bg: Blue, Fg: White, selected: true})
+ t.showText(t.selection, &selectedStyle)
}
if t.selection == "" {
Cursor()
}
if !t.selectionBefore {
- Text(t.selection, &Style {Bg: Blue, Fg: White, selected: true})
+ t.showText(t.selection, &selectedStyle)
}
- Text(t.afterCursor, nil)
+ t.showText(t.afterCursor, nil)
Pop()
}