supersede encryption type by encryption parameters
authorPhilipp Gesang <philipp.gesang@intra2net.com>
Fri, 3 Mar 2017 16:57:37 +0000 (17:57 +0100)
committerThomas Jarosch <thomas.jarosch@intra2net.com>
Mon, 2 Apr 2018 11:34:08 +0000 (13:34 +0200)
WIP

deltatar/crypto.py
deltatar/deltatar.py
deltatar/tarfile.py

index f4e678c..cc57c7d 100755 (executable)
@@ -56,6 +56,21 @@ __all__ = [ "ENCRYPT", "DECRYPT"
           , "hdr_make", "hdr_read", "hdr_fmt", "hdr_fmt_pretty"
           , "I2N_HDR_SIZE" ]
 
+
+###############################################################################
+## crypto layer version
+###############################################################################
+
+ENCRYPTION_PARAMETERS = \
+    { 1: \
+        { "kdf": ("scrypt",
+            { "dkLen"    : 16
+            , "N"        : 1 << 15
+            , "r"        : 8
+            , "p"        : 1
+            , "NaCl_LEN" : 16 }) } }
+
+
 ###############################################################################
 ## constants
 ###############################################################################
@@ -144,24 +159,27 @@ def hdr_read (data):
         }
 
 
-def hdr_make (hdr):
+def hdr_from_params (version, paramversion, nacl, iv, ctsize):
     buf  = bytearray (I2N_HDR_SIZE)
     bufv = memoryview (buf)
 
     try:
         struct.pack_into (FMT_I2N_HDR, bufv, 0,
                           I2N_HDR_MAGIC,
-                          hdr["version"],
-                          hdr["paramversion"],
-                          hdr["nacl"],
-                          hdr["iv"],
-                          hdr["ctsize"])
+                          version, paramversion, nacl, iv, ctsize)
     except Exception as exn:
         return False, "error writing header: %s" % str (exn)
 
     return True, bytes (buf)
 
 
+def hdr_make (hdr):
+    return hdr_from_params (version=hdr.get("version"),
+                            paramversion=hdr.get("paramversion"),
+                            nacl=hdr.get("nacl"), iv=hdr.get("iv"),
+                            ctsize=hdr.get("ctsize"))
+
+
 HDR_FMT = "I2n_header { version: %d, paramversion: %d, nacl: %s[%d]," \
                       " iv: %s[%d], ctsize: %d }"
 
@@ -215,28 +233,6 @@ def tag_read (data):
 ###############################################################################
 
 
-def aesgcm_enc (key, data, aad, iv=None):
-    iv = iv or os.urandom(AES_GCM_IV_LEN)
-    enc = Cipher \
-        ( algorithms.AES (key)
-        , modes.GCM (iv)
-        , backend = default_backend ()) \
-        .encryptor ()
-    enc.authenticate_additional_data (aad)
-    result = enc.update (data) + enc.finalize ()
-    return iv, result, enc.tag
-
-
-def aesgcm_dec (key, data, aad, iv, tag):
-    dec = Cipher \
-        ( algorithms.AES (key)
-        , modes.GCM (iv)
-        , backend = default_backend ()) \
-        . decryptor ()
-    dec.authenticate_additional_data (aad)
-    return dec.update (data) + dec.finalize_with_tag (tag)
-
-
 ENCRYPT = 0
 DECRYPT = 1
 
@@ -263,12 +259,10 @@ class AES_GCM_context (object):
     Thin wrapper context over AES encryption.
     """
 
-    key = None
-    aad = None
+    ctx = None
     iv  = None
 
     def __init__ (self, kind, key, aad, iv=None):
-        self.key  = key
         self.aad  = aad
         if not iv:
             iv = os.urandom (AES_GCM_IV_LEN)
@@ -284,22 +278,82 @@ class AES_GCM_context (object):
 
     def done (self, tag=None):
         if self.ctx is None:
-            return False, "no valid encryption context"
+            return False, "no valid encryption context", None
         if tag is None:
             ret = self.ctx.finalize ()
             return True, ret, self.ctx.tag
-        ret = self.ctx.finalize_with_tag (tag)
+        ret = self.ctx.finalize_with_tag (tag) # XXX this fails if tags don’t match
         return True, ret, None
 
 
 ###############################################################################
-## keys
+## convenience wrapper
 ###############################################################################
 
-def scrypt_derive (pw, NaCl=None):
-    NaCl = NaCl or os.urandom (SCRYPT_NaCl_LEN)
-    return NaCl, \
-           pylibscrypt.scrypt (pw, NaCl, SCRYPT_N, SCRYPT_r, SCRYPT_p, SCRYPT_dkLen)
+
+class Crypto (object):
+    """
+    Encryption context to remain alive throughout an entire tarfile pass.
+    """
+    kind = None
+    aes  = None
+    nacl = None
+    key  = None
+    pfx  = None # 64 bit fixed parts of IV
+    cnt  = None
+
+    def __init__ (self, kind, pw, nacl, paramversion, pfx=None):
+        defs = ENCRYPTION_PARAMETERS.get(paramversion)
+        if defs is None:
+            raise ValueError ("no encryption parameters for version %r"
+                              % paramversion)
+        (kdf, params) = defs["hash"]
+        if kdf != "scrypt":
+            raise ValueError ("key derivation method %r unknown" % kdf)
+        if nacl is None: # XXX do we actually want this anywhere?
+            nacl = os.urandom (params["NaCl_LEN"])
+        self.key = pylibscrypt.scrypt (pw, nacl, params["N"], params["r"],
+                                       params["p"], params["dkLen"])
+        self.kind = kind
+        self.cnt  = 1
+        self.pfx  = [ pfx or os.urandom(8) ]
+
+
+    def iv_make (self):
+        return struct.pack("<8sL", self.pfx, self.cnt % 0xffFFffFF)
+
+
+    def next (self, aad=None):
+        """Set up encryption for new object in stream.
+
+        if ``aad is None``: end reached, no new context.
+        """
+        if self.aes is not None:
+            (ok, data, tag)  = self.aes.done ()
+            if ok is False:
+                raise
+        else:
+            data, tag = None, None
+
+        if aad is None:
+            return
+
+        self.cnt += 1
+        try:
+            self.aes = AES_GCM_context (self.kind, self.key, aad, iv=iv_make())
+        except Exception as exn:
+            # XXX devise some error handling strategy
+            raise ("write failed with buffer of size %d" % len(buf))
+
+        return data, tag
+
+
+    def process (self, buf):
+        (ok, res) = self.aes.process_chunk (buf)
+        if ok is True:
+            return res
+        # XXX not clear how tarfile expects to communicate this
+        raise IOError ("write failed with buffer of size %d" % len(buf))
 
 
 ###############################################################################
index b397de3..92aa5ff 100644 (file)
@@ -486,13 +486,13 @@ class DeltaTar(object):
         else:
             comptype = 'tar'
 
-        enctype = ''
+        encver = None
         if 'aes' in self.index_mode:
-            enctype = 'aes'
+            encver = 1
 
         return tarfile._Stream(name=path, mode=mode, comptype=comptype,
                        bufsize=tarfile.RECORDSIZE, fileobj=None,
-                       enctype=enctype, password=self.password)
+                       encver=encver, password=self.password)
 
     def create_full_backup(self, source_path, backup_path,
                            max_volume_size=None, extra_data=dict()):
index 35c538d..13fb489 100644 (file)
@@ -53,7 +53,6 @@ import copy
 import re
 import operator
 
-#from . import aescrypto
 from . import crypto
 
 try:
@@ -113,6 +112,9 @@ GNU_FORMAT = 1                  # GNU tar format
 PAX_FORMAT = 2                  # POSIX.1-2001 (pax) format
 DEFAULT_FORMAT = GNU_FORMAT
 
+DELTATAR_HEADER_VERSION    = 1
+DELTATAR_PARAMETER_VERSION = 1
+
 #---------------------------------------------------------
 # tarfile constants
 #---------------------------------------------------------
@@ -362,7 +364,7 @@ class _Stream:
     """
 
     def __init__(self, name, mode, comptype, fileobj, bufsize,
-                 concat_stream=False, enctype='', password="",
+                 concat_stream=False, encver=None, password="",
                  compresslevel=9):
         """Construct a _Stream object.
         """
@@ -389,7 +391,7 @@ class _Stream:
         self.flags    = 0
         self.internal_pos = 0
         self.concat_stream = concat_stream
-        self.enctype  = enctype
+        self.encver   = encver
         self.password = password
         self.last_block_offset = 0
         self.dbuf     = b""
@@ -399,9 +401,6 @@ class _Stream:
         self.bytes_written = 0
 
         try:
-            if enctype != "" and enctype not in VALID_ENCRYPTION_MODES:
-                raise InvalidEncryptionError("unsupported encryption mode %r"
-                                             % enctype)
             if comptype == "gz":
                 try:
                     import zlib
@@ -409,8 +408,14 @@ class _Stream:
                     raise CompressionError("zlib module is not available")
                 self.zlib = zlib
                 if mode == "r":
-                    if self.enctype == 'aes':
-                        self.encryption = crypto.AES_GCM_context(self.password)
+                    if self.encver is not None:
+                        try:
+                            enc = crypto.Crypto (crypto.DECRYPT, pw, nacl, 1)
+                        except ValueError as exn:
+                            raise InvalidEncryptionError \
+                                    ("ctor failed (%r, <PASSWORD>, “%s”, %r)"
+                                     % (crypto.DECRYPT, nacl, 1))
+                        self.encryption = enc
                     self._init_read_gz()
                     self.exception = zlib.error
                 else:
@@ -418,10 +423,10 @@ class _Stream:
                 self.crc = zlib.crc32(b"") & 0xFFFFffff
 
             elif comptype == "bz2":
-                if self.enctype != "":
-                    raise InvalidEncryptionError("encryption mode %r not "
+                if self.encver is not None:
+                    raise InvalidEncryptionError("encryption version %r not "
                                                  "available for compression %s"
-                                                 % (enctype, comptype))
+                                                 % (encver, comptype))
                 try:
                     import bz2
                 except ImportError:
@@ -434,10 +439,10 @@ class _Stream:
                     self.cmp = bz2.BZ2Compressor()
 
             elif comptype == 'xz':
-                if self.enctype != "":
-                    raise InvalidEncryptionError("encryption mode %r not "
+                if self.encver is not None:
+                    raise InvalidEncryptionError("encryption version %r not "
                                                  "available for compression %s"
-                                                 % (enctype, comptype))
+                                                 % (encver, comptype))
                     try:
                         import lzma
                     except ImportError:
@@ -449,17 +454,31 @@ class _Stream:
                     else:
                         self.cmp = lzma.LZMACompressor()
 
-            elif self.enctype == 'aes':
-                self.encryption = aescrypto.AESCrypt(self.password)
-                if mode != "r":
+            elif self.encver is not None:
+                if mode == "r":
+                    try:
+                        enc = crypto.Crypto (crypto.DECRYPT, pw, nacl, 1)
+                    except ValueError as exn:
+                        raise InvalidEncryptionError \
+                                ("ctor failed (%r, <PASSWORD>, “%s”, %r)"
+                                 % (crypto.DECRYPT, nacl, 1))
+                    self.encryption = enc
+                else:
+                    try:
+                        enc = crypto.Crypto (crypto.ENCRYPT, pw, nacl, 1)
+                    except ValueError as exn:
+                        raise InvalidEncryptionError \
+                                ("ctor failed (%r, <PASSWORD>, “%s”, %r)"
+                                    % (crypto.DECRYPT, nacl, 1))
+                    self.encryption = enc
                     self.encryption.init()
                     self.__write_to_file(self.encryption.salt_str)
 
             elif comptype != "tar":
-                if self.enctype != "":
-                    raise InvalidEncryptionError("encryption mode %r not "
+                if self.encver is not None:
+                    raise InvalidEncryptionError("encryption version %r not "
                                                  "available for compression %s"
-                                                 % (enctype, comptype))
+                                                 % (encver, comptype))
                 raise CompressionError("unknown compression type %r" % comptype)
 
         except:
@@ -482,10 +501,14 @@ class _Stream:
                                          0)
 
         # if aes, we encrypt after compression
-        if self.enctype == 'aes':
-            self.encryption = aescrypto.AESCrypt(self.password)
-            self.encryption.init()
-            self.__write_to_file(self.encryption.salt_str)
+        if self.encver is not None:
+            hdr = crypto.hdr_from_params \
+                        (version=DELTATAR_HEADER_VERSION,
+                         paramversion=self.encver,
+                         nacl=self.encryption.salt_str,
+                         iv=self.encryption.iv,
+                         ctsize=self.encryption.ctsize)
+            self.__write_to_file(hdr)
 
         timestamp = struct.pack("<L", int(time.time()))
         self.__write(b"\037\213\010\010" + timestamp + b"\002\377")
@@ -506,7 +529,7 @@ class _Stream:
             raise CompressionError("new compression blocks can only be added in mode 'w'")
         if self.comptype == "gz":
             self._new_gz_block(True)
-        elif self.enctype == 'aes':
+        elif self.encver is not None:
             self._new_aes_block(True)
         else:
             raise CompressionError("Concat compression only available for comptype 'gz'")
@@ -526,7 +549,7 @@ class _Stream:
                                          0)
 
         # if aes, we encrypt after compression
-        if self.enctype == 'aes':
+        if self.encver is not None:
             self._new_aes_block(set_last_block_offset)
         elif set_last_block_offset:
             self.last_block_offset = self.fileobj.tell()
@@ -541,13 +564,22 @@ class _Stream:
         if self.comptype == "tar":
             self.close(close_fileobj=False)
             self.closed = False
-        self.__write_to_file(self.encryption.close_enc())
+
+        data, tag = self.encryption.next (aad=self.name)
+        if data is not None or tag is not None:
+            self.__write_to_file(data)
+            self.__write_to_file(tag)
 
         if set_last_block_offset:
             self.last_block_offset = self.fileobj.tell()
-        self.encryption = aescrypto.AESCrypt(self.password)
-        self.encryption.init()
-        self.__write_to_file(self.encryption.salt_str)
+
+        hdr = crypto.hdr_from_params \
+            (version=DELTATAR_HEADER_VERSION,
+             paramversion=self.encver,
+             nacl=self.encryption.salt_str,
+             iv=self.encryption.iv,
+             ctsize=self.encryption.ctsize)
+        self.__write_to_file(hdr)
 
     def write(self, s):
         """Write string s to the stream.
@@ -583,8 +615,8 @@ class _Stream:
         the file
         '''
         tow = s
-        if self.enctype == 'aes':
-            tow = self.encryption.encrypt(s)
+        if self.encver is not None:
+            tow = self.encryption.process(s)
         self.__write_to_file(tow)
 
     def estim_file_size(self):
@@ -609,7 +641,7 @@ class _Stream:
             result += len(self.buf)
         if self.comptype == 'gz':
             result += 8   # 2 longs = 8 byte (no extra info written for bzip2)
-        if self.enctype == 'aes':
+        if self.encver is not None:
             result += self.encryption.bs  # (salt was already written at start)
         return result
 
@@ -638,7 +670,7 @@ class _Stream:
             self.__enc_write(chunk)
 
         if close_fileobj and not self._extfileobj:
-            if self.enctype == 'aes' and self.mode == "w":
+            if self.encver is not None and self.mode == "w":
                 self.__write_to_file(self.encryption.close_enc())
             self.fileobj.close()
 
@@ -771,7 +803,6 @@ class _Stream:
                         pass
                     else:
                         raise ReadError("invalid compressed data")
-
                 if self.comptype == "gz" and hasattr(self, "crc"):
                     self.crc = self.zlib.crc32(buf, self.crc) & 0xFFFFffff
                 if self.concat_stream and len(self.cmp.unused_data) != 0:
@@ -841,7 +872,7 @@ class _Stream:
         Well, actually we not only substract the length of "Salted__", but 16/32
         chars because the file is decrypted in multiples of the key size.
         '''
-        if self.enctype == 'aes':
+        if self.encver is not None:
             ## XXX
             ## PHG: this logic doesn’t map to our header-based approach
             ##      and requires adjustment
@@ -866,9 +897,9 @@ class _Stream:
         if not buf: # what else?
             return buf ## XXX WTF‽
 
-        idx = buf.find(b'Salted__')
+        idx = buf.find(b'Salted__') ## wat?
         if idx == -1:
-            return self.encryption.decrypt(buf, last)
+            return self.encryption.process(buf, last) # decrypt
 
         b1 = buf[:idx]
         b2 = buf[idx:]
@@ -1997,28 +2028,27 @@ class TarFile(object):
             filemode, comptype = mode.split("#", 1)
             filemode = filemode or "r"
             password = ''
-            # if not enctype there's no encryption
-            enctype = ''
+            encver = None
 
             if filemode not in "rw":
                 raise ValueError("mode must be 'r' or 'w'")
 
-            if comptype not in ["gz", "gz.aes128", "gz.aes256", 'aes128',
-                                'aes256']:
-                raise ValueError("comptype must be 'gz' or 'aes'")
+            # XXX add equivalent check for defined modes
+            #if comptype not in ["gz", "gz.aes128", "gz.aes256", 'aes128',
+            #                    'aes256']:
+            #    raise ValueError("comptype must be 'gz' or 'aes'")
 
             # encryption gz.aes128 or gz.aes256
             if "." in comptype:
-                comptype, enctype = comptype.split(".", 1)
-                enctype = enctype[:3]
+                comptype, _ = comptype.split(".", 1)
+                encver = 1 # XXX set dynamically
                 password = kwargs.get('password', '')
                 if not password:
                     raise ValueError("you should give a password for encryption")
 
             if comptype.startswith("aes"):
-                enctype = comptype
                 comptype = 'tar'
-                enctype = enctype[:3]
+                encver = 1 # XXX set from mode or whatever
                 password = kwargs.get('password', '')
                 if not password:
                     raise ValueError("you should give a password for encryption")
@@ -2026,7 +2056,7 @@ class TarFile(object):
             kwargs['concat_compression'] = True
 
             stream = _Stream(name, filemode, comptype, fileobj, bufsize,
-                             concat_stream=True, enctype=enctype,
+                             concat_stream=True, encver=encver,
                              password=password, compresslevel=compresslevel)
             try:
                 t = cls(name, filemode, stream, **kwargs)
@@ -2542,7 +2572,7 @@ class TarFile(object):
                             fileobj=None,
                             bufsize=self.fileobj.bufsize,
                             password=self.fileobj.password,
-                            enctype=self.fileobj.enctype,
+                            encver=self.fileobj.encver,
                             concat_stream=self.fileobj.concat_stream)
             else:
                 # here, we lose information about compression/encryption!