Skip to content

Commit

Permalink
Add Argon2 credential hash support (#2888)
Browse files Browse the repository at this point in the history
* Add argon2 credential hash support

* update README, tests and documentation

---------

Co-authored-by: aler9 <46489434+aler9@users.noreply.github.com>
  • Loading branch information
SijmenHuizenga and aler9 committed Jan 13, 2024
1 parent 20bb9b9 commit 397c58a
Show file tree
Hide file tree
Showing 9 changed files with 330 additions and 58 deletions.
24 changes: 20 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
101 changes: 91 additions & 10 deletions internal/conf/credential.go
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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 changes: 167 additions & 0 deletions internal/conf/credential_test.go
Original file line number Diff line number Diff line change
@@ -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 changes: 11 additions & 11 deletions internal/conf/path.go
Original file line number Diff line number Diff line change
Expand Up @@ -339,34 +339,34 @@ 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")
}
if len(pconf.PublishIPs) > 0 && pconf.Source != "publisher" {
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'")
}
Expand Down
Loading

0 comments on commit 397c58a

Please sign in to comment.