diff --git a/srv/api/api.go b/srv/api/api.go index 15627f8..39d73d9 100644 --- a/srv/api/api.go +++ b/srv/api/api.go @@ -22,10 +22,11 @@ import ( // Params are used to instantiate a new API instance. All fields are required // unless otherwise noted. type Params struct { - Logger *mlog.Logger - PowManager pow.Manager - MailingList mailinglist.MailingList - GlobalRoom chat.Room + Logger *mlog.Logger + PowManager pow.Manager + MailingList mailinglist.MailingList + GlobalRoom chat.Room + UserIDCalculator chat.UserIDCalculator // ListenProto and ListenAddr are passed into net.Listen to create the // API's listener. Both "tcp" and "unix" protocols are explicitly diff --git a/srv/api/chat.go b/srv/api/chat.go index 84fffde..55d9d02 100644 --- a/srv/api/chat.go +++ b/srv/api/chat.go @@ -4,6 +4,8 @@ import ( "errors" "fmt" "net/http" + "strings" + "unicode" "github.com/mediocregopher/blog.mediocregopher.com/srv/chat" ) @@ -39,3 +41,48 @@ func (a *api) chatHistoryHandler() http.Handler { }) }) } + +func (a *api) getUserID(r *http.Request) (chat.UserID, error) { + name := r.PostFormValue("name") + if l := len(name); l == 0 { + return chat.UserID{}, errors.New("name is required") + } else if l > 16 { + return chat.UserID{}, errors.New("name too long") + } + + nameClean := strings.Map(func(r rune) rune { + if !unicode.IsPrint(r) { + return -1 + } + return r + }, name) + + if nameClean != name { + return chat.UserID{}, errors.New("name contains invalid characters") + } + + password := r.PostFormValue("password") + if l := len(password); l == 0 { + return chat.UserID{}, errors.New("password is required") + } else if l > 128 { + return chat.UserID{}, errors.New("password too long") + } + + return a.params.UserIDCalculator.Calculate(name, password), nil +} + +func (a *api) getUserIDHandler() http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + userID, err := a.getUserID(r) + if err != nil { + badRequest(rw, r, err) + return + } + + jsonResult(rw, r, struct { + UserID chat.UserID `json:"userID"` + }{ + UserID: userID, + }) + }) +} diff --git a/srv/chat/chat.go b/srv/chat/chat.go index 44449cd..ae305ac 100644 --- a/srv/chat/chat.go +++ b/srv/chat/chat.go @@ -29,17 +29,6 @@ var ( errInvalidMessageID = ErrInvalidArg{Err: errors.New("invalid Message ID")} ) -// UserID uniquely identifies an individual user who has posted a message in a -// Room. -type UserID struct { - - // Name will be the user's chosen display name. - Name string `json:"name"` - - // Hash will be a hex string generated from a secret only the user knows. - Hash string `json:"id"` -} - // Message describes a message which has been posted to a Room. type Message struct { ID string `json:"id"` diff --git a/srv/chat/user.go b/srv/chat/user.go new file mode 100644 index 0000000..1279a45 --- /dev/null +++ b/srv/chat/user.go @@ -0,0 +1,68 @@ +package chat + +import ( + "encoding/hex" + "fmt" + "sync" + + "golang.org/x/crypto/argon2" +) + +// UserID uniquely identifies an individual user who has posted a message in a +// Room. +type UserID struct { + + // Name will be the user's chosen display name. + Name string `json:"name"` + + // Hash will be a hex string generated from a secret only the user knows. + Hash string `json:"id"` +} + +// UserIDCalculator is used to calculate UserIDs. +type UserIDCalculator struct { + + // Secret is used when calculating UserID Hash salts. + Secret []byte + + // TimeCost, MemoryCost, and Threads are used as inputs to the Argon2id + // algorithm which is used to generate the Hash. + TimeCost, MemoryCost uint32 + Threads uint8 + + // HashLen specifies the number of bytes the Hash should be. + HashLen uint32 + + // Lock, if set, forces concurrent Calculate calls to occur sequentially. + Lock *sync.Mutex +} + +// NewUserIDCalculator returns a UserIDCalculator with sane defaults. +func NewUserIDCalculator(secret []byte) UserIDCalculator { + return UserIDCalculator{ + Secret: secret, + TimeCost: 15, + MemoryCost: 128 * 1024, + Threads: 2, + HashLen: 16, + Lock: new(sync.Mutex), + } +} + +// Calculate accepts a name and password and returns the calculated UserID. +func (c UserIDCalculator) Calculate(name, password string) UserID { + + input := fmt.Sprintf("%q:%q", name, password) + + hashB := argon2.IDKey( + []byte(input), + c.Secret, // salt + c.TimeCost, c.MemoryCost, c.Threads, + c.HashLen, + ) + + return UserID{ + Name: name, + Hash: hex.EncodeToString(hashB), + } +} diff --git a/srv/chat/user_test.go b/srv/chat/user_test.go new file mode 100644 index 0000000..2169cde --- /dev/null +++ b/srv/chat/user_test.go @@ -0,0 +1,26 @@ +package chat + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestUserIDCalculator(t *testing.T) { + + const name, password = "name", "password" + + c := NewUserIDCalculator([]byte("foo")) + + // calculating with same params twice should result in same UserID + userID := c.Calculate(name, password) + assert.Equal(t, userID, c.Calculate(name, password)) + + // changing either name or password should result in a different Hash + assert.NotEqual(t, userID.Hash, c.Calculate(name+"!", password).Hash) + assert.NotEqual(t, userID.Hash, c.Calculate(name, password+"!").Hash) + + // changing the secret should change the UserID + c.Secret = []byte("bar") + assert.NotEqual(t, userID, c.Calculate(name, password)) +} diff --git a/srv/cmd/userid-calc-cli/main.go b/srv/cmd/userid-calc-cli/main.go new file mode 100644 index 0000000..c86ae5d --- /dev/null +++ b/srv/cmd/userid-calc-cli/main.go @@ -0,0 +1,27 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + + "github.com/mediocregopher/blog.mediocregopher.com/srv/chat" +) + +func main() { + + secret := flag.String("secret", "", "Secret to use when calculating UserIDs") + name := flag.String("name", "", "") + password := flag.String("password", "", "") + flag.Parse() + + calc := chat.NewUserIDCalculator([]byte(*secret)) + userID := calc.Calculate(*name, *password) + + b, err := json.Marshal(userID) + if err != nil { + panic(err) + } + + fmt.Println(string(b)) +} diff --git a/srv/cmd/userid-calc-cli/userid-calc-cli b/srv/cmd/userid-calc-cli/userid-calc-cli new file mode 100755 index 0000000..ca18cc3 Binary files /dev/null and b/srv/cmd/userid-calc-cli/userid-calc-cli differ diff --git a/srv/go.mod b/srv/go.mod index e2e43f0..8587186 100644 --- a/srv/go.mod +++ b/srv/go.mod @@ -13,4 +13,5 @@ require ( github.com/stretchr/testify v1.7.0 github.com/tilinna/clock v1.1.0 github.com/ziutek/mymysql v1.5.4 // indirect + golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 // indirect ) diff --git a/srv/go.sum b/srv/go.sum index b5b3455..6c96538 100644 --- a/srv/go.sum +++ b/srv/go.sum @@ -176,6 +176,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20191122220453-ac88ee75c92c/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a h1:vclmkQCjlDX5OydZ9wv8rBCcS0QyQY66Mpf/7BZbInM= golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 h1:HWj/xjIHfjYU5nVXpTM0s39J9CbLn7Cc5a7IC5rwsMQ= +golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -187,6 +189,7 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -201,9 +204,15 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894 h1:Cz4ceDQGXuKRnVBDTS23GTn/pU5OE2C0WrNTOYK1Uuc= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=