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>
This commit is contained in:
Sijmen 2024-01-13 12:49:08 +01:00 committed by GitHub
parent 20bb9b90cd
commit 397c58a882
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 330 additions and 58 deletions

View File

@ -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 "userpass" | openssl dgst -binary -sha256 | openssl base64
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 "mypass" | openssl dgst -binary -sha256 | openssl base64
```
Then stored with the `sha256:` prefix:

1
go.mod
View File

@ -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
View File

@ -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=

View File

@ -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
}

View File

@ -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)
}
})
}
})
}

View File

@ -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'")
}

View File

@ -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"}
}
}

View File

@ -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

View File

@ -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: []