diff options
| author | Eric Biggers <ebiggers@google.com> | 2019-11-27 12:04:13 -0800 |
|---|---|---|
| committer | Eric Biggers <ebiggers@google.com> | 2020-01-22 19:05:06 -0800 |
| commit | 8cd1b3ba2e7a12cd68e2dfd0cbb5ec09ff92783b (patch) | |
| tree | 7cbace927ccef0392706fff52d1a56cb906f52ee | |
| parent | 059482129c5fdafebc582887a4ae4ef80988b708 (diff) | |
Automatically generate recovery passphrase when useful
If a user re-installs their system (or otherwise loses the /.fscrypt
directory on the root filesystem) they also lose access to any login
passphrase-protected directories on other filesystems, unless additional
protectors were manually added. This can be unexpected, as it may be
expected that the old login passphrase would still work.
We can't really fix this by storing a login protector on every
filesystem because:
- If a user were to have N login protectors, it would take them N times
longer to log in, as every login protector would need to be unlocked.
- If a user were to change their login passphrase while any external
volumes were unmounted, login protectors would get out of sync.
- It's preferable that an external volume isn't unlockable by itself
using only a login passphrase, as login passphrases are often weak.
Instead, generate a recovery passphrase when creating a login
passphrase-protected directory on a non-root filesystem.
The recovery passphrase is added as a custom_passphrase protector, thus
giving the policy two protectors: one pam_passphrase and one
custom_passphrase. Then this passphrase is written to a file in the new
encrypted directory. Writing the passphrase to a file here is okay
since it's encrypted, but it's obviously useless by itself; it's up to
the user to store this passphrase somewhere else if they need it.
Use a recovery passphrase instead of a "recovery code" that encodes the
policy key directly because a passphrase is more user-friendly: it can
safely be made much shorter than a key, and it works just like any other
fscrypt protector. Also, it's not as critical to allow recovery when
the .fscrypt directory on the *same* filesystem is deleted.
Resolves https://github.com/google/fscrypt/issues/164
| -rw-r--r-- | actions/recovery.go | 100 | ||||
| -rw-r--r-- | actions/recovery_test.go | 90 | ||||
| -rw-r--r-- | cmd/fscrypt/commands.go | 26 | ||||
| -rw-r--r-- | crypto/key.go | 10 | ||||
| -rw-r--r-- | crypto/rand.go | 32 |
5 files changed, 258 insertions, 0 deletions
diff --git a/actions/recovery.go b/actions/recovery.go new file mode 100644 index 0000000..b086705 --- /dev/null +++ b/actions/recovery.go @@ -0,0 +1,100 @@ +/* + * recovery.go - support for generating recovery passphrases + * + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ + +package actions + +import ( + "fmt" + "os" + "strconv" + + "github.com/pkg/errors" + + "github.com/google/fscrypt/crypto" +) + +// AddRecoveryPassphrase randomly generates a recovery passphrase and adds it as +// a custom_passphrase protector for the given Policy. +func AddRecoveryPassphrase(policy *Policy, dirname string) (*crypto.Key, *Protector, error) { + // 20 random characters in a-z is 94 bits of entropy, which is way more + // than enough for a passphrase which still goes through the usual + // passphrase hashing which makes it extremely costly to brute force. + passphrase, err := crypto.NewRandomPassphrase(20) + if err != nil { + return nil, nil, err + } + defer func() { + if err != nil { + passphrase.Wipe() + } + }() + getPassphraseFn := func(info ProtectorInfo, retry bool) (*crypto.Key, error) { + // CreateProtector() wipes the passphrase, but in this case we + // still need it for later, so make a copy. + return passphrase.Clone() + } + var recoveryProtector *Protector + seq := 1 + for { + // Automatically generate a name for the recovery protector. + name := "Recovery passphrase for " + dirname + if seq != 1 { + name += " (" + strconv.Itoa(seq) + ")" + } + recoveryProtector, err = CreateProtector(policy.Context, name, getPassphraseFn) + if err == nil { + break + } + if errors.Cause(err) != ErrDuplicateName { + return nil, nil, err + } + seq++ + } + if err := policy.AddProtector(recoveryProtector); err != nil { + return nil, nil, err + } + return passphrase, recoveryProtector, nil +} + +// WriteRecoveryInstructions writes a recovery passphrase and instructions to a +// file. This file should initially be located in the encrypted directory +// protected by the passphrase itself. It's up to the user to store the +// passphrase in a different location if they actually need it. +func WriteRecoveryInstructions(recoveryPassphrase *crypto.Key, path string) error { + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE, 0600) + if err != nil { + return err + } + defer file.Close() + str := fmt.Sprintf( + `fscrypt automatically generated a recovery passphrase for this directory: + + %s + +It did this because you chose to protect this directory with your login +passphrase, but this directory is not on the root filesystem. + +Copy this passphrase to a safe place if you want to still be able to unlock this +directory if you re-install your system or connect this storage media to a +different system (which would result in your login protector being lost). +`, recoveryPassphrase.Data()) + if _, err = file.WriteString(str); err != nil { + return err + } + return file.Sync() +} diff --git a/actions/recovery_test.go b/actions/recovery_test.go new file mode 100644 index 0000000..4332972 --- /dev/null +++ b/actions/recovery_test.go @@ -0,0 +1,90 @@ +/* + * recovery_test.go - tests for recovery passphrases + * + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ + +package actions + +import ( + "io/ioutil" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/google/fscrypt/crypto" +) + +func TestRecoveryPassphrase(t *testing.T) { + tempDir, err := ioutil.TempDir("", "fscrypt") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + recoveryFile := filepath.Join(tempDir, "recovery.txt") + + firstProtector, policy, err := makeBoth() + if err != nil { + t.Fatal(err) + } + defer cleanupPolicy(policy) + defer cleanupProtector(firstProtector) + + // Add a recovery passphrase and verify that it worked correctly. + passphrase, recoveryProtector, err := AddRecoveryPassphrase(policy, "foo") + if err != nil { + t.Fatal(err) + } + defer cleanupProtector(recoveryProtector) + if passphrase.Len() != 20 { + t.Error("Recovery passphrase has wrong length") + } + if recoveryProtector.data.Name != "Recovery passphrase for foo" { + t.Error("Recovery passphrase protector has wrong name") + } + if len(policy.ProtectorDescriptors()) != 2 { + t.Error("There should be 2 protectors now") + } + getPassphraseFn := func(info ProtectorInfo, retry bool) (*crypto.Key, error) { + return passphrase.Clone() + } + recoveryProtector.Lock() + if err = recoveryProtector.Unlock(getPassphraseFn); err != nil { + t.Fatal(err) + } + + // Test writing the recovery instructions. + if err = WriteRecoveryInstructions(passphrase, recoveryFile); err != nil { + t.Fatal(err) + } + contentsBytes, err := ioutil.ReadFile(recoveryFile) + if err != nil { + t.Fatal(err) + } + contents := string(contentsBytes) + if !strings.Contains(contents, string(passphrase.Data())) { + t.Error("Recovery instructions don't actually contain the passphrase!") + } + + // Test for protector naming collision. + if passphrase, recoveryProtector, err = AddRecoveryPassphrase(policy, "foo"); err != nil { + t.Fatal(err) + } + defer cleanupProtector(recoveryProtector) + if recoveryProtector.data.Name != "Recovery passphrase for foo (2)" { + t.Error("Recovery passphrase protector has wrong name (after naming collision)") + } +} diff --git a/cmd/fscrypt/commands.go b/cmd/fscrypt/commands.go index 41009b0..65e0f45 100644 --- a/cmd/fscrypt/commands.go +++ b/cmd/fscrypt/commands.go @@ -24,11 +24,13 @@ import ( "fmt" "log" "os" + "path/filepath" "github.com/pkg/errors" "github.com/urfave/cli" "github.com/google/fscrypt/actions" + "github.com/google/fscrypt/crypto" "github.com/google/fscrypt/filesystem" "github.com/google/fscrypt/keyring" "github.com/google/fscrypt/metadata" @@ -188,6 +190,7 @@ func encryptPath(path string) (err error) { } var policy *actions.Policy + var recoveryPassphrase *crypto.Key if policyFlag.Value != "" { log.Printf("getting policy for %q", path) @@ -227,6 +230,19 @@ func encryptPath(path string) (err error) { if policy, err = actions.CreatePolicy(ctx, protector); err != nil { return } + // Automatically generate a recovery passphrase if the protector + // is on a different filesystem from the policy. In practice, + // this happens for login passphrase-protected directories that + // aren't on the root filesystem, since login protectors are + // always stored on the root filesystem. + if ctx.Mount != protector.Context.Mount { + fmt.Printf("Generating recovery passphrase because protector is on a different filesystem.\n") + if recoveryPassphrase, _, err = actions.AddRecoveryPassphrase( + policy, filepath.Base(path)); err != nil { + return + } + defer recoveryPassphrase.Wipe() + } } // Successfully created policy should be reverted on failure. defer func() { @@ -255,6 +271,16 @@ func encryptPath(path string) (err error) { // EACCES at this point indicates ownership issues. err = errors.Wrap(ErrBadOwners, path) } + if err != nil { + return + } + if recoveryPassphrase != nil { + recoveryFile := filepath.Join(path, "fscrypt_recovery_readme.txt") + if err = actions.WriteRecoveryInstructions(recoveryPassphrase, recoveryFile); err != nil { + return + } + fmt.Printf("See %q for important recovery instructions!\n", recoveryFile) + } return } diff --git a/crypto/key.go b/crypto/key.go index 2220652..77adc95 100644 --- a/crypto/key.go +++ b/crypto/key.go @@ -195,6 +195,16 @@ func (key *Key) UnsafeToCString() unsafe.Pointer { return data } +// Clone creates a key as a copy of another one. +func (key *Key) Clone() (*Key, error) { + newKey, err := NewBlankKey(key.Len()) + if err != nil { + return nil, err + } + copy(newKey.data, key.data) + return newKey, nil +} + // NewKeyFromCString creates of a copy of some C string's data in a key. Note // that the original C string is not modified at all, so steps must be taken to // ensure that this original copy is secured. diff --git a/crypto/rand.go b/crypto/rand.go index 736d969..4d8c044 100644 --- a/crypto/rand.go +++ b/crypto/rand.go @@ -49,6 +49,38 @@ func NewRandomKey(length int) (*Key, error) { return NewFixedLengthKeyFromReader(randReader{}, length) } +// NewRandomPassphrase creates a random passphrase of the specified length +// containing random alphabetic characters. +func NewRandomPassphrase(length int) (*Key, error) { + chars := []byte("abcdefghijklmnopqrstuvwxyz") + passphrase, err := NewBlankKey(length) + if err != nil { + return nil, err + } + for i := 0; i < length; { + // Get some random bytes. + raw, err := NewRandomKey((length - i) * 2) + if err != nil { + return nil, err + } + // Translate the random bytes into random characters. + for _, b := range raw.data { + if int(b) >= 256-(256%len(chars)) { + // Avoid bias towards the first characters in the list. + continue + } + c := chars[int(b)%len(chars)] + passphrase.data[i] = c + i++ + if i == length { + break + } + } + raw.Wipe() + } + return passphrase, nil +} + // randReader just calls into Getrandom, so no internal data is needed. type randReader struct{} |