Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions crypto.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import (
"strings"

"github.com/google/uuid"
argon2_lib "golang.org/x/crypto/argon2"
bcrypt_lib "golang.org/x/crypto/bcrypt"
"golang.org/x/crypto/scrypt"
)
Expand Down Expand Up @@ -66,6 +67,44 @@ func bcrypt(input string) string {
return string(hash)
}

func argon2id(input string, params ...uint32) string {
// RFC 9106
// SECOND RECOMMENDED option and is suggested as a default setting for memory-constrained environments.
//
time := uint32(3)
memory := uint32(64 * 1024)
parallelism := uint8(1)
saltLen := uint32(16)
hashLen := uint32(32)

if len(params) > 0 && params[0] != 0 {
time = params[0]
}
if len(params) > 1 && params[1] != 0 {
memory = params[1]
}
if len(params) > 2 && params[2] != 0 {
parallelism = uint8(params[2])
}
if len(params) > 3 && params[3] != 0 {
saltLen = params[3]
}
if len(params) > 4 && params[4] != 0 {
hashLen = params[4]
}

salt := make([]byte, saltLen)
if _, err := rand.Read(salt); err != nil {
return fmt.Sprintf("failed to generate argon2 salt with len %d: %s", saltLen, err)
}

key := argon2_lib.IDKey([]byte(input), salt, time, memory, parallelism, hashLen)
saltKey := base64.RawStdEncoding.EncodeToString([]byte(salt))
hashKey := base64.RawStdEncoding.EncodeToString(key)
format := "$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s"
return fmt.Sprintf(format, argon2_lib.Version, memory, time, parallelism, saltKey, hashKey)
}

func hashSha(password string) string {
s := sha1.New()
s.Write([]byte(password))
Expand Down
187 changes: 187 additions & 0 deletions crypto_test.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
package sprig

import (
"bytes"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"strings"
"testing"

"github.com/stretchr/testify/assert"
argon2_lib "golang.org/x/crypto/argon2"
bcrypt_lib "golang.org/x/crypto/bcrypt"
)

Expand Down Expand Up @@ -64,6 +67,190 @@ func TestBcrypt(t *testing.T) {
}
}

type Argon2idParameters struct {
Password string
Time uint32
Memory uint32
Parallelism uint32
SaltLen uint32
HashLen uint32
}

func TestArgon2idParameters(t *testing.T) {
expectations := []Argon2idParameters{
{
Password: "abc",
Time: 1,
Memory: 131072,
Parallelism: 1,
SaltLen: 16,
HashLen: 32,
},
{
Password: "abcefgh",
Time: 1,
Memory: 262144,
Parallelism: 4,
SaltLen: 32,
HashLen: 64,
},
{
Password: "abcd",
Time: 0,
Memory: 0,
Parallelism: 0,
SaltLen: 0,
HashLen: 0,
},
}

for i, param := range expectations {
hash, err := runRaw(
`{{argon2id .Password .Time .Memory .Parallelism .SaltLen .HashLen}}`,
param,
)
if err != nil {
t.Errorf("Test %d: failed to render template: %s", i, err)
continue
}

if err := argon2idVerify(hash, param); err != nil {
t.Errorf("Test %d: Generated hash %s for password %s is not valid: %s", i, hash, param.Password, err)
}
}

hash, err := runRaw(`{{argon2id "testPassword"}}`, nil)
if err != nil {
t.Errorf("failed to render template: %s", err)
return
}

param := Argon2idParameters{
Password: "testPassword",
Time: 3,
Memory: 64 * 1024,
Parallelism: 1,
SaltLen: 16,
HashLen: 32,
}

if err := argon2idVerify(hash, param); err != nil {
t.Errorf("Generated hash %s for password %s is not valid: %s", hash, param.Password, err)
return
}

hash2, err := runRaw(`{{argon2id "myPassword" 1 2097152 4}}`, nil)
if err != nil {
t.Errorf("failed to render template: %s", err)
return
}

param2 := Argon2idParameters{
Password: "myPassword",
Time: 1,
Memory: 2097152,
Parallelism: 4,
SaltLen: 16,
HashLen: 32,
}

if err := argon2idVerify(hash2, param2); err != nil {
t.Errorf("Generated hash %s for password %s is not valid: %s", hash2, param2.Password, err)
}
}

func argon2idVerify(encodedHash string, param Argon2idParameters) error {
hashParts := strings.Split(encodedHash, "$")
if len(hashParts) != 6 {
return errors.New("invalid hash format")
}

if hashParts[1] != "argon2id" {
return fmt.Errorf("invalid variant: %s", hashParts[1])
}

var version int
if _, err := fmt.Sscanf(hashParts[2], "v=%d", &version); err != nil {
return fmt.Errorf("invalid version format: %s", err)
}
if version != argon2_lib.Version {
return fmt.Errorf("incompatible version: %d", version)
}

memPart := strings.Split(hashParts[3], ",")
if len(memPart) != 3 {
return errors.New("invalid memory/time/parallelism format")
}

var memory uint32
if _, err := fmt.Sscanf(memPart[0], "m=%d", &memory); err != nil {
return fmt.Errorf("invalid memory value: %s", err)
}

var timeVal uint32
if _, err := fmt.Sscanf(memPart[1], "t=%d", &timeVal); err != nil {
return fmt.Errorf("invalid time value: %s", err)
}

var parallelism uint8
if _, err := fmt.Sscanf(memPart[2], "p=%d", &parallelism); err != nil {
return fmt.Errorf("invalid parallelism value: %s", err)
}

salt, err := base64.RawStdEncoding.DecodeString(hashParts[4])
if err != nil {
return fmt.Errorf("failed to decode salt: %s", err)
}
expectedHash, err := base64.RawStdEncoding.DecodeString(hashParts[5])
if err != nil {
return fmt.Errorf("failed to decode hash: %s", err)
}

paramTime := param.Time
if paramTime == 0 {
paramTime = 3
}
paramMemory := param.Memory
if paramMemory == 0 {
paramMemory = 64 * 1024
}
paramParallelism := uint8(param.Parallelism)
if paramParallelism == 0 {
paramParallelism = 1
}
paramSaltLen := param.SaltLen
if paramSaltLen == 0 {
paramSaltLen = uint32(len(salt))
}
paramHashLen := param.HashLen
if paramHashLen == 0 {
paramHashLen = uint32(len(expectedHash))
}

if memory != paramMemory {
return fmt.Errorf("memory mismatch: hash has %d, expected %d", memory, paramMemory)
}
if timeVal != paramTime {
return fmt.Errorf("time mismatch: hash has %d, expected %d", timeVal, paramTime)
}
if parallelism != paramParallelism {
return fmt.Errorf("parallelism mismatch: hash has %d, expected %d", parallelism, paramParallelism)
}
if uint32(len(salt)) != paramSaltLen {
return fmt.Errorf("salt length mismatch: hash has %d, expected %d", len(salt), paramSaltLen)
}
if uint32(len(expectedHash)) != paramHashLen {
return fmt.Errorf("hash length mismatch: hash has %d, expected %d", len(expectedHash), paramHashLen)
}

computed := argon2_lib.IDKey([]byte(param.Password), salt, paramTime, paramMemory, paramParallelism, paramHashLen)
if !bytes.Equal(computed, expectedHash) {
return errors.New("password does not match hash")
}

return nil
}

type HtpasswdCred struct {
Username string
Password string
Expand Down
15 changes: 15 additions & 0 deletions docs/crypto.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,21 @@ The `bcrypt` function receives a string, and generates its `bcrypt` hash.
bcrypt "myPassword"
```

## argon2id

The `argon2id` function takes a `password`, a `time`, a `memory`, a `parallelism`, a `saltLen`, and a `hashLen` and generates a `argon2id' hash.
Default values follows second recommended values by [RFC 9106](https://datatracker.ietf.org/doc/rfc9106/) (t=3 and 64 MiB memory) as a default setting for memory-constrained environments, parallelism is 1 saltLen is 16 and hashLen is 32.
Argon2 is the winner of [Password Hashing Competition](https://www.password-hashing.net/) 2015 and is current recommended hashing algorithm for passwords.

```
argon2id "myPassword" [time [memory [parallelism [saltLen [hashLen]]]]]
```

### RFC 9106 First recommended format (t=1 and 2 GiB memory) using 4 cores
```
argon2id "myPassword" 1 2097152 4
```

## htpasswd

The `htpasswd` function takes a `username`, a `password`, and a `hashAlgorithm` and generates a `bcrypt` (recommended) or a base64 encoded and prefixed `sha` hash of the password. `hashAlgorithm` is optional and defaults to `bcrypt`. The result can be used for basic authentication on an [Apache HTTP Server](https://httpd.apache.org/docs/2.4/misc/password_encryptions.html#basic).
Expand Down
1 change: 1 addition & 0 deletions functions.go
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,7 @@ var genericMap = map[string]interface{}{

// Crypto:
"bcrypt": bcrypt,
"argon2id": argon2id,
"htpasswd": htpasswd,
"genPrivateKey": generatePrivateKey,
"derivePassword": derivePassword,
Expand Down
5 changes: 3 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/Masterminds/sprig/v3

go 1.23.0
go 1.24.0

toolchain go1.24.4

Expand All @@ -14,14 +14,15 @@ require (
github.com/shopspring/decimal v1.4.0
github.com/spf13/cast v1.9.2
github.com/stretchr/testify v1.10.0
golang.org/x/crypto v0.40.0
golang.org/x/crypto v0.45.0
)

require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
golang.org/x/sys v0.38.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
Expand Down