diff options
| -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{} |