Repository: borgbackup/borg

Issue:
migrate-to-repokey endless passphrase loop on repokey repo
borg migrate-to-repokey on a repokey repo created endless loop for asking passphrase

Expected behaviour is to indicate repokey mode active after first passphrase query if repo is not passphrase repo.



Discussion:
Should manifest key type change when migrating repo key?
AFAICT Manifest does not change on migrate. 

Quick fix for this problem would be to add test for keytype on migrate.
archiver.py
132:        if manifest_data[0] == PassphraseKey.TYPE:

With that change migrate will not stay in endless loop, but already migrated passphrase things rechange repokey.

I don't know enough about manifest manipulation in borg, so I'm not comfortable touching that part of the code. 

No, keytype byte does not change. As the support for passphrase-only was removed, its type byte was just re-assigned to be repokey now. The type byte is in every chunk stored into the repo, not just the manifest. So, checking for the type byte does not help here, but checking for key location would.

Another quickfix would be to change while True to a counter + error

borg/key.py
234:        while True:

This is not an issue for me, but migrating to 1.0.0 from old passphrase system or other people unsure about this command might be confused and some clean exit would be nice (like three tries).


Resolving patch:
diff --git a/borg/key.py b/borg/key.py
--- a/borg/key.py
+++ b/borg/key.py
@@ -18,6 +18,10 @@
 PREFIX = b'\0' * 8
 
 
+class PasswordRetriesExceeded(Error):
+    """exceeded the maximum password retries"""
+
+
 class UnsupportedPayloadError(Error):
     """Unsupported payload type {}. A newer version is required to access this repository."""
 
@@ -185,7 +189,7 @@ def new(cls, allow_empty=False):
         passphrase = cls.env_passphrase()
         if passphrase is not None:
             return passphrase
-        while True:
+        for retry in range(1, 11):
             passphrase = cls.getpass('Enter new passphrase: ')
             if allow_empty or passphrase:
                 passphrase2 = cls.getpass('Enter same passphrase again: ')
@@ -196,6 +200,8 @@ def new(cls, allow_empty=False):
                     print('Passphrases do not match', file=sys.stderr)
             else:
                 print('Passphrase must not be blank', file=sys.stderr)
+        else:
+            raise PasswordRetriesExceeded
 
     def __repr__(self):
         return '<Passphrase "***hidden***">'
@@ -231,7 +237,7 @@ def detect(cls, repository, manifest_data):
         passphrase = Passphrase.env_passphrase()
         if passphrase is None:
             passphrase = Passphrase.getpass(prompt)
-        while True:
+        for retry in range(1, 3):
             key.init(repository, passphrase)
             try:
                 key.decrypt(None, manifest_data)
@@ -240,6 +246,8 @@ def detect(cls, repository, manifest_data):
                 return key
             except IntegrityError:
                 passphrase = Passphrase.getpass(prompt)
+        else:
+            raise PasswordRetriesExceeded
 
     def change_passphrase(self):
         class ImmutablePassphraseError(Error):
@@ -259,8 +267,12 @@ def detect(cls, repository, manifest_data):
         target = key.find_key()
         prompt = 'Enter passphrase for key %s: ' % target
         passphrase = Passphrase.env_passphrase(default='')
-        while not key.load(target, passphrase):
+        for retry in range(1, 4):
+            if key.load(target, passphrase):
+                break
             passphrase = Passphrase.getpass(prompt)
+        else:
+            raise PasswordRetriesExceeded
         num_blocks = num_aes_blocks(len(manifest_data) - 41)
         key.init_ciphers(PREFIX + long_to_bytes(key.extract_nonce(manifest_data) + num_blocks))
         return key


Test patch: