Browse Source

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>
pull/2907/head
Sijmen 1 year ago committed by GitHub
parent
commit
397c58a882
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 24
      README.md
  2. 1
      go.mod
  3. 2
      go.sum
  4. 101
      internal/conf/credential.go
  5. 167
      internal/conf/credential_test.go
  6. 22
      internal/conf/path.go
  7. 38
      internal/core/auth.go
  8. 25
      internal/core/rtsp_server_test.go
  9. 8
      mediamtx.yml

24
README.md

@ -1035,14 +1035,30 @@ It's possible to setup authentication for readers too: @@ -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:

1
go.mod

@ -17,6 +17,7 @@ require ( @@ -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

2
go.sum

@ -102,6 +102,8 @@ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= @@ -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=

101
internal/conf/credential.go

@ -1,22 +1,31 @@ @@ -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 { @@ -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
}

167
internal/conf/credential_test.go

@ -0,0 +1,167 @@ @@ -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)
}
})
}
})
}

22
internal/conf/path.go

@ -339,11 +339,11 @@ func (pconf *Path) check(conf *Conf, name string) error { @@ -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 { @@ -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'")
}

38
internal/core/auth.go

@ -2,13 +2,10 @@ package core @@ -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 ( @@ -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( @@ -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( @@ -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( @@ -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"}
}
}

25
internal/core/rtsp_server_test.go

@ -89,7 +89,7 @@ func TestRTSPServer(t *testing.T) { @@ -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) { @@ -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

8
mediamtx.yml

@ -318,19 +318,19 @@ pathDefaults: @@ -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: []

Loading…
Cancel
Save