Merge branch 'crypto-review'
[python-delta-tar] / deltatar / crypto.py
index d18d406..b3ab9bc 100755 (executable)
@@ -34,7 +34,9 @@ Errors fall into roughly three categories:
         - ``InvalidGCMTag`` (decryption failed on account of an invalid GCM
           tag),
         - ``InvalidIVFixedPart`` (IV fixed part of object not found in list),
-        - ``DuplicateIV`` (the IV of an encrypted object already occurred),
+        - ``DuplicateIV`` (the IV of an object encrypted earlier was reused),
+        - ``NonConsecutiveIV`` (IVs of two encrypted objects are not
+          consecutive),
         - ``DecryptionError`` (used in CLI decryption for presenting error
           conditions to the user).
 
@@ -70,6 +72,14 @@ e. g. when traversing the same object multiple times. Since the crypto context
 has no notion of a position in a PDT encrypted archive, this condition must be
 sorted out downstream.
 
+When encrypting with more than one Encrypt context special care must be taken
+to prevent accidental reuse of IVs. The builtin protection against reuse is
+only effective for objects encrypted with the same Encrypt handle. If multiple
+Encrypt handles are used to encrypt with the same combination of password and
+salt, the encryption becomes susceptible to birthday attacks (bound = 2^32 due
+to the 64-bit random iv). Thus the use of multiple handles is discouraged.
+
+
 Command Line Utility
 -------------------------------------------------------------------------------
 
@@ -121,6 +131,13 @@ archives essentially consist of a stream of independent objects, the salt and
 other parameters may change. Thus a key derived using above method from the
 first object doesn’t necessarily apply to any of the subsequent objects.
 
+Future Developments
+-------------------------------------------------------------------------------
+
+As of 2020 with the format version 1, Deltatar encryption uses the AES-GCM mode
+which requires meticulous bookkeeping of initialization vectors. A future
+version could simplify this aspect of the encryption by switching to the more
+recent AES-GCM-SIV mode (RFC 8452).
 """
 
 import base64
@@ -281,6 +298,10 @@ ENCRYPTION_PARAMETERS = \
                    , "NaCl_LEN" : 16 })
         , "enc": "aes-gcm" } }
 
+# Mode zero is unencrypted and only provided for testing purposes. nless
+# the encryptor / decryptor are explicitly instructed to do so.
+MIN_SECURE_PARAMETERS = 1
+
 ###############################################################################
 ## constants
 ###############################################################################
@@ -522,7 +543,7 @@ IV_FMT = "((f %s) (c %d))"
 def iv_fmt (iv):
     """Format the two components of an IV in a readable fashion."""
     fixed, cnt = struct.unpack (FMT_I2N_IV, iv)
-    return IV_FMT % (binascii.hexlify (fixed), cnt)
+    return IV_FMT % (binascii.hexlify (fixed).decode (), cnt)
 
 
 ###############################################################################
@@ -826,7 +847,7 @@ def kdf_by_version (paramversion=None, defs=None):
     (kdf, params) = defs["kdf"]
     fn = None
     if kdf == "scrypt" : fn = kdf_scrypt
-    if kdf == "dummy"  : fn = kdf_dummy
+    elif kdf == "dummy"  : fn = kdf_dummy
     if fn is None:
         raise ValueError ("key derivation method %r unknown" % kdf)
     return partial (fn, params)
@@ -907,9 +928,10 @@ class Crypto (object):
     iv   = None # current IV
     fixed        = None  # accu for 64 bit fixed parts of IV
     used_ivs     = None  # tracks IVs
-    strict_ivs   = False # if True, panic on duplicate object IV
+    strict_ivs   = False # if True, panic on duplicate or non-consecutive object IV
     password     = None
     paramversion = None
+    insecure     = False # allow plaintext parameters
     stats = { "in"  : 0
             , "out" : 0
             , "obj" : 0 }
@@ -966,7 +988,7 @@ class Crypto (object):
         elif cnt == AES_GCM_IV_CNT_INDEX:
             if self.index_counter_used is True:
                 raise InvalidFileCounter ("attempted to reuse index file "
-                                          " counter %d: must be unique" % cnt)
+                                          "counter %d: must be unique" % cnt)
             self.index_counter_used = True
         if cnt <= AES_GCM_IV_CNT_MAX:
             self.cnt = cnt
@@ -977,16 +999,28 @@ class Crypto (object):
 
 
     def set_parameters (self, password=None, key=None, paramversion=None,
-                        nacl=None, counter=None, strict_ivs=False):
+                        nacl=None, counter=None, strict_ivs=False,
+                        insecure=False):
         """
         Configure the internal state of a crypto context. Not intended for
         external use.
+
+        A parameter version indicating passthrough (plaintext) mode is rejected
+        with an ``InvalidParameter`` unless ``insecure`` is set.
         """
         self.next_fixed ()
         self.set_object_counter (counter)
         self.strict_ivs = strict_ivs
 
+        self.insecure = insecure
+
         if paramversion is not None:
+            if self.insecure is False \
+                    and paramversion < MIN_SECURE_PARAMETERS:
+                raise InvalidParameter \
+                    ("set_parameters: requested parameter version %d but "
+                     "plaintext encryption disallowed in secure context!"
+                     % paramversion)
             self.paramversion = paramversion
 
         if key is not None:
@@ -1026,36 +1060,22 @@ class Crypto (object):
         return out
 
 
-    def next (self, password, paramversion, nacl, iv):
+    def next (self, password, paramversion, nacl):
         """
         Prepare for encrypting another object: Reset the data counters and
         change the configuration in case one of the variable parameters differs
-        from the last object. Also check the IV for duplicates and error out
-        if strict checking was requested.
+        from the last object.
         """
         self.ctsize = 0
         self.ptsize = 0
         self.stats ["obj"] += 1
 
-        self.check_duplicate_iv (iv)
-
         if (   self.paramversion != paramversion
             or self.password     != password
             or self.nacl         != nacl):
             self.set_parameters (password=password, paramversion=paramversion,
-                                 nacl=nacl, strict_ivs=self.strict_ivs)
-
-
-    def check_duplicate_iv (self, iv):
-        """
-        Add an IV (the 12 byte representation as in the header) to the list. With
-        strict checking enabled, this will throw a ``DuplicateIV``. Depending on
-        the context, this may indicate a serious error (IV reuse).
-        """
-        if self.strict_ivs is True and iv in self.used_ivs:
-            raise DuplicateIV ("iv %s was reused" % iv_fmt (iv))
-        # vi has not been used before; add to collection
-        self.used_ivs.add (iv)
+                                 nacl=nacl, strict_ivs=self.strict_ivs,
+                                 insecure=self.insecure)
 
 
     def counters (self):
@@ -1073,6 +1093,22 @@ class Crypto (object):
         self.enc = None
 
 
+    def get_used_ivs (self):
+        """
+        Get the set of IVs that were used so far during the lifetime of
+        this context. Useful to check for IV reuse if multiple encryption
+        contexts were used independently.
+        """
+        return self.used_ivs
+
+
+    def reset_last_iv (self):
+        """
+        Implemented only for decryptor; no-op otherwise.
+        """
+        pass
+
+
 class Encrypt (Crypto):
 
     lastinfo     = None
@@ -1080,12 +1116,11 @@ class Encrypt (Crypto):
     paramenc     = None
 
     def __init__ (self, version, paramversion, password=None, key=None, nacl=None,
-                  counter=AES_GCM_IV_CNT_DATA, strict_ivs=True):
+                  counter=AES_GCM_IV_CNT_DATA, strict_ivs=False, insecure=False):
         """
         The ctor will throw immediately if one of the parameters does not conform
         to our expectations.
 
-                  counter=AES_GCM_IV_CNT_DATA, strict_ivs=True):
         :type      version: int to fit   uint16_t
         :type paramversion: int to fit   uint16_t
         :param    password: mutually exclusive with ``key``
@@ -1098,6 +1133,19 @@ class Encrypt (Crypto):
                             ``AES_GCM_IV_CNT_INDEX`` are unique in each backup set
                             and cannot be reused even with different fixed parts.
         :type   strict_ivs: bool
+        :param  strict_ivs: Enable paranoid tracking of IVs.
+        :type     insecure: bool
+        :param    insecure: whether to permit passthrough mode
+
+        *Security considerations*: The ``class Encrypt`` handle guarantees that
+        all random parts (first eight bytes) of the IVs used for encrypting
+        objects are unique. This guarantee does *not* apply across handles if
+        multiple handles are used with the same combination of password and
+        salt. Thus, use of multiple handles with the same combination of password
+        and salt is subject to birthday attacks with a bound of 2^32. To avoid
+        collisions, the application should keep the number of handles as low
+        as possible and check for reuse by comparing the set of IVs used of all
+        handles that were created (accessible using the ``get_used_ivs`` method).
         """
         if         password is     None and key is     None \
                 or password is not None and key is not None :
@@ -1145,7 +1193,7 @@ class Encrypt (Crypto):
         self.paramenc     = ENCRYPTION_PARAMETERS.get (paramversion) ["enc"]
 
         super().__init__ (password, key, paramversion, nacl, counter=counter,
-                          strict_ivs=strict_ivs)
+                          strict_ivs=strict_ivs, insecure=insecure)
 
 
     def next_fixed (self, retries=PDTCRYPT_IV_GEN_MAX_RETRIES):
@@ -1230,12 +1278,32 @@ class Encrypt (Crypto):
                                     % self.paramversion)
         hdrdum = hdr_make_dummy (filename)
         self.lastinfo = (filename, hdrdum)
-        super().next (self.password, self.paramversion, self.nacl, self.iv)
+
+        self.check_duplicate_iv (self.iv)
+
+        super().next (self.password, self.paramversion, self.nacl)
 
         self.set_object_counter (self.cnt + 1)
         return hdrdum
 
 
+    def check_duplicate_iv (self, iv):
+        """
+        Add an IV (the 12 byte representation as in the header) to the list. With
+        strict checking enabled, this will throw a ``DuplicateIV``. Depending on
+        the context, this may indicate a serious error (IV reuse).
+
+        IVs are only tracked in strict_ivs mode.
+        """
+        if self.strict_ivs is False:
+            return
+
+        if iv in self.used_ivs:
+            raise DuplicateIV ("iv %s was reused" % iv_fmt (iv))
+        # vi has not been used before; add to collection
+        self.used_ivs.add (iv)
+
+
     def done (self, cmpdata):
         """
         Complete encryption of an object. After this has been called, attempts
@@ -1299,7 +1367,7 @@ class Decrypt (Crypto):
     hdr_ctsize = -1
 
     def __init__ (self, password=None, key=None, counter=None, fixedparts=None,
-                  strict_ivs=False):
+                  strict_ivs=True, insecure=False):
         """
         Sanitizing ctor for the decryption context. ``fixedparts`` specifies a
         list of IV fixed parts accepted during decryption. If a fixed part is
@@ -1314,6 +1382,20 @@ class Decrypt (Crypto):
                             ``AES_GCM_IV_CNT_INDEX`` are unique in each backup set
                             and cannot be reused even with different fixed parts.
         :type   fixedparts: bytes list
+        :type   strict_ivs: bool
+        :param  strict_ivs: fail if IVs of decrypted objects are not linearly
+                            increasing
+        :type     insecure: bool
+        :param    insecure: whether to process objects encrypted in
+                            passthrough mode (*``paramversion`` < 1*)
+
+        *Security considerations*: The ``strict_ivs`` setting protects against
+        ciphertext reordering and injection attacks. For this to work it relies
+        on a property of how the object counters are created during encryption.
+        If multiple ``Encrypt`` handles have been used during encryption, this
+        is property is unlikely to apply as it would require manual management
+        of counters across Encrypt handles. In these cases it may thus be
+        necessary to disable the ```strict_ivs`` protection.
         """
         if         password is     None and key is     None \
                 or password is not None and key is not None :
@@ -1340,7 +1422,7 @@ class Decrypt (Crypto):
             self.fixed.sort ()
 
         super().__init__ (password=password, key=key, counter=counter,
-                          strict_ivs=strict_ivs)
+                          strict_ivs=strict_ivs, insecure=insecure)
 
 
     def valid_fixed_part (self, iv):
@@ -1353,6 +1435,17 @@ class Decrypt (Crypto):
         return i != len (self.fixed) and self.fixed [i] == fixed
 
 
+    def reset_last_iv (self):
+        """
+        Force a new IV sequence start. The last IV counter will be set from the
+        next IV encountered and the check for consecutive IVs will be suppressed.
+
+        The intended use is backup volume boundaries or handling batches of
+        objects encrypted with ``Encrypt`` handles initialized with different
+        initial counter values.
+        """
+        self.last_iv = None
+
     def check_consecutive_iv (self, iv):
         """
         Check whether the counter part of the given IV is indeed the successor
@@ -1364,10 +1457,10 @@ class Decrypt (Crypto):
         if self.strict_ivs is True \
                 and self.last_iv is not None \
                 and self.last_iv [0] == fixed \
-                and self.last_iv [1] != cnt - 1:
+                and self.last_iv [1] + 1 != cnt:
             raise NonConsecutiveIV ("iv %s counter not successor of "
                                     "last object (expected %d, found %d)"
-                                    % (iv_fmt (iv), self.last_iv [1], cnt))
+                                    % (iv_fmt (iv), self.last_iv [1] + 1, cnt))
         self.last_iv = (fixed, cnt)
 
 
@@ -1399,10 +1492,11 @@ class Decrypt (Crypto):
 
         self.hdr_ctsize = ctsize
 
-        super().next (self.password, paramversion, nacl, iv)
+        super().next (self.password, paramversion, nacl)
         if self.fixed is not None and self.valid_fixed_part (iv) is False:
             raise InvalidIVFixedPart ("iv %s has invalid fixed part"
                                       % iv_fmt (iv))
+
         self.check_consecutive_iv (iv)
 
         self.tag = tag
@@ -1522,11 +1616,13 @@ def open2_dump_file (fname, dir_fd, force=False):
 PDTCRYPT_SUB_PROCESS = 0
 PDTCRYPT_SUB_SCRYPT  = 1
 PDTCRYPT_SUB_SCAN    = 2
+PDTCRYPT_SUB_IVCHECK = 3
 
 PDTCRYPT_SUB = \
         { "process" : PDTCRYPT_SUB_PROCESS
         , "scrypt"  : PDTCRYPT_SUB_SCRYPT
-        , "scan"    : PDTCRYPT_SUB_SCAN }
+        , "scan"    : PDTCRYPT_SUB_SCAN
+        , "ivcheck" : PDTCRYPT_SUB_IVCHECK }
 
 PDTCRYPT_DECRYPT   = 1 << 0 # decrypt archive with password
 PDTCRYPT_SPLIT     = 1 << 1 # split archive into individual objects
@@ -1594,6 +1690,53 @@ class PassthroughDecryptor (object):
         return d
 
 
+def check_ivs (ifs):
+    """
+    Walk the objects in the given reader, validating uniqueness and
+    consecutiveness of the IVs in the object headers.
+
+    As the IVs are metadata this does not require decryption.
+    """
+    objs = 0
+    seen  = set ()
+    last = None
+
+    while True:
+        try:
+            hdr = hdr_read_stream (ifs)
+        except EndOfFile as exn:
+            break # done
+
+        objs += 1
+        cur = hdr ["iv"]
+
+        fixed, cnt = struct.unpack (FMT_I2N_IV, cur)
+
+        if PDTCRYPT_VERBOSE is True:
+            noise ("PDT: obj %d, iv %s" % (objs, iv_fmt (cur)))
+
+        if last is not None:
+            if fixed != last [0]:
+                noise ("PDT: obj %d, fixed part changed last: %s → this: %s"
+                       % (obj,
+                          binascii.hexlify (last [0]),
+                          binascii.hexlify (fixed)))
+            if cnt != last [1] + 1:
+                raise NonConsecutiveIV ("iv %s counter not successor of "
+                                        "last object (expected %d, found %d)"
+                                        % (iv_fmt (cur), last [1] + 1, cnt))
+
+        if cur in seen:
+            raise DuplicateIV ("iv %s was reused" % iv_fmt (cur))
+
+        seen.add (cur)
+        last = (fixed, cnt)
+
+        ifs.read (hdr ["ctsize"])
+
+    return objs
+
+
 def depdtcrypt (mode, secret, ins, outs):
     """
     Remove PDTCRYPT layer from all objects encrypted with the secret. Used on a
@@ -1913,6 +2056,30 @@ def find_overlaps (slices):
     return [ slices [i] for i in ovrlp ]
 
 
+def mode_ivcheck (ifd):
+    total_obj = 0
+    try:
+        total_obj = check_ivs (ifd)
+    except (NonConsecutiveIV, DuplicateIV) as exn:
+        noise ("PDT: Detected inconsistent initialization vectors")
+        noise ("PDT:")
+        noise ("PDT:    “%s”" % exn)
+        noise ("PDT:")
+        noise ("")
+        return 1
+    except Exception as exn:
+        noise ("PDT: Hit an error unrelated to checking IVs")
+        noise ("PDT:")
+        noise ("PDT:    “%s”" % exn)
+        noise ("PDT:")
+        return 1
+
+    noise ("PDT: Successfully traversed %d encrypted objects in input."
+           % total_obj)
+    noise ("PDT:")
+    noise ("PDT: All IVs consecutive and unique.")
+
+
 def mode_scan (secret, fname, outs=None, nacl=None):
     """
     Dissect a binary file, looking for PDTCRYPT headers and objects.
@@ -2004,6 +2171,7 @@ def mode_scan (secret, fname, outs=None, nacl=None):
         for slice in overlap:
             noise ("PDT:    × %d→%d" % (slice [0], slice [1]))
 
+
 def usage (err=False):
     out = print
     if err is True:
@@ -2018,10 +2186,12 @@ def usage (err=False):
     out ("       %s              [ -f | --format ]" % indent)
     out ("")
     out ("\twhere")
-    out ("\t\tSUBCOMMAND      main mode: { process | scrypt }")
+    out ("\t\tSUBCOMMAND      main mode: { process | scrypt | scan | ivcheck }")
     out ("\t\t                where:")
     out ("\t\t                   process: extract objects from PDT archive")
     out ("\t\t                   scrypt:  calculate hash from password and first object")
+    out ("\t\t                   scan:    scan input for PDTCRYPT headers")
+    out ("\t\t                   ivcheck: check whether IVs are consecutive")
     out ("\t\t-p PASSWORD     password to derive the encryption key from")
     out ("\t\t-k KEY          encryption key as 16 bytes in hexadecimal notation")
     out ("\t\t-s              enforce strict handling of initialization vectors")
@@ -2158,7 +2328,9 @@ def parse_argv (argv):
             checked_secret (make_secret (key=ek.strip ()))
 
     if secret is None:
-        if subcommand == PDTCRYPT_SUB_SCRYPT:
+        if subcommand == PDTCRYPT_SUB_IVCHECK:
+            pass
+        elif subcommand == PDTCRYPT_SUB_SCRYPT:
             bail ("ERROR: scrypt hash mode requested but no password given")
         elif mode & PDTCRYPT_DECRYPT:
             bail ("ERROR: decryption requested but no password given")
@@ -2195,6 +2367,10 @@ def parse_argv (argv):
             bail ("ERROR: input must be seekable; please specify a file")
         return True, partial (mode_scan, secret, insspec, outs, nacl=nacl)
 
+    if subcommand == PDTCRYPT_SUB_IVCHECK:
+        if insspec is None:
+            bail ("ERROR: please supply an input file for checking ivs")
+
     if subcommand == PDTCRYPT_SUB_SCRYPT:
         if secret [0] == PDTCRYPT_SECRET_KEY:
             bail ("ERROR: scrypt mode requires a password")
@@ -2208,6 +2384,9 @@ def parse_argv (argv):
     if insspec is not None or subcommand != PDTCRYPT_SUB_SCRYPT:
         ins = deptdcrypt_mk_stream (PDTCRYPT_SOURCE, insspec or "-")
 
+    if subcommand == PDTCRYPT_SUB_IVCHECK:
+        return True, partial (mode_ivcheck, ins)
+
     if subcommand == PDTCRYPT_SUB_SCRYPT:
         return True, partial (mode_scrypt, secret [1].encode (), ins, nacl,
                               fmt=scrypt_format)