From 397c58a882507fad4cad95a6202f0a72acb235ba Mon Sep 17 00:00:00 2001 From: Sijmen Date: Sat, 13 Jan 2024 12:49:08 +0100 Subject: [PATCH] Add Argon2 credential hash support (#2888) * Add argon2 credential hash support * update README, tests and documentation --------- Co-authored-by: aler9 <46489434+aler9@users.noreply.github.com> --- README.md | 24 ++++- go.mod | 1 + go.sum | 2 + internal/conf/credential.go | 101 ++++++++++++++++-- internal/conf/credential_test.go | 167 ++++++++++++++++++++++++++++++ internal/conf/path.go | 22 ++-- internal/core/auth.go | 38 ++----- internal/core/rtsp_server_test.go | 25 ++++- mediamtx.yml | 8 +- 9 files changed, 330 insertions(+), 58 deletions(-) create mode 100644 internal/conf/credential_test.go diff --git a/README.md b/README.md index 45356d2bc4e..c1e9ecabd9d 100644 --- a/README.md +++ b/README.md @@ -1035,14 +1035,30 @@ It's possible to setup authentication for readers too: ```yml pathDefaults: - readUser: user - readPass: userpass + readUser: myuser + readPass: mypass ``` -If storing plain credentials in the configuration file is a security problem, username and passwords can be stored as sha256-hashed strings; a string must be hashed with sha256 and encoded with base64: +If storing plain credentials in the configuration file is a security problem, username and passwords can be stored as hashed strings. The Argon2 and SHA256 hashing algorithms are supported. + +To use Argon2, the string must be hashed using Argon2id (recommended) or Argon2i: + +``` +echo -n "mypass" | argon2 saltItWithSalt -id -l 32 -e +``` + +Then stored with the `argon2:` prefix: + +```yml +pathDefaults: + readUser: argon2:$argon2id$v=19$m=4096,t=3,p=1$MTIzNDU2Nzg$OGGO0eCMN0ievb4YGSzvS/H+Vajx1pcbUmtLp2tRqRU + readPass: argon2:$argon2i$v=19$m=4096,t=3,p=1$MTIzNDU2Nzg$oct3kOiFywTdDdt19kT07hdvmsPTvt9zxAUho2DLqZw +``` + +To use SHA256, the string must be hashed with SHA256 and encoded with base64: ``` -echo -n "userpass" | openssl dgst -binary -sha256 | openssl base64 +echo -n "mypass" | openssl dgst -binary -sha256 | openssl base64 ``` Then stored with the `sha256:` prefix: diff --git a/go.mod b/go.mod index cacd76731d1..2918a0f0093 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/gookit/color v1.5.4 github.com/gorilla/websocket v1.5.1 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 + github.com/matthewhartstonge/argon2 v1.0.0 github.com/notedit/rtmp v0.0.2 github.com/pion/ice/v2 v2.3.11 github.com/pion/interceptor v0.1.25 diff --git a/go.sum b/go.sum index 15a85f5a788..01856626fc1 100644 --- a/go.sum +++ b/go.sum @@ -102,6 +102,8 @@ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= +github.com/matthewhartstonge/argon2 v1.0.0 h1:e65fkae6O8Na6YTy2HAccUbXR+GQHOnpQxeWGqWCRIw= +github.com/matthewhartstonge/argon2 v1.0.0/go.mod h1:Fm4FHZxdxCM6hg21Jkz3YZVKnU7VnTlqDQ3ghS/Myok= github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= diff --git a/internal/conf/credential.go b/internal/conf/credential.go index 8c5106f76a5..c1683e2908d 100644 --- a/internal/conf/credential.go +++ b/internal/conf/credential.go @@ -1,22 +1,31 @@ package conf import ( + "crypto/sha256" + "encoding/base64" "encoding/json" "fmt" "regexp" "strings" + + "github.com/matthewhartstonge/argon2" ) -var reCredential = regexp.MustCompile(`^[a-zA-Z0-9!\$\(\)\*\+\.;<=>\[\]\^_\-\{\}@#&]+$`) +var ( + rePlainCredential = regexp.MustCompile(`^[a-zA-Z0-9!\$\(\)\*\+\.;<=>\[\]\^_\-\{\}@#&]+$`) + reBase64 = regexp.MustCompile(`^sha256:[a-zA-Z0-9\+/=]+$`) +) -const credentialSupportedChars = "A-Z,0-9,!,$,(,),*,+,.,;,<,=,>,[,],^,_,-,\",\",@,#,&" +const plainCredentialSupportedChars = "A-Z,0-9,!,$,(,),*,+,.,;,<,=,>,[,],^,_,-,\",\",@,#,&" // Credential is a parameter that is used as username or password. -type Credential string +type Credential struct { + value string +} // MarshalJSON implements json.Marshaler. func (d Credential) MarshalJSON() ([]byte, error) { - return json.Marshal(string(d)) + return json.Marshal(d.value) } // UnmarshalJSON implements json.Unmarshaler. @@ -26,17 +35,89 @@ func (d *Credential) UnmarshalJSON(b []byte) error { return err } - if in != "" && - !strings.HasPrefix(in, "sha256:") && - !reCredential.MatchString(in) { - return fmt.Errorf("credential contains unsupported characters. Supported are: %s", credentialSupportedChars) + *d = Credential{ + value: in, } - *d = Credential(in) - return nil + return d.validateConfig() } // UnmarshalEnv implements env.Unmarshaler. func (d *Credential) UnmarshalEnv(_ string, v string) error { return d.UnmarshalJSON([]byte(`"` + v + `"`)) } + +// GetValue returns the value of the credential. +func (d *Credential) GetValue() string { + return d.value +} + +// IsEmpty returns true if the credential is not configured. +func (d *Credential) IsEmpty() bool { + return d.value == "" +} + +// IsSha256 returns true if the credential is a sha256 hash. +func (d *Credential) IsSha256() bool { + return d.value != "" && strings.HasPrefix(d.value, "sha256:") +} + +// IsArgon2 returns true if the credential is an argon2 hash. +func (d *Credential) IsArgon2() bool { + return d.value != "" && strings.HasPrefix(d.value, "argon2:") +} + +// IsHashed returns true if the credential is a sha256 or argon2 hash. +func (d *Credential) IsHashed() bool { + return d.IsSha256() || d.IsArgon2() +} + +func sha256Base64(in string) string { + h := sha256.New() + h.Write([]byte(in)) + return base64.StdEncoding.EncodeToString(h.Sum(nil)) +} + +// Check returns true if the given value matches the credential. +func (d *Credential) Check(guess string) bool { + if d.IsSha256() { + return d.value[len("sha256:"):] == sha256Base64(guess) + } + if d.IsArgon2() { + // TODO: remove matthewhartstonge/argon2 when this PR gets merged into mainline Go: + // https://go-review.googlesource.com/c/crypto/+/502515 + ok, err := argon2.VerifyEncoded([]byte(guess), []byte(d.value[len("argon2:"):])) + return ok && err == nil + } + if d.IsEmpty() { + // when no credential is set, any value is valid + return true + } + + return d.value == guess +} + +func (d *Credential) validateConfig() error { + if d.IsEmpty() { + return nil + } + + switch { + case d.IsSha256(): + if !reBase64.MatchString(d.value) { + return fmt.Errorf("credential contains unsupported characters, sha256 hash must be base64 encoded") + } + case d.IsArgon2(): + // TODO: remove matthewhartstonge/argon2 when this PR gets merged into mainline Go: + // https://go-review.googlesource.com/c/crypto/+/502515 + _, err := argon2.Decode([]byte(d.value[len("argon2:"):])) + if err != nil { + return fmt.Errorf("invalid argon2 hash: %w", err) + } + default: + if !rePlainCredential.MatchString(d.value) { + return fmt.Errorf("credential contains unsupported characters. Supported are: %s", plainCredentialSupportedChars) + } + } + return nil +} diff --git a/internal/conf/credential_test.go b/internal/conf/credential_test.go new file mode 100644 index 00000000000..a8a43c53906 --- /dev/null +++ b/internal/conf/credential_test.go @@ -0,0 +1,167 @@ +package conf + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCredential(t *testing.T) { + t.Run("MarshalJSON", func(t *testing.T) { + cred := Credential{value: "password"} + expectedJSON := []byte(`"password"`) + actualJSON, err := cred.MarshalJSON() + assert.NoError(t, err) + assert.Equal(t, expectedJSON, actualJSON) + }) + + t.Run("UnmarshalJSON", func(t *testing.T) { + expectedCred := Credential{value: "password"} + jsonData := []byte(`"password"`) + var actualCred Credential + err := actualCred.UnmarshalJSON(jsonData) + assert.NoError(t, err) + assert.Equal(t, expectedCred, actualCred) + }) + + t.Run("UnmarshalEnv", func(t *testing.T) { + cred := Credential{} + err := cred.UnmarshalEnv("", "password") + assert.NoError(t, err) + assert.Equal(t, "password", cred.value) + }) + + t.Run("GetValue", func(t *testing.T) { + cred := Credential{value: "password"} + actualValue := cred.GetValue() + assert.Equal(t, "password", actualValue) + }) + + t.Run("IsEmpty", func(t *testing.T) { + cred := Credential{} + assert.True(t, cred.IsEmpty()) + assert.False(t, cred.IsHashed()) + + cred.value = "password" + assert.False(t, cred.IsEmpty()) + assert.False(t, cred.IsHashed()) + }) + + t.Run("IsSha256", func(t *testing.T) { + cred := Credential{} + assert.False(t, cred.IsSha256()) + assert.False(t, cred.IsHashed()) + + cred.value = "sha256:j1tsRqDEw9xvq/D7/9tMx6Jh/jMhk3UfjwIB2f1zgMo=" + assert.True(t, cred.IsSha256()) + assert.True(t, cred.IsHashed()) + + cred.value = "argon2:$argon2id$v=19$m=65536,t=1," + + "p=4$WXJGqwIB2qd+pRmxMOw9Dg$X4gvR0ZB2DtQoN8vOnJPR2SeFdUhH9TyVzfV98sfWeE" + assert.False(t, cred.IsSha256()) + assert.True(t, cred.IsHashed()) + }) + + t.Run("IsArgon2", func(t *testing.T) { + cred := Credential{} + assert.False(t, cred.IsArgon2()) + assert.False(t, cred.IsHashed()) + + cred.value = "sha256:j1tsRqDEw9xvq/D7/9tMx6Jh/jMhk3UfjwIB2f1zgMo=" + assert.False(t, cred.IsArgon2()) + assert.True(t, cred.IsHashed()) + + cred.value = "argon2:$argon2id$v=19$m=65536,t=1," + + "p=4$WXJGqwIB2qd+pRmxMOw9Dg$X4gvR0ZB2DtQoN8vOnJPR2SeFdUhH9TyVzfV98sfWeE" + assert.True(t, cred.IsArgon2()) + assert.True(t, cred.IsHashed()) + }) + + t.Run("Check-plain", func(t *testing.T) { + cred := Credential{value: "password"} + assert.True(t, cred.Check("password")) + assert.False(t, cred.Check("wrongpassword")) + }) + + t.Run("Check-sha256", func(t *testing.T) { + cred := Credential{value: "password"} + assert.True(t, cred.Check("password")) + assert.False(t, cred.Check("wrongpassword")) + }) + + t.Run("Check-sha256", func(t *testing.T) { + cred := Credential{value: "sha256:rl3rgi4NcZkpAEcacZnQ2VuOfJ0FxAqCRaKB/SwdZoQ="} + assert.True(t, cred.Check("testuser")) + assert.False(t, cred.Check("notestuser")) + }) + + t.Run("Check-argon2", func(t *testing.T) { + cred := Credential{value: "argon2:$argon2id$v=19$m=4096,t=3," + + "p=1$MTIzNDU2Nzg$Ux/LWeTgJQPyfMMJo1myR64+o8rALHoPmlE1i/TR+58"} + assert.True(t, cred.Check("testuser")) + assert.False(t, cred.Check("notestuser")) + }) + + t.Run("validateConfig", func(t *testing.T) { + tests := []struct { + name string + cred *Credential + wantErr bool + }{ + { + name: "Empty credential", + cred: &Credential{value: ""}, + wantErr: false, + }, + { + name: "Valid plain credential", + cred: &Credential{value: "validPlain123"}, + wantErr: false, + }, + { + name: "Invalid plain credential", + cred: &Credential{value: "invalid/Plain"}, + wantErr: true, + }, + { + name: "Valid sha256 credential", + cred: &Credential{value: "sha256:validBase64EncodedHash=="}, + wantErr: false, + }, + { + name: "Invalid sha256 credential", + cred: &Credential{value: "sha256:inval*idBase64"}, + wantErr: true, + }, + { + name: "Valid Argon2 credential", + cred: &Credential{value: "argon2:$argon2id$v=19$m=4096," + + "t=3,p=1$MTIzNDU2Nzg$zarsL19s86GzUWlAkvwt4gJBFuU/A9CVuCjNI4fksow"}, + wantErr: false, + }, + { + name: "Invalid Argon2 credential", + cred: &Credential{value: "argon2:invalid"}, + wantErr: true, + }, + { + name: "Invalid Argon2 credential", + // testing argon2d errors, because it's not supported + cred: &Credential{value: "$argon2d$v=19$m=4096,t=3," + + "p=1$MTIzNDU2Nzg$Xqyd4R7LzXvvAEHaVU12+Nzf5OkHoYcwIEIIYJUDpz0"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.cred.validateConfig() + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } + }) +} diff --git a/internal/conf/path.go b/internal/conf/path.go index c60d5253ca6..3633576f6aa 100644 --- a/internal/conf/path.go +++ b/internal/conf/path.go @@ -339,11 +339,11 @@ func (pconf *Path) check(conf *Conf, name string) error { // Authentication - if (pconf.PublishUser != "" && pconf.PublishPass == "") || - (pconf.PublishUser == "" && pconf.PublishPass != "") { + if (!pconf.PublishUser.IsEmpty() && pconf.PublishPass.IsEmpty()) || + (pconf.PublishUser.IsEmpty() && !pconf.PublishPass.IsEmpty()) { return fmt.Errorf("read username and password must be both filled") } - if pconf.PublishUser != "" && pconf.Source != "publisher" { + if !pconf.PublishUser.IsEmpty() && pconf.Source != "publisher" { return fmt.Errorf("'publishUser' is useless when source is not 'publisher', since " + "the stream is not provided by a publisher, but by a fixed source") } @@ -351,22 +351,22 @@ func (pconf *Path) check(conf *Conf, name string) error { return fmt.Errorf("'publishIPs' is useless when source is not 'publisher', since " + "the stream is not provided by a publisher, but by a fixed source") } - if (pconf.ReadUser != "" && pconf.ReadPass == "") || - (pconf.ReadUser == "" && pconf.ReadPass != "") { + if (!pconf.ReadUser.IsEmpty() && pconf.ReadPass.IsEmpty()) || + (pconf.ReadUser.IsEmpty() && !pconf.ReadPass.IsEmpty()) { return fmt.Errorf("read username and password must be both filled") } if contains(conf.AuthMethods, headers.AuthDigest) { - if strings.HasPrefix(string(pconf.PublishUser), "sha256:") || - strings.HasPrefix(string(pconf.PublishPass), "sha256:") || - strings.HasPrefix(string(pconf.ReadUser), "sha256:") || - strings.HasPrefix(string(pconf.ReadPass), "sha256:") { + if pconf.PublishUser.IsHashed() || + pconf.PublishPass.IsHashed() || + pconf.ReadUser.IsHashed() || + pconf.ReadPass.IsHashed() { return fmt.Errorf("hashed credentials can't be used when the digest auth method is available") } } if conf.ExternalAuthenticationURL != "" { - if pconf.PublishUser != "" || + if !pconf.PublishUser.IsEmpty() || len(pconf.PublishIPs) > 0 || - pconf.ReadUser != "" || + !pconf.ReadUser.IsEmpty() || len(pconf.ReadIPs) > 0 { return fmt.Errorf("credentials or IPs can't be used together with 'externalAuthenticationURL'") } diff --git a/internal/core/auth.go b/internal/core/auth.go index 04ef357a978..dbe2abf04af 100644 --- a/internal/core/auth.go +++ b/internal/core/auth.go @@ -2,13 +2,10 @@ package core import ( "bytes" - "crypto/sha256" - "encoding/base64" "encoding/json" "fmt" "io" "net/http" - "strings" "github.com/bluenviron/gortsplib/v4/pkg/auth" "github.com/bluenviron/gortsplib/v4/pkg/headers" @@ -18,20 +15,6 @@ import ( "github.com/bluenviron/mediamtx/internal/defs" ) -func sha256Base64(in string) string { - h := sha256.New() - h.Write([]byte(in)) - return base64.StdEncoding.EncodeToString(h.Sum(nil)) -} - -func checkCredential(right string, guess string) bool { - if strings.HasPrefix(right, "sha256:") { - return right[len("sha256:"):] == sha256Base64(guess) - } - - return right == guess -} - func doExternalAuthentication( ur string, accessRequest defs.PathAccessRequest, @@ -102,17 +85,17 @@ func doAuthentication( } var pathIPs conf.IPsOrCIDRs - var pathUser string - var pathPass string + var pathUser conf.Credential + var pathPass conf.Credential if accessRequest.Publish { pathIPs = pathConf.PublishIPs - pathUser = string(pathConf.PublishUser) - pathPass = string(pathConf.PublishPass) + pathUser = pathConf.PublishUser + pathPass = pathConf.PublishPass } else { pathIPs = pathConf.ReadIPs - pathUser = string(pathConf.ReadUser) - pathPass = string(pathConf.ReadPass) + pathUser = pathConf.ReadUser + pathPass = pathConf.ReadPass } if pathIPs != nil { @@ -121,12 +104,12 @@ func doAuthentication( } } - if pathUser != "" { + if !pathUser.IsEmpty() { if accessRequest.RTSPRequest != nil && rtspAuth.Method == headers.AuthDigest { err := auth.Validate( accessRequest.RTSPRequest, - pathUser, - pathPass, + pathUser.GetValue(), + pathPass.GetValue(), accessRequest.RTSPBaseURL, rtspAuthMethods, "IPCAM", @@ -134,8 +117,7 @@ func doAuthentication( if err != nil { return defs.AuthenticationError{Message: err.Error()} } - } else if !checkCredential(pathUser, accessRequest.User) || - !checkCredential(pathPass, accessRequest.Pass) { + } else if !pathUser.Check(accessRequest.User) || !pathPass.Check(accessRequest.Pass) { return defs.AuthenticationError{Message: "invalid credentials"} } } diff --git a/internal/core/rtsp_server_test.go b/internal/core/rtsp_server_test.go index 152fd40dc6b..672d5172677 100644 --- a/internal/core/rtsp_server_test.go +++ b/internal/core/rtsp_server_test.go @@ -89,7 +89,7 @@ func TestRTSPServer(t *testing.T) { } } -func TestRTSPServerAuthHashed(t *testing.T) { +func TestRTSPServerAuthHashedSHA256(t *testing.T) { p, ok := newInstance( "rtmp: no\n" + "hls: no\n" + @@ -112,6 +112,29 @@ func TestRTSPServerAuthHashed(t *testing.T) { defer source.Close() } +func TestRTSPServerAuthHashedArgon2(t *testing.T) { + p, ok := newInstance( + "rtmp: no\n" + + "hls: no\n" + + "webrtc: no\n" + + "paths:\n" + + " all_others:\n" + + " publishUser: argon2:$argon2id$v=19$m=4096,t=3,p=1$MTIzNDU2Nzg$Ux/LWeTgJQPyfMMJo1myR64+o8rALHoPmlE1i/TR+58\n" + + " publishPass: argon2:$argon2i$v=19$m=4096,t=3,p=1$MTIzNDU2Nzg$/mrZ42TiTv1mcPnpMUera5oi0SFYbbyueAbdx5sUvWo\n") + require.Equal(t, true, ok) + defer p.Close() + + medi := testMediaH264 + + source := gortsplib.Client{} + + err := source.StartRecording( + "rtsp://testuser:testpass@127.0.0.1:8554/test/stream", + &description.Session{Medias: []*description.Media{medi}}) + require.NoError(t, err) + defer source.Close() +} + func TestRTSPServerAuthFail(t *testing.T) { for _, ca := range []struct { name string diff --git a/mediamtx.yml b/mediamtx.yml index 62c3b05c20b..714af4f60da 100644 --- a/mediamtx.yml +++ b/mediamtx.yml @@ -318,19 +318,19 @@ pathDefaults: # Default path settings -> Authentication # Username required to publish. - # SHA256-hashed values can be inserted with the "sha256:" prefix. + # Hashed values can be inserted with the "argon2:" or "sha256:" prefix. publishUser: # Password required to publish. - # SHA256-hashed values can be inserted with the "sha256:" prefix. + # Hashed values can be inserted with the "argon2:" or "sha256:" prefix. publishPass: # IPs or networks (x.x.x.x/24) allowed to publish. publishIPs: [] # Username required to read. - # SHA256-hashed values can be inserted with the "sha256:" prefix. + # Hashed values can be inserted with the "argon2:" or "sha256:" prefix. readUser: # password required to read. - # SHA256-hashed values can be inserted with the "sha256:" prefix. + # Hashed values can be inserted with the "argon2:" or "sha256:" prefix. readPass: # IPs or networks (x.x.x.x/24) allowed to read. readIPs: []