Repository: kopia/kopia
Pull request #1197 (2021-07-14 05:38:09)

Repository password change support
This will only work for new repositories, since there's now a repository-wide flag that disables replication of `kopia.repository` contents in all packs.

codecov[bot]: # [Codecov](https://codecov.io/gh/kopia/kopia/pull/1197?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia) Report
> Merging [#1197](https://codecov.io/gh/kopia/kopia/pull/1197?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia) (89626b5) into [master](https://codecov.io/gh/kopia/kopia/commit/7cabed85ae0ada6bf34a048ce2306d06b5b2979b?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia) (7cabed8) will **decrease** coverage by `0.03%`.
> The diff coverage is `66.41%`.

[![Impacted file tree graph](https://codecov.io/gh/kopia/kopia/pull/1197/graphs/tree.svg?width=650&height=150&src=pr&token=CRK4RMRFSH&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia)](https://codecov.io/gh/kopia/kopia/pull/1197?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia)

```diff
@@            Coverage Diff             @@
##           master    #1197      +/-   ##
==========================================
- Coverage   67.88%   67.85%   -0.04%     
==========================================
  Files         358      360       +2     
  Lines       21025    21123      +98     
==========================================
+ Hits        14273    14332      +59     
- Misses       5222     5252      +30     
- Partials     1530     1539       +9     
```


| [Impacted Files](https://codecov.io/gh/kopia/kopia/pull/1197?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia) | Coverage Δ | |
|---|---|---|
| [cli/password.go](https://codecov.io/gh/kopia/kopia/pull/1197/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia#diff-Y2xpL3Bhc3N3b3JkLmdv) | `10.71% <0.00%> (-2.93%)` | :arrow_down: |
| [repo/content/content\_formatting\_options.go](https://codecov.io/gh/kopia/kopia/pull/1197/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia#diff-cmVwby9jb250ZW50L2NvbnRlbnRfZm9ybWF0dGluZ19vcHRpb25zLmdv) | `100.00% <ø> (ø)` | |
| [cli/command\_repository\_change\_password.go](https://codecov.io/gh/kopia/kopia/pull/1197/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia#diff-Y2xpL2NvbW1hbmRfcmVwb3NpdG9yeV9jaGFuZ2VfcGFzc3dvcmQuZ28=) | `28.57% <28.57%> (ø)` | |
| [repo/change\_password.go](https://codecov.io/gh/kopia/kopia/pull/1197/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia#diff-cmVwby9jaGFuZ2VfcGFzc3dvcmQuZ28=) | `35.29% <35.29%> (ø)` | |
| [repo/initialize.go](https://codecov.io/gh/kopia/kopia/pull/1197/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia#diff-cmVwby9pbml0aWFsaXplLmdv) | `78.78% <50.00%> (+0.32%)` | :arrow_up: |
| [repo/parameters.go](https://codecov.io/gh/kopia/kopia/pull/1197/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia#diff-cmVwby9wYXJhbWV0ZXJzLmdv) | `50.00% <50.00%> (ø)` | |
| [cli/command\_repository\_connect.go](https://codecov.io/gh/kopia/kopia/pull/1197/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia#diff-Y2xpL2NvbW1hbmRfcmVwb3NpdG9yeV9jb25uZWN0Lmdv) | `90.16% <83.33%> (-2.43%)` | :arrow_down: |
| [repo/open.go](https://codecov.io/gh/kopia/kopia/pull/1197/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia#diff-cmVwby9vcGVuLmdv) | `63.47% <86.36%> (+3.61%)` | :arrow_up: |
| [cli/command\_repository.go](https://codecov.io/gh/kopia/kopia/pull/1197/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia#diff-Y2xpL2NvbW1hbmRfcmVwb3NpdG9yeS5nbw==) | `100.00% <100.00%> (ø)` | |
| [cli/command\_repository\_create.go](https://codecov.io/gh/kopia/kopia/pull/1197/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia#diff-Y2xpL2NvbW1hbmRfcmVwb3NpdG9yeV9jcmVhdGUuZ28=) | `74.11% <100.00%> (+0.62%)` | :arrow_up: |
| ... and [14 more](https://codecov.io/gh/kopia/kopia/pull/1197/diff?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/kopia/kopia/pull/1197?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/kopia/kopia/pull/1197?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia). Last update [7cabed8...89626b5](https://codecov.io/gh/kopia/kopia/pull/1197?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia).


Patch:
diff --git a/cli/command_repository.go b/cli/command_repository.go
index 1eb9aaa6..276e9676 100644
--- a/cli/command_repository.go
+++ b/cli/command_repository.go
@@ -1,14 +1,15 @@
 package cli
 
 type commandRepository struct {
-	connect       commandRepositoryConnect
-	create        commandRepositoryCreate
-	disconnect    commandRepositoryDisconnect
-	repair        commandRepositoryRepair
-	setClient     commandRepositorySetClient
-	setParameters commandRepositorySetParameters
-	status        commandRepositoryStatus
-	syncTo        commandRepositorySyncTo
+	connect        commandRepositoryConnect
+	create         commandRepositoryCreate
+	disconnect     commandRepositoryDisconnect
+	repair         commandRepositoryRepair
+	setClient      commandRepositorySetClient
+	setParameters  commandRepositorySetParameters
+	changePassword commandRepositoryChangePassword
+	status         commandRepositoryStatus
+	syncTo         commandRepositorySyncTo
 }
 
 func (c *commandRepository) setup(svc advancedAppServices, parent commandParent) {
@@ -22,4 +23,5 @@ func (c *commandRepository) setup(svc advancedAppServices, parent commandParent)
 	c.setParameters.setup(svc, cmd)
 	c.status.setup(svc, cmd)
 	c.syncTo.setup(svc, cmd)
+	c.changePassword.setup(svc, cmd)
 }
diff --git a/cli/command_repository_change_password.go b/cli/command_repository_change_password.go
new file mode 100644
index 00000000..5bb587ae
--- /dev/null
+++ b/cli/command_repository_change_password.go
@@ -0,0 +1,50 @@
+package cli
+
+import (
+	"context"
+
+	"github.com/pkg/errors"
+
+	"github.com/kopia/kopia/repo"
+)
+
+type commandRepositoryChangePassword struct {
+	newPassword string
+
+	svc advancedAppServices
+}
+
+func (c *commandRepositoryChangePassword) setup(svc advancedAppServices, parent commandParent) {
+	cmd := parent.Command("change-password", "Change repository password")
+	cmd.Flag("new-password", "New password").Envar("KOPIA_NEW_PASSWORD").StringVar(&c.newPassword)
+
+	c.svc = svc
+	cmd.Action(svc.directRepositoryWriteAction(c.run))
+}
+
+func (c *commandRepositoryChangePassword) run(ctx context.Context, rep repo.DirectRepositoryWriter) error {
+	var newPass string
+
+	if c.newPassword == "" {
+		n, err := askForChangedRepositoryPassword(c.svc.stdout())
+		if err != nil {
+			return err
+		}
+
+		newPass = n
+	} else {
+		newPass = c.newPassword
+	}
+
+	if err := rep.ChangePassword(ctx, newPass); err != nil {
+		return errors.Wrap(err, "unable to change password")
+	}
+
+	log(ctx).Infof(`NOTE: Repository password has been changed.`)
+
+	if err := c.svc.passwordPersistenceStrategy().PersistPassword(ctx, c.svc.repositoryConfigFileName(), newPass); err != nil {
+		return errors.Wrap(err, "unable to persist password")
+	}
+
+	return nil
+}
diff --git a/cli/command_repository_create.go b/cli/command_repository_create.go
index 8984f225..ad371fe7 100644
--- a/cli/command_repository_create.go
+++ b/cli/command_repository_create.go
@@ -24,6 +24,7 @@ type commandRepositoryCreate struct {
 	createOnly                  bool
 	createIndexVersion          int
 	createIndexEpochs           bool
+	enablePasswordChange        bool
 
 	co  connectOptions
 	svc advancedAppServices
@@ -37,6 +38,7 @@ func (c *commandRepositoryCreate) setup(svc advancedAppServices, parent commandP
 	cmd.Flag("encryption", "Content encryption algorithm.").PlaceHolder("ALGO").Default(encryption.DefaultAlgorithm).EnumVar(&c.createBlockEncryptionFormat, encryption.SupportedAlgorithms(false)...)
 	cmd.Flag("object-splitter", "The splitter to use for new objects in the repository").Default(splitter.DefaultAlgorithm).EnumVar(&c.createSplitter, splitter.SupportedAlgorithms()...)
 	cmd.Flag("create-only", "Create repository, but don't connect to it.").Short('c').BoolVar(&c.createOnly)
+	cmd.Flag("enable-password-change", "Enable password change").Hidden().Default("true").BoolVar(&c.enablePasswordChange)
 	cmd.Flag("index-version", "Force particular index version").Hidden().Envar("KOPIA_CREATE_INDEX_VERSION").IntVar(&c.createIndexVersion)
 	cmd.Flag("enable-index-epochs", "Enable index epochs").Hidden().BoolVar(&c.createIndexEpochs)
 
@@ -82,6 +84,7 @@ func (c *commandRepositoryCreate) newRepositoryOptionsFromFlags() *repo.NewRepos
 				IndexVersion:    c.createIndexVersion,
 				EpochParameters: c.epochParametersFromFlags(),
 			},
+			EnablePasswordChange: c.enablePasswordChange,
 		},
 
 		ObjectFormat: object.Format{
diff --git a/cli/command_repository_repair.go b/cli/command_repository_repair.go
index 68f502eb..de572192 100644
--- a/cli/command_repository_repair.go
+++ b/cli/command_repository_repair.go
@@ -15,7 +15,7 @@
 type commandRepositoryRepair struct {
 	repairCommandRecoverFormatBlob         string
 	repairCommandRecoverFormatBlobPrefixes []string
-	repairDryDrun                          bool
+	repairDryRun                           bool
 }
 
 func (c *commandRepositoryRepair) setup(svc advancedAppServices, parent commandParent) {
@@ -23,7 +23,7 @@ func (c *commandRepositoryRepair) setup(svc advancedAppServices, parent commandP
 
 	cmd.Flag("recover-format", "Recover format blob from a copy").Default("auto").EnumVar(&c.repairCommandRecoverFormatBlob, "auto", "yes", "no")
 	cmd.Flag("recover-format-block-prefixes", "Prefixes of file names").StringsVar(&c.repairCommandRecoverFormatBlobPrefixes)
-	cmd.Flag("dry-run", "Do not modify repository").Short('n').BoolVar(&c.repairDryDrun)
+	cmd.Flag("dry-run", "Do not modify repository").Short('n').BoolVar(&c.repairDryRun)
 
 	for _, prov := range storageProviders {
 		f := prov.newFlags()
@@ -80,7 +80,7 @@ func (c *commandRepositoryRepair) recoverFormatBlob(ctx context.Context, st blob
 		err := st.ListBlobs(ctx, blob.ID(prefix), func(bi blob.Metadata) error {
 			log(ctx).Infof("looking for replica of format blob in %v...", bi.BlobID)
 			if b, err := repo.RecoverFormatBlob(ctx, st, bi.BlobID, bi.Length); err == nil {
-				if !c.repairDryDrun {
+				if !c.repairDryRun {
 					if puterr := st.PutBlob(ctx, repo.FormatBlobID, gather.FromSlice(b)); puterr != nil {
 						return errors.Wrap(puterr, "error writing format blob")
 					}
diff --git a/cli/command_repository_status.go b/cli/command_repository_status.go
index 2c0a02fb..6f911a07 100644
--- a/cli/command_repository_status.go
+++ b/cli/command_repository_status.go
@@ -67,6 +67,7 @@ func (c *commandRepositoryStatus) run(ctx context.Context, rep repo.Repository)
 	c.out.printStdout("Splitter:            %v\n", dr.ObjectFormat().Splitter)
 	c.out.printStdout("Format version:      %v\n", dr.ContentReader().ContentFormat().Version)
 	c.out.printStdout("Content compression: %v\n", dr.ContentReader().SupportsContentCompression())
+	c.out.printStdout("Password changes:    %v\n", dr.ContentReader().ContentFormat().EnablePasswordChange)
 
 	c.out.printStdout("Max pack length:     %v\n", units.BytesStringBase2(int64(dr.ContentReader().ContentFormat().MaxPackSize)))
 	c.out.printStdout("Index Format:        v%v\n", dr.ContentReader().ContentFormat().IndexVersion)
diff --git a/cli/password.go b/cli/password.go
index ec8a6731..46836e34 100644
--- a/cli/password.go
+++ b/cli/password.go
@@ -33,6 +33,26 @@ func askForNewRepositoryPassword(out io.Writer) (string, error) {
 	}
 }
 
+func askForChangedRepositoryPassword(out io.Writer) (string, error) {
+	for {
+		p1, err := askPass(out, "Enter new password: ")
+		if err != nil {
+			return "", errors.Wrap(err, "password entry")
+		}
+
+		p2, err := askPass(out, "Re-enter password for verification: ")
+		if err != nil {
+			return "", errors.Wrap(err, "password verification")
+		}
+
+		if p1 != p2 {
+			fmt.Println("Passwords don't match!")
+		} else {
+			return p1, nil
+		}
+	}
+}
+
 func askForExistingRepositoryPassword(out io.Writer) (string, error) {
 	p1, err := askPass(out, "Enter password to open repository: ")
 	if err != nil {
diff --git a/repo/change_password.go b/repo/change_password.go
new file mode 100644
index 00000000..bfc9b20d
--- /dev/null
+++ b/repo/change_password.go
@@ -0,0 +1,47 @@
+package repo
+
+import (
+	"context"
+	"os"
+	"path/filepath"
+
+	"github.com/pkg/errors"
+)
+
+// ChangePassword changes the repository password and rewrites `kopia.repository`.
+func (r *directRepository) ChangePassword(ctx context.Context, newPassword string) error {
+	f := r.formatBlob
+
+	repoConfig, err := f.decryptFormatBytes(r.formatEncryptionKey)
+	if err != nil {
+		return errors.Wrap(err, "unable to decrypt repository config")
+	}
+
+	if !repoConfig.EnablePasswordChange {
+		return errors.Errorf("password changes are not supported for repositories created using Kopia v0.8 or older")
+	}
+
+	newFormatEncryptionKey, err := f.deriveFormatEncryptionKeyFromPassword(newPassword)
+	if err != nil {
+		return errors.Wrap(err, "unable to derive master key")
+	}
+
+	r.formatEncryptionKey = newFormatEncryptionKey
+
+	if err := encryptFormatBytes(f, repoConfig, newFormatEncryptionKey, f.UniqueID); err != nil {
+		return errors.Wrap(err, "unable to encrypt format bytes")
+	}
+
+	if err := writeFormatBlob(ctx, r.blobs, f); err != nil {
+		return errors.Wrap(err, "unable to write format blob")
+	}
+
+	// remove cached kopia.repository blob.
+	if cd := r.cachingOptions.CacheDirectory; cd != "" {
+		if err := os.Remove(filepath.Join(r.cachingOptions.CacheDirectory, "kopia.repository")); err != nil {
+			log(ctx).Errorf("unable to remove kopia.repository: %v", err)
+		}
+	}
+
+	return nil
+}
diff --git a/repo/content/content_formatting_options.go b/repo/content/content_formatting_options.go
index 40a4414b..df488e49 100644
--- a/repo/content/content_formatting_options.go
+++ b/repo/content/content_formatting_options.go
@@ -20,6 +20,8 @@ type FormattingOptions struct {
 	HMACSecret []byte `json:"secret,omitempty"`     // HMAC secret used to generate encryption keys
 	MasterKey  []byte `json:"masterKey,omitempty"`  // master encryption key (SIV-mode encryption only)
 	MutableParameters
+
+	EnablePasswordChange bool `json:"enablePasswordChange"` // disables replication of kopia.repository blob in packs
 }
 
 // MutableParameters represents parameters of the content manager that can be mutated after the repository
diff --git a/repo/initialize.go b/repo/initialize.go
index 32d87689..24ac0966 100644
--- a/repo/initialize.go
+++ b/repo/initialize.go
@@ -58,9 +58,9 @@ func Initialize(ctx context.Context, st blob.Storage, opt *NewRepositoryOptions,
 
 	format := formatBlobFromOptions(opt)
 
-	masterKey, err := format.deriveMasterKeyFromPassword(password)
+	formatEncryptionKey, err := format.deriveFormatEncryptionKeyFromPassword(password)
 	if err != nil {
-		return errors.Wrap(err, "unable to derive master key")
+		return errors.Wrap(err, "unable to derive format encryption key")
 	}
 
 	f := repositoryObjectFormatFromOptions(opt)
@@ -68,7 +68,7 @@ func Initialize(ctx context.Context, st blob.Storage, opt *NewRepositoryOptions,
 		return errors.Wrap(err, "invalid parameters")
 	}
 
-	if err := encryptFormatBytes(format, f, masterKey, format.UniqueID); err != nil {
+	if err := encryptFormatBytes(format, f, formatEncryptionKey, format.UniqueID); err != nil {
 		return errors.Wrap(err, "unable to encrypt format bytes")
 	}
 
@@ -104,6 +104,7 @@ func repositoryObjectFormatFromOptions(opt *NewRepositoryOptions) *repositoryObj
 				IndexVersion:    applyDefaultInt(opt.BlockFormat.IndexVersion, content.DefaultIndexVersion),
 				EpochParameters: opt.BlockFormat.EpochParameters,
 			},
+			EnablePasswordChange: opt.BlockFormat.EnablePasswordChange,
 		},
 		Format: object.Format{
 			Splitter: applyDefaultString(opt.ObjectFormat.Splitter, splitter.DefaultAlgorithm),
diff --git a/repo/open.go b/repo/open.go
index 0b80ceb5..e9957f02 100644
--- a/repo/open.go
+++ b/repo/open.go
@@ -37,6 +37,11 @@
 // as valid.
 const defaultFormatBlobCacheDuration = 15 * time.Minute
 
+// localCacheIntegrityHMACSecretLength length of HMAC secret protecting local cache items.
+const localCacheIntegrityHMACSecretLength = 16
+
+var localCacheIntegrityPurpose = []byte("local-cache-integrity")
+
 const cacheDirMarkerContents = CacheDirMarkerHeader + `
 #
 # This file is a cache directory tag created by Kopia - Fast And Secure Open-Source Backup.
@@ -186,18 +191,22 @@ func openWithConfig(ctx context.Context, st blob.Storage, lc *LocalConfig, passw
 		return nil, errors.Errorf("unable to add checksum")
 	}
 
-	masterKey, err := f.deriveMasterKeyFromPassword(password)
+	formatEncryptionKey, err := f.deriveFormatEncryptionKeyFromPassword(password)
 	if err != nil {
 		return nil, err
 	}
 
-	repoConfig, err := f.decryptFormatBytes(masterKey)
+	repoConfig, err := f.decryptFormatBytes(formatEncryptionKey)
 	if err != nil {
 		return nil, ErrInvalidPassword
 	}
 
-	// nolint:gomnd
-	caching.HMACSecret = deriveKeyFromMasterKey(masterKey, f.UniqueID, []byte("local-cache-integrity"), 16)
+	if repoConfig.FormattingOptions.EnablePasswordChange {
+		caching.HMACSecret = deriveKeyFromMasterKey(repoConfig.HMACSecret, f.UniqueID, localCacheIntegrityPurpose, localCacheIntegrityHMACSecretLength)
+	} else {
+		// deriving from formatEncryptionKey was actually a bug, that only matters will change when we change the password
+		caching.HMACSecret = deriveKeyFromMasterKey(formatEncryptionKey, f.UniqueID, localCacheIntegrityPurpose, localCacheIntegrityHMACSecretLength)
+	}
 
 	fo := &repoConfig.FormattingOptions
 
@@ -212,6 +221,11 @@ func openWithConfig(ctx context.Context, st blob.Storage, lc *LocalConfig, passw
 		DisableInternalLog:    options.DisableInternalLog,
 	}
 
+	// do not embed repository format info in pack blobs when password change is enabled.
+	if fo.EnablePasswordChange {
+		cmOpts.RepositoryFormatBytes = nil
+	}
+
 	scm, err := content.NewSharedManager(ctx, st, fo, caching, cmOpts)
 	if err != nil {
 		return nil, errors.Wrap(err, "unable to create shared content manager")
@@ -239,14 +253,14 @@ func openWithConfig(ctx context.Context, st blob.Storage, lc *LocalConfig, passw
 		mmgr:  manifests,
 		sm:    scm,
 		directRepositoryParameters: directRepositoryParameters{
-			uniqueID:       f.UniqueID,
-			cachingOptions: *caching,
-			formatBlob:     f,
-			masterKey:      masterKey,
-			timeNow:        cmOpts.TimeNow,
-			cliOpts:        lc.ClientOptions.ApplyDefaults(ctx, "Repository in "+st.DisplayName()),
-			configFile:     configFile,
-			nextWriterID:   new(int32),
+			uniqueID:            f.UniqueID,
+			cachingOptions:      *caching,
+			formatBlob:          f,
+			formatEncryptionKey: formatEncryptionKey,
+			timeNow:             cmOpts.TimeNow,
+			cliOpts:             lc.ClientOptions.ApplyDefaults(ctx, "Repository in "+st.DisplayName()),
+			configFile:          configFile,
+			nextWriterID:        new(int32),
 		},
 		closed: make(chan struct{}),
 	}
@@ -294,10 +308,6 @@ func formatBytesCachingEnabled(cacheDirectory string, validDuration time.Duratio
 }
 
 func readFormatBlobBytesFromCache(ctx context.Context, cachedFile string, validDuration time.Duration) ([]byte, error) {
-	if err := os.MkdirAll(filepath.Dir(cachedFile), cache.DirMode); err != nil && !os.IsExist(err) {
-		log(ctx).Errorf("unable to create cache directory: %v", err)
-	}
-
 	cst, err := os.Stat(cachedFile)
 	if err != nil {
 		return nil, errors.Wrap(err, "unable to open cache file")
@@ -322,6 +332,12 @@ func readAndCacheFormatBlobBytes(ctx context.Context, st blob.Storage, cacheDire
 		validDuration = defaultFormatBlobCacheDuration
 	}
 
+	if cacheDirectory != "" {
+		if err := os.MkdirAll(cacheDirectory, cache.DirMode); err != nil && !os.IsExist(err) {
+			log(ctx).Errorf("unable to create cache directory: %v", err)
+		}
+	}
+
 	cacheEnabled := formatBytesCachingEnabled(cacheDirectory, validDuration)
 	if cacheEnabled {
 		b, err := readFormatBlobBytesFromCache(ctx, cachedFile, validDuration)
diff --git a/repo/parameters.go b/repo/parameters.go
index d6e4e9e1..60ebaaf0 100644
--- a/repo/parameters.go
+++ b/repo/parameters.go
@@ -14,7 +14,7 @@
 func (r *directRepository) SetParameters(ctx context.Context, m content.MutableParameters) error {
 	f := r.formatBlob
 
-	repoConfig, err := f.decryptFormatBytes(r.masterKey)
+	repoConfig, err := f.decryptFormatBytes(r.formatEncryptionKey)
 	if err != nil {
 		return errors.Wrap(err, "unable to decrypt repository config")
 	}
@@ -25,7 +25,7 @@ func (r *directRepository) SetParameters(ctx context.Context, m content.MutableP
 
 	repoConfig.FormattingOptions.MutableParameters = m
 
-	if err := encryptFormatBytes(f, repoConfig, r.masterKey, f.UniqueID); err != nil {
+	if err := encryptFormatBytes(f, repoConfig, r.formatEncryptionKey, f.UniqueID); err != nil {
 		return errors.Errorf("unable to encrypt format bytes")
 	}
 
diff --git a/repo/repository.go b/repo/repository.go
index a2816c99..d352ee87 100644
--- a/repo/repository.go
+++ b/repo/repository.go
@@ -74,17 +74,18 @@ type DirectRepositoryWriter interface {
 	BlobStorage() blob.Storage
 	ContentManager() *content.WriteManager
 	SetParameters(ctx context.Context, m content.MutableParameters) error
+	ChangePassword(ctx context.Context, newPassword string) error
 }
 
 type directRepositoryParameters struct {
-	uniqueID       []byte
-	configFile     string
-	cachingOptions content.CachingOptions
-	cliOpts        ClientOptions
-	timeNow        func() time.Time
-	formatBlob     *formatBlob
-	masterKey      []byte
-	nextWriterID   *int32
+	uniqueID            []byte
+	configFile          string
+	cachingOptions      content.CachingOptions
+	cliOpts             ClientOptions
+	timeNow             func() time.Time
+	formatBlob          *formatBlob
+	formatEncryptionKey []byte
+	nextWriterID        *int32
 }
 
 // directRepository is an implementation of repository that directly manipulates underlying storage.
@@ -102,7 +103,14 @@ type directRepository struct {
 
 // DeriveKey derives encryption key of the provided length from the master key.
 func (r *directRepository) DeriveKey(purpose []byte, keyLength int) []byte {
-	return deriveKeyFromMasterKey(r.masterKey, r.uniqueID, purpose, keyLength)
+	if r.cmgr.ContentFormat().EnablePasswordChange {
+		return deriveKeyFromMasterKey(r.cmgr.ContentFormat().MasterKey, r.uniqueID, purpose, keyLength)
+	}
+
+	// version of kopia <v0.9 had a bug where certain keys were derived directly from
+	// the password and not from the random master key. This made it impossible to change
+	// password.
+	return deriveKeyFromMasterKey(r.formatEncryptionKey, r.uniqueID, purpose, keyLength)
 }
 
 // ClientOptions returns client options.


Test patch:
diff --git a/cli/command_repository_change_password_test.go b/cli/command_repository_change_password_test.go
new file mode 100644
index 00000000..09e5772c
--- /dev/null
+++ b/cli/command_repository_change_password_test.go
@@ -0,0 +1,46 @@
+package cli_test
+
+import (
+	"testing"
+
+	"github.com/kopia/kopia/tests/testenv"
+)
+
+func TestRepositoryChangePassword(t *testing.T) {
+	r1 := testenv.NewInProcRunner(t)
+	r2 := testenv.NewInProcRunner(t)
+	env1 := testenv.NewCLITest(t, r1)
+	env2 := testenv.NewCLITest(t, r2)
+
+	env1.RunAndExpectSuccess(t, "repo", "create", "filesystem", "--path", env1.RepoDir, "--disable-repository-format-cache")
+	env1.RunAndExpectSuccess(t, "snapshot", "ls")
+
+	// connect to repo with --disable-repository-format-cache so that format blob is not cached
+	// this makes password changes immediate
+	env2.RunAndExpectSuccess(t, "repo", "connect", "filesystem", "--path", env1.RepoDir, "--disable-repository-format-cache")
+	env2.RunAndExpectSuccess(t, "snapshot", "ls")
+
+	env1.RunAndExpectSuccess(t, "repo", "change-password", "--new-password", "newPass")
+
+	// at this point env2 stops working
+	env2.RunAndExpectFailure(t, "snapshot", "ls")
+
+	r3 := testenv.NewInProcRunner(t)
+
+	// new connections will fail when using old (default) password
+	env3 := testenv.NewCLITest(t, r3)
+	env3.RunAndExpectFailure(t, "repo", "connect", "filesystem", "--path", env1.RepoDir, "--disable-repository-format-cache")
+
+	// new connections will succeed when using new password
+	r3.RepoPassword = "newPass"
+
+	env3.RunAndExpectSuccess(t, "repo", "connect", "filesystem", "--path", env1.RepoDir, "--disable-repository-format-cache")
+}
+
+func TestRepositoryChangePassword_LegacDisallowed(t *testing.T) {
+	env1 := testenv.NewCLITest(t, testenv.NewInProcRunner(t))
+	// pass --no-enable-password-change to create repository using old format that does
+	// not support password change.
+	env1.RunAndExpectSuccess(t, "repo", "create", "filesystem", "--path", env1.RepoDir, "--disable-repository-format-cache", "--no-enable-password-change")
+	env1.RunAndExpectFailure(t, "repo", "change-password", "--new-password", "newPass")
+}
diff --git a/internal/repotesting/repotesting.go b/internal/repotesting/repotesting.go
index 879d6086..cfc371c5 100644
--- a/internal/repotesting/repotesting.go
+++ b/internal/repotesting/repotesting.go
@@ -17,13 +17,15 @@
 	"github.com/kopia/kopia/repo/object"
 )
 
-const masterPassword = "foobarbazfoobarbaz"
+const defaultPassword = "foobarbazfoobarbaz"
 
 // Environment encapsulates details of a test environment.
 type Environment struct {
 	Repository       repo.Repository
 	RepositoryWriter repo.DirectRepositoryWriter
 
+	Password string
+
 	configDir  string
 	storageDir string
 	connected  bool
@@ -46,9 +48,10 @@ func (e *Environment) setup(t *testing.T, opts ...Options) *Environment {
 
 	opt := &repo.NewRepositoryOptions{
 		BlockFormat: content.FormattingOptions{
-			HMACSecret: []byte{},
-			Hash:       "HMAC-SHA256",
-			Encryption: encryption.DefaultAlgorithm,
+			HMACSecret:           []byte{},
+			Hash:                 "HMAC-SHA256",
+			Encryption:           encryption.DefaultAlgorithm,
+			EnablePasswordChange: true,
 		},
 		ObjectFormat: object.Format{
 			Splitter: "FIXED-1M",
@@ -72,17 +75,21 @@ func (e *Environment) setup(t *testing.T, opts ...Options) *Environment {
 		t.Fatalf("err: %v", err)
 	}
 
-	if err = repo.Initialize(ctx, st, opt, masterPassword); err != nil {
+	if e.Password == "" {
+		e.Password = defaultPassword
+	}
+
+	if err = repo.Initialize(ctx, st, opt, e.Password); err != nil {
 		t.Fatalf("err: %v", err)
 	}
 
-	if err = repo.Connect(ctx, e.ConfigFile(), st, masterPassword, nil); err != nil {
+	if err = repo.Connect(ctx, e.ConfigFile(), st, e.Password, nil); err != nil {
 		t.Fatalf("can't connect: %v", err)
 	}
 
 	e.connected = true
 
-	rep, err := repo.Open(ctx, e.ConfigFile(), masterPassword, openOpt)
+	rep, err := repo.Open(ctx, e.ConfigFile(), e.Password, openOpt)
 	if err != nil {
 		t.Fatalf("can't open: %v", err)
 	}
@@ -135,7 +142,7 @@ func (e *Environment) MustReopen(t *testing.T, openOpts ...func(*repo.Options))
 		t.Fatalf("close error: %v", err)
 	}
 
-	rep, err := repo.Open(ctx, e.ConfigFile(), masterPassword, repoOptions(openOpts))
+	rep, err := repo.Open(ctx, e.ConfigFile(), e.Password, repoOptions(openOpts))
 	if err != nil {
 		t.Fatalf("err: %v", err)
 	}
@@ -154,7 +161,7 @@ func (e *Environment) MustOpenAnother(t *testing.T) repo.RepositoryWriter {
 
 	ctx := testlogging.Context(t)
 
-	rep2, err := repo.Open(ctx, e.ConfigFile(), masterPassword, &repo.Options{})
+	rep2, err := repo.Open(ctx, e.ConfigFile(), e.Password, &repo.Options{})
 	if err != nil {
 		t.Fatalf("err: %v", err)
 	}
@@ -192,11 +199,11 @@ func (e *Environment) MustConnectOpenAnother(t *testing.T, openOpts ...func(*rep
 		},
 	}
 
-	if err = repo.Connect(ctx, config, st, masterPassword, connOpts); err != nil {
+	if err = repo.Connect(ctx, config, st, e.Password, connOpts); err != nil {
 		t.Fatal("can't connect:", err)
 	}
 
-	rep, err := repo.Open(ctx, e.ConfigFile(), masterPassword, repoOptions(openOpts))
+	rep, err := repo.Open(ctx, e.ConfigFile(), e.Password, repoOptions(openOpts))
 	if err != nil {
 		t.Fatal("can't open:", err)
 	}
diff --git a/internal/repotesting/repotesting_test.go b/internal/repotesting/repotesting_test.go
index 2375038c..cfb6796f 100644
--- a/internal/repotesting/repotesting_test.go
+++ b/internal/repotesting/repotesting_test.go
@@ -19,7 +19,7 @@ func TestTimeFuncWiring(t *testing.T) {
 	ft := faketime.NewTimeAdvance(time.Date(2018, time.February, 6, 0, 0, 0, 0, time.UTC), 0)
 
 	// Re open with injected time
-	rep, err := repo.Open(ctx, env.RepositoryWriter.ConfigFilename(), masterPassword, &repo.Options{TimeNowFunc: ft.NowFunc()})
+	rep, err := repo.Open(ctx, env.RepositoryWriter.ConfigFilename(), env.Password, &repo.Options{TimeNowFunc: ft.NowFunc()})
 	if err != nil {
 		t.Fatal("Failed to open repo:", err)
 	}
diff --git a/repo/crypto_key_derivation_nontest.go b/repo/crypto_key_derivation_nontest.go
index ee143d42..e11cec37 100644
--- a/repo/crypto_key_derivation_nontest.go
+++ b/repo/crypto_key_derivation_nontest.go
@@ -10,7 +10,7 @@
 // defaultKeyDerivationAlgorithm is the key derivation algorithm for new configurations.
 const defaultKeyDerivationAlgorithm = "scrypt-65536-8-1"
 
-func (f *formatBlob) deriveMasterKeyFromPassword(password string) ([]byte, error) {
+func (f *formatBlob) deriveFormatEncryptionKeyFromPassword(password string) ([]byte, error) {
 	const masterKeySize = 32
 
 	switch f.KeyDerivationAlgorithm {
diff --git a/repo/crypto_key_derivation_testing.go b/repo/crypto_key_derivation_testing.go
index dd229054..93c3d210 100644
--- a/repo/crypto_key_derivation_testing.go
+++ b/repo/crypto_key_derivation_testing.go
@@ -11,7 +11,7 @@
 // defaultKeyDerivationAlgorithm is the key derivation algorithm for new configurations.
 const defaultKeyDerivationAlgorithm = "testing-only-insecure"
 
-func (f *formatBlob) deriveMasterKeyFromPassword(password string) ([]byte, error) {
+func (f *formatBlob) deriveFormatEncryptionKeyFromPassword(password string) ([]byte, error) {
 	const masterKeySize = 32
 
 	switch f.KeyDerivationAlgorithm {
diff --git a/repo/repository_test.go b/repo/repository_test.go
index 7c93fe9f..9f950fa7 100644
--- a/repo/repository_test.go
+++ b/repo/repository_test.go
@@ -437,6 +437,16 @@ func TestWriteSessionFlushOnFailure(t *testing.T) {
 	verify(ctx, t, env.Repository, oid, []byte{1, 2, 3}, "test-1")
 }
 
+func TestChangePassword(t *testing.T) {
+	ctx, env := repotesting.NewEnvironment(t)
+
+	require.NoError(t, env.RepositoryWriter.ChangePassword(ctx, "new-password"))
+
+	r, err := repo.Open(ctx, env.RepositoryWriter.ConfigFilename(), "new-password", nil)
+	require.NoError(t, err)
+	r.Close(ctx)
+}
+
 func verifyNotFound(ctx context.Context, t *testing.T, rep repo.Repository, objectID object.ID, testCaseID string) {
 	t.Helper()
 
diff --git a/tests/end_to_end_test/repository_repair_test.go b/tests/end_to_end_test/repository_repair_test.go
index 15813328..1f06abb3 100644
--- a/tests/end_to_end_test/repository_repair_test.go
+++ b/tests/end_to_end_test/repository_repair_test.go
@@ -6,7 +6,22 @@
 	"github.com/kopia/kopia/tests/testenv"
 )
 
-func TestRepositoryRepair(t *testing.T) {
+// when password change is enabled, replicas of kopia.repository are not embedded in blobs
+// so `kopia repository repair` will not work.
+func TestRepositoryRepair_PasswordChangeEnabled(t *testing.T) {
+	t.Parallel()
+
+	runner := testenv.NewInProcRunner(t)
+	e := testenv.NewCLITest(t, runner)
+
+	e.RunAndExpectSuccess(t, "repo", "create", "filesystem", "--path", e.RepoDir)
+	e.RunAndExpectSuccess(t, "blob", "rm", "kopia.repository")
+	e.RunAndExpectSuccess(t, "repo", "disconnect")
+
+	e.RunAndExpectFailure(t, "repo", "repair", "filesystem", "--path", e.RepoDir)
+}
+
+func TestRepositoryRepair_PasswordChangeDisabled(t *testing.T) {
 	t.Parallel()
 
 	runner := testenv.NewInProcRunner(t)
@@ -14,7 +29,7 @@ func TestRepositoryRepair(t *testing.T) {
 
 	defer e.RunAndExpectSuccess(t, "repo", "disconnect")
 
-	e.RunAndExpectSuccess(t, "repo", "create", "filesystem", "--path", e.RepoDir)
+	e.RunAndExpectSuccess(t, "repo", "create", "filesystem", "--path", e.RepoDir, "--no-enable-password-change")
 
 	e.RunAndExpectSuccess(t, "snapshot", "create", ".")
 	e.RunAndExpectSuccess(t, "snapshot", "list", ".")
diff --git a/tests/testenv/cli_inproc_runner.go b/tests/testenv/cli_inproc_runner.go
index d922a570..0d608652 100644
--- a/tests/testenv/cli_inproc_runner.go
+++ b/tests/testenv/cli_inproc_runner.go
@@ -11,7 +11,9 @@
 )
 
 // CLIInProcRunner is a CLIRunner that invokes provided commands in the current process.
-type CLIInProcRunner struct{}
+type CLIInProcRunner struct {
+	RepoPassword string
+}
 
 // Start implements CLIRunner.
 func (e *CLIInProcRunner) Start(t *testing.T, args []string) (stdout, stderr io.Reader, wait func() error, kill func()) {
@@ -23,7 +25,7 @@ func (e *CLIInProcRunner) Start(t *testing.T, args []string) (stdout, stderr io.
 	a.AdvancedCommands = "enabled"
 
 	return a.RunSubcommand(ctx, append([]string{
-		"--password", TestRepoPassword,
+		"--password", e.RepoPassword,
 	}, args...))
 }
 
@@ -35,7 +37,9 @@ func NewInProcRunner(t *testing.T) *CLIInProcRunner {
 		t.Skip("not running test since it's also included in the unit tests")
 	}
 
-	return &CLIInProcRunner{}
+	return &CLIInProcRunner{
+		RepoPassword: TestRepoPassword,
+	}
 }
 
 var _ CLIRunner = (*CLIInProcRunner)(nil)