diff --git a/argon2_test.go b/argon2_test.go index 6c7cb813..603ed225 100644 --- a/argon2_test.go +++ b/argon2_test.go @@ -23,6 +23,7 @@ import ( "math" "os" "runtime" + "time" snapd_testutil "github.com/snapcore/snapd/testutil" @@ -101,6 +102,21 @@ func (s *argon2Suite) TestKDFParamsExplicitMode(c *C) { }) } +func (s *argon2Suite) TestKDFParamsTargetDuration(c *C) { + var opts Argon2Options + opts.TargetDuration = 1 * time.Second + params, err := opts.KdfParams(32) + c.Assert(err, IsNil) + c.Check(s.kdf.BenchmarkMode, Equals, Argon2id) + + c.Check(params, DeepEquals, &KdfParams{ + Type: "argon2id", + Time: 4, + Memory: 512031, + CPUs: s.cpusAuto, + }) +} + func (s *argon2Suite) TestKDFParamsMemoryLimit(c *C) { var opts Argon2Options opts.MemoryKiB = 32 * 1024 diff --git a/crypt_test.go b/crypt_test.go index 0ce1a756..fbc16c72 100644 --- a/crypt_test.go +++ b/crypt_test.go @@ -409,9 +409,11 @@ func (s *cryptSuite) SetUpTest(c *C) { activated: make(map[string]string)} s.AddCleanup(s.luks2.enableMocks()) +} - origKdf := SetArgon2KDF(&testutil.MockArgon2KDF{}) - s.AddCleanup(func() { SetArgon2KDF(origKdf) }) +func (s *cryptSuite) TearDownTest(c *C) { + s.keyDataTestBase.TearDownTest(c) + s.KeyringTestBase.TearDownTest(c) } func (s *cryptSuite) addMockToken(path string, token luks2.Token) int { diff --git a/export_test.go b/export_test.go index a284fddf..22383c5d 100644 --- a/export_test.go +++ b/export_test.go @@ -20,12 +20,18 @@ package secboot import ( + "crypto" "io" + "time" "github.com/snapcore/secboot/internal/luks2" "github.com/snapcore/secboot/internal/luksview" ) +const ( + NilHash = nilHash +) + var ( UnmarshalV1KeyPayload = unmarshalV1KeyPayload UnmarshalProtectedKeys = unmarshalProtectedKeys @@ -36,10 +42,18 @@ type ( ProtectedKeys = protectedKeys ) +func KDFOptionsKdfParams(o KDFOptions, keyLen uint32) (*KdfParams, error) { + return o.kdfParams(keyLen) +} + func (o *Argon2Options) KdfParams(keyLen uint32) (*KdfParams, error) { return o.kdfParams(keyLen) } +func (o *PBKDF2Options) KdfParams(keyLen uint32) (*KdfParams, error) { + return o.kdfParams(keyLen) +} + func MockLUKS2Activate(fn func(string, string, []byte, int) error) (restore func()) { origActivate := luks2Activate luks2Activate = fn @@ -112,6 +126,14 @@ func MockNewLUKSView(fn func(string, luks2.LockMode) (*luksview.View, error)) (r } } +func MockPBKDF2Benchmark(fn func(time.Duration, crypto.Hash) (uint, error)) (restore func()) { + orig := pbkdf2Benchmark + pbkdf2Benchmark = fn + return func() { + pbkdf2Benchmark = orig + } +} + func MockRuntimeNumCPU(n int) (restore func()) { orig := runtimeNumCPU runtimeNumCPU = func() int { diff --git a/go.mod b/go.mod index 49198257..60e4edcb 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/canonical/tcglog-parser v0.0.0-20240820013904-60cf7cbc7c5d github.com/intel-go/cpuid v0.0.0-20220614022739-219e067757cb github.com/snapcore/snapd v0.0.0-20220714152900-4a1f4c93fc85 - golang.org/x/crypto v0.9.0 + golang.org/x/crypto v0.21.0 golang.org/x/sys v0.19.0 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c @@ -24,6 +24,6 @@ require ( github.com/kr/text v0.1.0 // indirect github.com/snapcore/go-gettext v0.0.0-20191107141714-82bbea49e785 // indirect golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f // indirect - golang.org/x/net v0.10.0 // indirect + golang.org/x/net v0.21.0 // indirect gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637 // indirect ) diff --git a/go.sum b/go.sum index 468a6c4c..477557e5 100644 --- a/go.sum +++ b/go.sum @@ -45,14 +45,14 @@ github.com/snapcore/snapd v0.0.0-20220714152900-4a1f4c93fc85/go.mod h1:Ab4TsNgVa go.mozilla.org/pkcs7 v0.0.0-20200128120323-432b2356ecb1/go.mod h1:SNgMg+EgDFwmvSmLRTNKC5fegJjB7v23qTQ0XLGUNHk= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g= -golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= +golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f h1:99ci1mjWVBWwJiEKYY6jWa4d2nTQVIEhZIptnrVb1XY= golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20201002202402-0a1ea396d57c/go.mod h1:iQL9McJNjoIa5mjH6nYTCTZXUN6RP+XW3eib7Ya3XcI= -golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/internal/pbkdf2/export_test.go b/internal/pbkdf2/export_test.go new file mode 100644 index 00000000..7b208082 --- /dev/null +++ b/internal/pbkdf2/export_test.go @@ -0,0 +1,30 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2024 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package pbkdf2 + +import "time" + +func MockTimeExecution(fn func(*Params) time.Duration) (restore func()) { + orig := timeExecution + timeExecution = fn + return func() { + timeExecution = orig + } +} diff --git a/internal/pbkdf2/pbkdf2.go b/internal/pbkdf2/pbkdf2.go new file mode 100644 index 00000000..727a8eb5 --- /dev/null +++ b/internal/pbkdf2/pbkdf2.go @@ -0,0 +1,145 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2024 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package pbkdf2 + +import ( + "crypto" + "errors" + "math" + "time" + + "golang.org/x/crypto/pbkdf2" +) + +const ( + benchmarkPassword = "foo" +) + +var ( + benchmarkSalt = []byte("0123456789abcdefghijklmnopqrstuv") +) + +var timeExecution = func(params *Params) time.Duration { + start := time.Now() + if _, err := Key(benchmarkPassword, benchmarkSalt, params, uint(params.HashAlg.Size())); err != nil { + panic(err) + } + return time.Now().Sub(start) +} + +// Benchmark computes the number of iterations for desired duration +// with the specified digest algorithm. The specified algorithm must +// be available. This benchmark is largely based on that implemented +// by cryptsetup. +// +// When producing keys that are larger than the output size of the +// digest algorithm, PBKDF2 runs the specified number of iterations +// multiple times - eg, to produce a 64-byte key with SHA-256, PBKDF2 +// runs the specified number of iterations twice to produce the key in +// 2 rounds and this takes twice as long as it takes to produce a +// 32-byte key. This runs the benchmark for a single round by selecting a +// key length that is the same size as the output of the digest algorithm, +// which means that if SHA-256 is selected with a target duration of 1 second +// and the result is subsequently used to derive a 64-byte key, it will take 2 +// seconds. This is safer than the alternative which is that all rounds are +// benchmarked (eg, using SHA-256 to produce a 64-byte key) for 1 second, and +// then it's subsequently possible to run a single round in order to produce +// 32-bytes of output key material in 500ms. +func Benchmark(targetDuration time.Duration, hashAlg crypto.Hash) (uint, error) { + if !hashAlg.Available() { + return 0, errors.New("unavailable digest algorithm") + } + + // Start with 1000 iterations. + iterationsOut := uint(1000) + iterations := iterationsOut + + for i := 1; ; i++ { + // time the key derivation + duration := timeExecution(&Params{Iterations: iterations, HashAlg: hashAlg}) + if duration > 0 { + // calculate the required number of iterations to return, based on the tested + // iterations, measured duration and target duration. + newIterationsOut := (int64(iterations) * int64(targetDuration)) / int64(duration) + if newIterationsOut > math.MaxInt { + return 0, errors.New("iteration count result overflow") + } + iterationsOut = uint(newIterationsOut) + } + + // scale up the number of iterations to test next + var scale uint + switch { + case i > 10: + return 0, errors.New("insufficient progress") + case duration > 500*time.Millisecond: + return iterationsOut, nil + case duration <= 62*time.Millisecond: + scale = 16 + case duration <= 125*time.Millisecond: + scale = 8 + case duration <= 250*time.Millisecond: + scale = 4 + default: + scale = 2 + } + if math.MaxInt/scale < iterations { + // It's only possible to hit this on 32-bit platforms. On + // 64-bit platforms, we'll always hit the "insufficient progress" + // branch first. + return 0, errors.New("test iteration count overflow") + } + iterations *= scale + if int64(iterations) > math.MaxInt64/int64(targetDuration) { + return 0, errors.New("iteration count result will overflow") + } + } +} + +// Params are the key derivation parameters for PBKDF2. +type Params struct { + // Iterations are the number of iterations. This can't be + // greater than math.MaxInt. + Iterations uint + + // HashAlg is the digest algorithm to use. The algorithm + // must be available + HashAlg crypto.Hash +} + +// Key derives a key of the desired length from the supplied passphrase and salt, +// using the supplied parameters. +// +// This will return an error if the key length or number of iterations are greater +// than the maximum value of a signed integer, or the supplied digest algorithm is +// not available. +func Key(passphrase string, salt []byte, params *Params, keyLen uint) ([]byte, error) { + switch { + case params == nil: + return nil, errors.New("nil params") + case params.Iterations > math.MaxInt: + return nil, errors.New("too many iterations") + case !params.HashAlg.Available(): + return nil, errors.New("unavailable digest algorithm") + case keyLen > math.MaxInt: + return nil, errors.New("invalid key length") + } + return pbkdf2.Key([]byte(passphrase), salt, int(params.Iterations), int(keyLen), params.HashAlg.New), nil +} diff --git a/internal/pbkdf2/pbkdf2_test.go b/internal/pbkdf2/pbkdf2_test.go new file mode 100644 index 00000000..7c7a4165 --- /dev/null +++ b/internal/pbkdf2/pbkdf2_test.go @@ -0,0 +1,140 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2021 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package pbkdf2_test + +import ( + "crypto" + "crypto/rand" + _ "crypto/sha256" + _ "crypto/sha512" + "math" + "testing" + "time" + + "golang.org/x/crypto/pbkdf2" + . "gopkg.in/check.v1" + + . "github.com/snapcore/secboot/internal/pbkdf2" +) + +func Test(t *testing.T) { TestingT(t) } + +type pbkdf2Suite struct{} + +func (s *pbkdf2Suite) mockTimeExecution(c *C, expectedHash crypto.Hash) (restore func()) { + return MockTimeExecution(func(params *Params) time.Duration { + c.Check(params.HashAlg, Equals, expectedHash) + // hardcode 1us per iteration + return time.Duration(params.Iterations) * time.Microsecond + }) +} + +var _ = Suite(&pbkdf2Suite{}) + +func (s *pbkdf2Suite) TestBenchmark(c *C) { + restore := s.mockTimeExecution(c, crypto.SHA256) + defer restore() + + iterations, err := Benchmark(250*time.Millisecond, crypto.SHA256) + c.Check(err, IsNil) + c.Check(iterations, Equals, uint(250000)) +} + +func (s *pbkdf2Suite) TestBenchmarkDifferentHash(c *C) { + restore := s.mockTimeExecution(c, crypto.SHA512) + defer restore() + + iterations, err := Benchmark(250*time.Millisecond, crypto.SHA512) + c.Check(err, IsNil) + c.Check(iterations, Equals, uint(250000)) +} + +func (s *pbkdf2Suite) TestBenchmarkDifferentTarget(c *C) { + restore := s.mockTimeExecution(c, crypto.SHA256) + defer restore() + + iterations, err := Benchmark(2*time.Second, crypto.SHA256) + c.Check(err, IsNil) + c.Check(iterations, Equals, uint(2000000)) +} + +func (s *pbkdf2Suite) TestBenchmarkDifferentTarget2(c *C) { + restore := s.mockTimeExecution(c, crypto.SHA256) + defer restore() + + iterations, err := Benchmark(10*time.Millisecond, crypto.SHA256) + c.Check(err, IsNil) + c.Check(iterations, Equals, uint(10000)) +} + +func (s *pbkdf2Suite) TestBenchmarkInvalidHash(c *C) { + _, err := Benchmark(2*time.Second, 0) + c.Check(err, ErrorMatches, `unavailable digest algorithm`) +} + +func (s *pbkdf2Suite) TestBenchmarkOverflow(c *C) { + restore := MockTimeExecution(func(params *Params) time.Duration { + return 50 * time.Millisecond + }) + defer restore() + + _, err := Benchmark(450*time.Second, crypto.SHA256) + c.Check(err, ErrorMatches, `iteration count result will overflow`) +} + +func (s *pbkdf2Suite) TestKey(c *C) { + salt := make([]byte, 16) + rand.Read(salt) + + key, err := Key("foo", salt, &Params{Iterations: 1000, HashAlg: crypto.SHA256}, 32) + c.Check(err, IsNil) + expectedKey := pbkdf2.Key([]byte("foo"), salt, 1000, 32, crypto.SHA256.New) + c.Check(key, DeepEquals, expectedKey) +} + +func (s *pbkdf2Suite) TestKeyDifferentArgs(c *C) { + salt := make([]byte, 32) + rand.Read(salt) + + key, err := Key("bar", salt, &Params{Iterations: 200000, HashAlg: crypto.SHA512}, 64) + c.Check(err, IsNil) + expectedKey := pbkdf2.Key([]byte("bar"), salt, 200000, 64, crypto.SHA512.New) + c.Check(key, DeepEquals, expectedKey) +} + +func (s *pbkdf2Suite) TestKeyNilParams(c *C) { + _, err := Key("foo", nil, nil, 32) + c.Check(err, ErrorMatches, `nil params`) +} + +func (s *pbkdf2Suite) TestKeyInvalidIterations(c *C) { + _, err := Key("foo", nil, &Params{Iterations: math.MaxUint, HashAlg: crypto.SHA256}, 32) + c.Check(err, ErrorMatches, `too many iterations`) +} + +func (s *pbkdf2Suite) TestKeyInvalidHash(c *C) { + _, err := Key("foo", nil, &Params{Iterations: 1000}, 32) + c.Check(err, ErrorMatches, `unavailable digest algorithm`) +} + +func (s *pbkdf2Suite) TestKeyInvalidKeyLen(c *C) { + _, err := Key("foo", nil, &Params{Iterations: 1000, HashAlg: crypto.SHA256}, math.MaxUint) + c.Check(err, ErrorMatches, `invalid key length`) +} diff --git a/kdf.go b/kdf.go new file mode 100644 index 00000000..70fc9c6d --- /dev/null +++ b/kdf.go @@ -0,0 +1,26 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2024 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package secboot + +// KDFOptions is an interface for supplying options for different +// key derivation functions +type KDFOptions interface { + kdfParams(keyLen uint32) (*kdfParams, error) +} diff --git a/keydata.go b/keydata.go index 33c196ff..14dabfc3 100644 --- a/keydata.go +++ b/keydata.go @@ -31,6 +31,7 @@ import ( "hash" "io" + "github.com/snapcore/secboot/internal/pbkdf2" "golang.org/x/crypto/cryptobyte" cryptobyte_asn1 "golang.org/x/crypto/cryptobyte/asn1" "golang.org/x/crypto/hkdf" @@ -159,7 +160,7 @@ type KeyParams struct { // implementation. type KeyWithPassphraseParams struct { KeyParams - KDFOptions *Argon2Options // The passphrase KDF options + KDFOptions KDFOptions // The passphrase KDF options // AuthKeySize is the size of key to derive from the passphrase for // use by the platform implementation. @@ -279,10 +280,11 @@ func (a HashAlg) MarshalASN1(b *cryptobyte.Builder) { } type kdfParams struct { - Type string `json:"type"` - Time int `json:"time"` - Memory int `json:"memory"` - CPUs int `json:"cpus"` + Type string `json:"type"` + Time int `json:"time"` + Memory int `json:"memory"` + CPUs int `json:"cpus"` + Hash HashAlg `json:"hash"` } // kdfData corresponds to the arguments to a KDF and matches the @@ -387,6 +389,9 @@ func (d *KeyData) derivePassphraseKeys(passphrase string) (key, iv, auth []byte, if params.AuthKeySize < 0 { return nil, nil, nil, fmt.Errorf("invalid auth key size (%d bytes)", params.AuthKeySize) } + if params.KDF.Time < 0 { + return nil, nil, nil, fmt.Errorf("invalid KDF time (%d)", params.KDF.Time) + } kdfAlg := d.data.KDFAlg if !hashAlgAvailable(&kdfAlg) { @@ -413,6 +418,13 @@ func (d *KeyData) derivePassphraseKeys(passphrase string) (key, iv, auth []byte, switch params.KDF.Type { case string(Argon2i), string(Argon2id): + if params.KDF.Memory < 0 { + return nil, nil, nil, fmt.Errorf("invalid argon2 memory (%d)", params.KDF.Memory) + } + if params.KDF.CPUs < 0 { + return nil, nil, nil, fmt.Errorf("invalid argon2 threads (%d)", params.KDF.CPUs) + } + mode := Argon2Mode(params.KDF.Type) costParams := &Argon2CostParams{ Time: uint32(params.KDF.Time), @@ -425,6 +437,15 @@ func (d *KeyData) derivePassphraseKeys(passphrase string) (key, iv, auth []byte, if len(derived) != params.DerivedKeySize { return nil, nil, nil, errors.New("KDF returned unexpected key length") } + case pbkdf2Type: + pbkdfParams := &pbkdf2.Params{ + Iterations: uint(params.KDF.Time), + HashAlg: crypto.Hash(params.KDF.Hash), + } + derived, err = pbkdf2.Key(passphrase, salt, pbkdfParams, uint(params.DerivedKeySize)) + if err != nil { + return nil, nil, nil, xerrors.Errorf("cannot derive key from passphrase: %w", err) + } default: return nil, nil, nil, fmt.Errorf("unexpected intermediate KDF type \"%s\"", params.KDF.Type) } diff --git a/keydata_luks_test.go b/keydata_luks_test.go index 54ce2411..b10c71db 100644 --- a/keydata_luks_test.go +++ b/keydata_luks_test.go @@ -52,6 +52,11 @@ func (s *keyDataLuksSuite) SetUpTest(c *C) { s.AddCleanup(s.luks2.enableMocks()) } +func (s *keyDataLuksSuite) TearDownTest(c *C) { + s.keyDataTestBase.TearDownTest(c) + s.BaseTest.TearDownTest(c) +} + var _ = Suite(&keyDataLuksSuite{}) func (s *keyDataLuksSuite) checkKeyDataJSONFromLUKSToken(c *C, path string, id int, keyslot int, name string, priority int, creationParams *KeyParams, nmodels int) { diff --git a/keydata_test.go b/keydata_test.go index 5ac68ab6..05bb39b6 100644 --- a/keydata_test.go +++ b/keydata_test.go @@ -39,6 +39,7 @@ import ( "time" . "github.com/snapcore/secboot" + "github.com/snapcore/secboot/internal/pbkdf2" "github.com/snapcore/secboot/internal/testutil" snapd_testutil "github.com/snapcore/snapd/testutil" @@ -231,6 +232,8 @@ func toHash(c *C, v interface{}) crypto.Hash { str, ok := v.(string) c.Assert(ok, testutil.IsTrue) switch str { + case "null": + return crypto.Hash(0) case "sha1": return crypto.SHA1 case "sha224": @@ -248,8 +251,11 @@ func toHash(c *C, v interface{}) crypto.Hash { } type keyDataTestBase struct { - handler *mockPlatformKeyDataHandler - mockPlatformName string + handler *mockPlatformKeyDataHandler + mockPlatformName string + origArgon2KDF Argon2KDF + restorePBKDF2Benchmark func() + expectedPBKDF2Hash crypto.Hash } func (s *keyDataTestBase) SetUpSuite(c *C) { @@ -261,6 +267,19 @@ func (s *keyDataTestBase) SetUpSuite(c *C) { func (s *keyDataTestBase) SetUpTest(c *C) { s.handler.state = mockPlatformDeviceStateOK s.handler.passphraseSupport = false + s.origArgon2KDF = SetArgon2KDF(&testutil.MockArgon2KDF{}) + s.restorePBKDF2Benchmark = MockPBKDF2Benchmark(func(duration time.Duration, hashAlg crypto.Hash) (uint, error) { + c.Check(hashAlg, Equals, s.expectedPBKDF2Hash) + return uint(duration / time.Microsecond), nil + }) +} + +func (s *keyDataTestBase) TearDownTest(c *C) { + if s.restorePBKDF2Benchmark != nil { + s.restorePBKDF2Benchmark() + s.restorePBKDF2Benchmark = nil + } + SetArgon2KDF(s.origArgon2KDF) } func (s *keyDataTestBase) TearDownSuite(c *C) { @@ -313,7 +332,7 @@ func (s *keyDataTestBase) mockProtectKeys(c *C, primaryKey PrimaryKey, kdfAlg cr return out, unlockKey } -func (s *keyDataTestBase) mockProtectKeysWithPassphrase(c *C, primaryKey PrimaryKey, kdfOptions *Argon2Options, authKeySize int, KDFAlg crypto.Hash, modelAuthHash crypto.Hash) (out *KeyWithPassphraseParams, unlockKey DiskUnlockKey) { +func (s *keyDataTestBase) mockProtectKeysWithPassphrase(c *C, primaryKey PrimaryKey, kdfOptions KDFOptions, authKeySize int, KDFAlg crypto.Hash, modelAuthHash crypto.Hash) (out *KeyWithPassphraseParams, unlockKey DiskUnlockKey) { kp, unlockKey := s.mockProtectKeys(c, primaryKey, KDFAlg, modelAuthHash) expectedHandle, ok := kp.Handle.(*mockPlatformKeyDataHandle) @@ -328,6 +347,14 @@ func (s *keyDataTestBase) mockProtectKeysWithPassphrase(c *C, primaryKey Primary kdfOptions = &defaultOptions } + switch opt := kdfOptions.(type) { + case *PBKDF2Options: + s.expectedPBKDF2Hash = opt.HashAlg + if opt.HashAlg == crypto.Hash(0) { + s.expectedPBKDF2Hash = crypto.SHA256 + } + } + kpp := &KeyWithPassphraseParams{ KeyParams: *kp, KDFOptions: kdfOptions, @@ -428,13 +455,13 @@ func (s *keyDataTestBase) checkKeyDataJSONFromReaderAuthModeNone(c *C, r io.Read s.checkKeyDataJSONDecodedAuthModeNone(c, j, creationParams, nmodels) } -func (s *keyDataTestBase) checkKeyDataJSONDecodedAuthModePassphrase(c *C, j map[string]interface{}, creationParams *KeyWithPassphraseParams, nmodels int, passphrase string, kdfOpts *Argon2Options) { +func (s *keyDataTestBase) checkKeyDataJSONDecodedAuthModePassphrase(c *C, j map[string]interface{}, creationParams *KeyWithPassphraseParams, nmodels int, passphrase string, kdfOpts KDFOptions) { if kdfOpts == nil { var def Argon2Options kdfOpts = &def } - kdfParams, err := kdfOpts.KdfParams(0) + kdfParams, err := KDFOptionsKdfParams(kdfOpts, 0) c.Assert(err, IsNil) s.checkKeyDataJSONCommon(c, j, &creationParams.KeyParams, nmodels) @@ -461,15 +488,15 @@ func (s *keyDataTestBase) checkKeyDataJSONDecodedAuthModePassphrase(c *C, j map[ k, ok := p["kdf"].(map[string]interface{}) c.Check(ok, testutil.IsTrue) - str, ok := k["type"].(string) - c.Check(ok, testutil.IsTrue) - c.Check(str, Equals, "argon2id") - - str, ok = k["salt"].(string) + str, ok := k["salt"].(string) c.Check(ok, testutil.IsTrue) salt, err := base64.StdEncoding.DecodeString(str) c.Check(err, IsNil) + str, ok = k["type"].(string) + c.Check(ok, testutil.IsTrue) + c.Check(str, Equals, string(kdfParams.Type)) + time, ok := k["time"].(float64) c.Check(ok, testutil.IsTrue) c.Check(time, Equals, float64(kdfParams.Time)) @@ -482,6 +509,10 @@ func (s *keyDataTestBase) checkKeyDataJSONDecodedAuthModePassphrase(c *C, j map[ c.Check(ok, testutil.IsTrue) c.Check(cpus, Equals, float64(kdfParams.CPUs)) + h := toHash(c, k["hash"]) + c.Check(ok, testutil.IsTrue) + c.Check(h, Equals, crypto.Hash(kdfParams.Hash)) + str, ok = j["encrypted_payload"].(string) c.Check(ok, testutil.IsTrue) encryptedPayload, err := base64.StdEncoding.DecodeString(str) @@ -509,13 +540,23 @@ func (s *keyDataTestBase) checkKeyDataJSONDecodedAuthModePassphrase(c *C, j map[ asnsalt, err := builder.Bytes() c.Assert(err, IsNil) - var kdf testutil.MockArgon2KDF - costParams := &Argon2CostParams{ - Time: uint32(kdfParams.Time), - MemoryKiB: uint32(kdfParams.Memory), - Threads: uint8(kdfParams.CPUs), + var derived []byte + switch o := kdfOpts.(type) { + case *Argon2Options: + _ = o + var kdf testutil.MockArgon2KDF + costParams := &Argon2CostParams{ + Time: uint32(kdfParams.Time), + MemoryKiB: uint32(kdfParams.Memory), + Threads: uint8(kdfParams.CPUs), + } + derived, _ = kdf.Derive(passphrase, asnsalt, Argon2Mode(kdfParams.Type), costParams, uint32(derivedKeySize)) + case *PBKDF2Options: + _ = o + var err error + derived, err = pbkdf2.Key(passphrase, asnsalt, &pbkdf2.Params{Iterations: uint(kdfParams.Time), HashAlg: crypto.Hash(kdfParams.Hash)}, uint(derivedKeySize)) + c.Assert(err, IsNil) } - derived, _ := kdf.Derive(passphrase, asnsalt, Argon2Mode(kdfParams.Type), costParams, uint32(derivedKeySize)) key := make([]byte, int(encryptionKeySize)) @@ -536,7 +577,7 @@ func (s *keyDataTestBase) checkKeyDataJSONDecodedAuthModePassphrase(c *C, j map[ c.Check(payload, DeepEquals, creationParams.EncryptedPayload) } -func (s *keyDataTestBase) checkKeyDataJSONFromReaderAuthModePassphrase(c *C, r io.Reader, creationParams *KeyWithPassphraseParams, nmodels int, passphrase string, kdfOpts *Argon2Options) { +func (s *keyDataTestBase) checkKeyDataJSONFromReaderAuthModePassphrase(c *C, r io.Reader, creationParams *KeyWithPassphraseParams, nmodels int, passphrase string, kdfOpts KDFOptions) { var j map[string]interface{} d := json.NewDecoder(r) @@ -553,9 +594,11 @@ type keyDataSuite struct { func (s *keyDataSuite) SetUpTest(c *C) { s.BaseTest.SetUpTest(c) s.keyDataTestBase.SetUpTest(c) +} - origKdf := SetArgon2KDF(&testutil.MockArgon2KDF{}) - s.AddCleanup(func() { SetArgon2KDF(origKdf) }) +func (s *keyDataSuite) TearDownTest(c *C) { + s.BaseTest.TearDownTest(c) + s.keyDataTestBase.TearDownTest(c) } var _ = Suite(&keyDataSuite{}) @@ -567,7 +610,7 @@ func (s *keyDataSuite) checkKeyDataJSONAuthModeNone(c *C, keyData *KeyData, crea s.checkKeyDataJSONFromReaderAuthModeNone(c, w.Reader(), creationParams, nmodels) } -func (s *keyDataSuite) checkKeyDataJSONAuthModePassphrase(c *C, keyData *KeyData, creationParams *KeyWithPassphraseParams, nmodels int, passphrase string, kdfOpts *Argon2Options) { +func (s *keyDataSuite) checkKeyDataJSONAuthModePassphrase(c *C, keyData *KeyData, creationParams *KeyWithPassphraseParams, nmodels int, passphrase string, kdfOpts KDFOptions) { w := makeMockKeyDataWriter() c.Check(keyData.WriteAtomic(w), IsNil) @@ -803,6 +846,20 @@ func (s *keyDataSuite) TestRecoverKeysWithPassphrase2(c *C) { s.testRecoverKeysWithPassphrase(c, "1234") } +func (s *keyDataSuite) TestRecoverKeysWithPassphrasePBKDF2(c *C) { + s.handler.passphraseSupport = true + + primaryKey := s.newPrimaryKey(c, 32) + protected, unlockKey := s.mockProtectKeysWithPassphrase(c, primaryKey, &PBKDF2Options{}, 32, crypto.SHA256, crypto.SHA256) + keyData, err := NewKeyDataWithPassphrase(protected, "passphrase") + c.Assert(err, IsNil) + + recoveredUnlockKey, recoveredPrimaryKey, err := keyData.RecoverKeysWithPassphrase("passphrase") + c.Check(err, IsNil) + c.Check(recoveredUnlockKey, DeepEquals, unlockKey) + c.Check(recoveredPrimaryKey, DeepEquals, primaryKey) +} + type testRecoverKeysWithPassphraseErrorHandlingData struct { kdfType string errMsg string @@ -1023,7 +1080,7 @@ func (s *keyDataSuite) TestChangePassphraseWithoutInitial(c *C) { type testChangePassphraseData struct { passphrase1 string passphrase2 string - kdfOptions *Argon2Options + kdfOptions KDFOptions } func (s *keyDataSuite) testChangePassphrase(c *C, data *testChangePassphraseData) { @@ -1074,6 +1131,13 @@ func (s *keyDataSuite) TestChangePassphraseForceIterations(c *C) { kdfOptions: &Argon2Options{ForceIterations: 3, MemoryKiB: 32 * 1024}}) } +func (s *keyDataSuite) TestChangePassphrasePBKDF2(c *C) { + s.testChangePassphrase(c, &testChangePassphraseData{ + passphrase1: "12345678", + passphrase2: "87654321", + kdfOptions: &PBKDF2Options{}}) +} + func (s *keyDataSuite) TestChangePassphraseWrongPassphrase(c *C) { s.handler.passphraseSupport = true diff --git a/pbkdf2.go b/pbkdf2.go new file mode 100644 index 00000000..8b911d9e --- /dev/null +++ b/pbkdf2.go @@ -0,0 +1,107 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2024 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package secboot + +import ( + "crypto" + "errors" + "fmt" + "math" + "time" + + "github.com/snapcore/secboot/internal/pbkdf2" + "golang.org/x/xerrors" +) + +const ( + pbkdf2Type = "pbkdf2" +) + +var ( + pbkdf2Benchmark = pbkdf2.Benchmark +) + +type PBKDF2Options struct { + TargetDuration time.Duration + + ForceIterations uint32 + + HashAlg crypto.Hash +} + +func (o *PBKDF2Options) kdfParams(keyLen uint32) (*kdfParams, error) { + if keyLen > math.MaxInt32 { + return nil, errors.New("invalid key length") + } + + defaultHashAlg := crypto.SHA256 + switch { + case keyLen >= 48 && keyLen < 64: + defaultHashAlg = crypto.SHA384 + case keyLen >= 64: + defaultHashAlg = crypto.SHA512 + } + + switch { + case o.ForceIterations > 0: + // The non-benchmarked path. Ensure that ForceIterations + // fits into an int32 so that it always fits into an int + switch { + case o.ForceIterations > math.MaxInt32: + return nil, fmt.Errorf("invalid iterations count %d", o.ForceIterations) + } + + params := &kdfParams{ + Type: pbkdf2Type, + Time: int(o.ForceIterations), // no limit to the time cost. + Hash: HashAlg(defaultHashAlg), + } + if o.HashAlg != crypto.Hash(0) { + switch o.HashAlg { + case crypto.SHA1, crypto.SHA224, crypto.SHA256, crypto.SHA384, crypto.SHA512: + params.Hash = HashAlg(o.HashAlg) + default: + return nil, errors.New("invalid hash algorithm") + } + } + + return params, nil + default: + targetDuration := 2 * time.Second // the default target duration is 2s. + HashAlg := defaultHashAlg + + if o.TargetDuration != 0 { + targetDuration = o.TargetDuration + } + if o.HashAlg != crypto.Hash(0) { + HashAlg = o.HashAlg + } + + iterations, err := pbkdf2Benchmark(targetDuration, HashAlg) + if err != nil { + return nil, xerrors.Errorf("cannot benchmark KDF: %w", err) + } + + o = &PBKDF2Options{ + ForceIterations: uint32(iterations), + HashAlg: HashAlg} + return o.kdfParams(keyLen) + } +} diff --git a/pbkdf2_test.go b/pbkdf2_test.go new file mode 100644 index 00000000..0ae0c007 --- /dev/null +++ b/pbkdf2_test.go @@ -0,0 +1,137 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2021 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package secboot_test + +import ( + "crypto" + "time" + + . "gopkg.in/check.v1" + + . "github.com/snapcore/secboot" + "github.com/snapcore/secboot/internal/pbkdf2" +) + +type pbkdf2Suite struct{} + +var _ = Suite(&pbkdf2Suite{}) + +func (s *pbkdf2Suite) TestKDFParamsDefault(c *C) { + restore := MockPBKDF2Benchmark(func(targetDuration time.Duration, hashAlg crypto.Hash) (uint, error) { + c.Check(targetDuration, Equals, 2*time.Second) + c.Check(hashAlg, Equals, crypto.SHA256) + return pbkdf2.Benchmark(targetDuration, hashAlg) + }) + defer restore() + + var opts PBKDF2Options + params, err := opts.KdfParams(32) + c.Assert(err, IsNil) + c.Check(params.Type, Equals, "pbkdf2") + c.Check(params.Hash, Equals, HashAlg(crypto.SHA256)) + c.Check(params.Memory, Equals, 0) + c.Check(params.CPUs, Equals, 0) +} + +func (s *pbkdf2Suite) TestKDFParamsDefault48(c *C) { + restore := MockPBKDF2Benchmark(func(targetDuration time.Duration, hashAlg crypto.Hash) (uint, error) { + c.Check(targetDuration, Equals, 2*time.Second) + c.Check(hashAlg, Equals, crypto.SHA384) + return pbkdf2.Benchmark(targetDuration, hashAlg) + }) + defer restore() + + var opts PBKDF2Options + params, err := opts.KdfParams(48) + c.Assert(err, IsNil) + c.Check(params.Type, Equals, "pbkdf2") + c.Check(params.Hash, Equals, HashAlg(crypto.SHA384)) + c.Check(params.Memory, Equals, 0) + c.Check(params.CPUs, Equals, 0) +} + +func (s *pbkdf2Suite) TestKDFParamsDefault64(c *C) { + restore := MockPBKDF2Benchmark(func(targetDuration time.Duration, hashAlg crypto.Hash) (uint, error) { + c.Check(targetDuration, Equals, 2*time.Second) + c.Check(hashAlg, Equals, crypto.SHA512) + return pbkdf2.Benchmark(targetDuration, hashAlg) + }) + defer restore() + + var opts PBKDF2Options + params, err := opts.KdfParams(64) + c.Assert(err, IsNil) + c.Check(params.Type, Equals, "pbkdf2") + c.Check(params.Hash, Equals, HashAlg(crypto.SHA512)) + c.Check(params.Memory, Equals, 0) + c.Check(params.CPUs, Equals, 0) +} + +func (s *pbkdf2Suite) TestKDFParamsTargetDuration(c *C) { + restore := MockPBKDF2Benchmark(func(targetDuration time.Duration, hashAlg crypto.Hash) (uint, error) { + c.Logf("benchmarking (%d)", targetDuration) + if targetDuration != 200*time.Millisecond { + panic("") + } + c.Check(targetDuration, Equals, 200*time.Millisecond) + c.Check(hashAlg, Equals, crypto.SHA256) + return pbkdf2.Benchmark(targetDuration, hashAlg) + }) + defer restore() + + var opts PBKDF2Options + opts.TargetDuration = 200 * time.Millisecond + params, err := opts.KdfParams(32) + c.Assert(err, IsNil) + c.Check(params.Type, Equals, "pbkdf2") + c.Check(params.Hash, Equals, HashAlg(crypto.SHA256)) + c.Check(params.Memory, Equals, 0) + c.Check(params.CPUs, Equals, 0) +} + +func (s *pbkdf2Suite) TestKDFParamsForceIterations(c *C) { + var opts PBKDF2Options + opts.ForceIterations = 2000 + params, err := opts.KdfParams(32) + c.Assert(err, IsNil) + c.Check(params, DeepEquals, &KdfParams{ + Type: "pbkdf2", + Time: 2000, + Hash: HashAlg(crypto.SHA256), + }) +} + +func (s *pbkdf2Suite) TestKDFParamsCustomHash(c *C) { + restore := MockPBKDF2Benchmark(func(targetDuration time.Duration, hashAlg crypto.Hash) (uint, error) { + c.Check(targetDuration, Equals, 2*time.Second) + c.Check(hashAlg, Equals, crypto.SHA512) + return pbkdf2.Benchmark(targetDuration, hashAlg) + }) + defer restore() + + var opts PBKDF2Options + opts.HashAlg = crypto.SHA512 + params, err := opts.KdfParams(32) + c.Assert(err, IsNil) + c.Check(params.Type, Equals, "pbkdf2") + c.Check(params.Hash, Equals, HashAlg(crypto.SHA512)) + c.Check(params.Memory, Equals, 0) + c.Check(params.CPUs, Equals, 0) +}