implement split mode for CLI encryption
authorPhilipp Gesang <philipp.gesang@intra2net.com>
Mon, 24 Apr 2017 09:23:53 +0000 (11:23 +0200)
committerThomas Jarosch <thomas.jarosch@intra2net.com>
Mon, 2 Apr 2018 11:34:08 +0000 (13:34 +0200)
deltatar/crypto.py

index 5fa4048..5b1c004 100755 (executable)
@@ -750,6 +750,11 @@ class Decrypt (Crypto):
 ## freestanding invocation
 ###############################################################################
 
+PDTCRYPT_DECRYPT   = 1 << 0 # decrypt archive with password
+PDTCRYPT_SPLIT     = 1 << 1 # split archive into individual objects
+
+PDTCRYPT_SPLITNAME = "pdtcrypt-object-%d.bin"
+
 PDTCRYPT_VERBOSE   = False
 PDTCRYPT_STRICTIVS = False
 PDTCRYPT_BLOCKSIZE = 1 << 12
@@ -761,12 +766,15 @@ SELF               = None
 class PDTDecryptionError (Exception):
     """Decryption failed."""
 
+class PDTSplitError (Exception):
+    """Decryption failed."""
+
 
 def noise (*a, **b):
     print (file=sys.stderr, *a, **b)
 
 
-def depdtcrypt (pw, ins, outs):
+def depdtcrypt (mode, pw, ins, outs):
     """
     Remove PDTCRYPT layer from obj encrypted with pw. Used on a Deltatar
     backup this will yield a (possibly Gzip compressed) tarball.
@@ -778,6 +786,45 @@ def depdtcrypt (pw, ins, outs):
     total_pt   = 0               # total plaintext bytes
     total_ct   = 0               # total ciphertext bytes
     total_read = 0               # total bytes read
+    outfile    = None            # Python file object for output
+
+    def nextout (_):
+        """Dummy for non-split mode: output file does not vary."""
+        return outs
+
+    if mode & PDTCRYPT_SPLIT:
+        def nextout (outfile):
+            """
+            We were passed an fd as outs for accessing the destination
+            directory where extracted archive components are supposed
+            to end up in.
+            """
+
+            if outfile is None:
+                if PDTCRYPT_VERBOSE is True:
+                    noise ("PDT: no output file to close at this point")
+            else:
+                if PDTCRYPT_VERBOSE is True:
+                    noise ("PDT: release output file %r" % outfile)
+                    # cleanup happens automatically by the GC; the next
+                    # line will error out on account of an invalid fd
+                    #outfile.close ()
+
+            assert total_obj > 0
+            fname = PDTCRYPT_SPLITNAME % total_obj
+            try:
+                outfd = os.open (fname, os.O_CREAT | os.O_EXCL | os.O_WRONLY,
+                                 0o600, dir_fd=outs)
+                if PDTCRYPT_VERBOSE is True:
+                    noise ("PDT: new output file %s → %d" % (fname, outfd))
+            except FileExistsError as exn:
+                noise ("PDT: refusing to overwrite existing file %s" % fname)
+                noise ("")
+                raise PDTSplitError ("destination file %s already exists"
+                                     % fname)
+
+            return os.fdopen (outfd, "wb", closefd=True)
+
 
     def tell (s):
         """ESPIPE is normal on stdio stream."""
@@ -787,14 +834,14 @@ def depdtcrypt (pw, ins, outs):
             if exn.errno == 29:
                 return -1
 
-    def out (pt):
+    def out (pt, outfile):
         npt = len (pt)
         nonlocal total_pt
         total_pt += npt
         if PDTCRYPT_VERBOSE is True:
             noise ("PDT:\t· decrypt plaintext %d B" % (npt))
         try:
-            nn = outs.write (pt)
+            nn = outfile.write (pt)
         except OSError as exn: # probably ENOSPC
             raise DecryptionError ("error (%s)" % exn)
         if nn != npt:
@@ -813,7 +860,7 @@ def depdtcrypt (pw, ins, outs):
                     raise DecryptionError ("error finalizing object %d (%d B): "
                                            "%r" % (total_obj, len (pt), exn)) \
                           from exn
-                out (pt)
+                out (pt, outfile)
                 if PDTCRYPT_VERBOSE is True:
                     noise ("PDT:\t· object validated")
 
@@ -841,7 +888,12 @@ def depdtcrypt (pw, ins, outs):
                                pretty.splitlines (), ""))
             ctcurrent = ctleft = hdr ["ctsize"]
             decr.next (hdr)
-            total_obj += 1
+
+            total_obj += 1 # used in file counter with split mode
+
+            # finalization complete or skipped in case of first object in
+            # stream; create a new output file if necessary
+            outfile = nextout (outfile)
 
             if PDTCRYPT_VERBOSE is True:
                 noise ("PDT: %d decrypt obj no. %d,  %d B"
@@ -871,7 +923,7 @@ def depdtcrypt (pw, ins, outs):
         if PDTCRYPT_VERBOSE is True:
             noise ("PDT:\t· decrypt ciphertext %d B" % (nct))
         pt = decr.process (ct)
-        out (pt)
+        out (pt, outfile)
 
 
 def deptdcrypt_mk_stream (kind, path):
@@ -910,14 +962,20 @@ def usage (err=False):
     out = print
     if err is True:
         out = noise
-    out ("usage: %s { --help | [ -v ] PASSWORD -i { - | SOURCE } -o { - | DESTINATION } }"
-         % SELF)
+    out ("usage: %s { --help" % SELF)
+    out ("          | [ -v ] { PASSWORD } { { -i | --in }  { - | SOURCE } }")
+    out ("                                { { -o | --out } { - | DESTINATION } }")
+    out ("                                { -D | --no-decrypt } { -S | --split }")
     out ("")
     out ("\twhere")
     out ("\t\tPASSWORD        password to derive the encryption key from")
+    out ("\t\t-s              enforce strict handling of initialization vectors")
     out ("\t\t-i SOURCE       file name to read from")
     out ("\t\t-o DESTINATION  file to write output to")
     out ("\t\t-v              print extra info")
+    out ("\t\t-S              split into files at object boundaries; this")
+    out ("\t\t                requires DESTINATION to refer to directory")
+    out ("\t\t-D              PDT header and ciphertext passthrough")
     out ("")
     out ("\tinstead of filenames, “-” may used to specify stdin / stdout")
     out ("")
@@ -926,6 +984,7 @@ def usage (err=False):
 
 def parse_argv (argv):
     global SELF
+    mode      = PDTCRYPT_DECRYPT
     pw        = None
     insspec   = None
     outsspec  = None
@@ -949,6 +1008,12 @@ def parse_argv (argv):
         elif arg in [ "-o", "--out", "--dest", "--sink" ]:
             outsspec = next (argvi)
             if PDTCRYPT_VERBOSE is True: noise ("PDT: decrypt to %s" % outsspec)
+        elif arg in [ "-S", "--split" ]:
+            mode |= PDTCRYPT_SPLIT
+            if PDTCRYPT_VERBOSE is True: noise ("PDT: split files")
+        elif arg in [ "-D", "--no-decrypt" ]:
+            mode &= ~PDTCRYPT_DECRYPT
+            if PDTCRYPT_VERBOSE is True: noise ("PDT: not decrypting")
         else:
             if pw is None:
                 pw = arg
@@ -959,23 +1024,49 @@ def parse_argv (argv):
                 usage (err=True)
                 raise Unreachable
 
-    if pw is None:
-        noise ("ERROR: no password given")
+    if mode & PDTCRYPT_DECRYPT and pw is None:
+        noise ("ERROR: encryption requested but no password given")
         noise ("")
         usage (err=True)
         raise Unreachable
 
     # default to stdout
     ins  = deptdcrypt_mk_stream (PDTCRYPT_SOURCE, insspec  or "-")
-    outs = deptdcrypt_mk_stream (PDTCRYPT_SINK  , outsspec or "-")
-    return pw, ins, outs
+    if mode & PDTCRYPT_SPLIT: # destination must be directory
+        if outsspec is None or outsspec == "-":
+            noise ("ERROR: split mode is incompatible with stdout sink")
+            noise ("")
+            usage (err=True)
+            raise Unreachable
+
+        try:
+            try:
+                os.makedirs (outsspec, 0o700)
+            except FileExistsError:
+                # if it’s a directory with appropriate perms, everything is
+                # good; otherwise, below invocation of open(2) will fail
+                pass
+            outs = os.open (outsspec, os.O_DIRECTORY, 0o600)
+        except FileNotFoundError as exn:
+            noise ("ERROR: cannot create target directory “%s”" % outsspec)
+            noise ("")
+            usage (err=True)
+            raise Unreachable
+        except NotADirectoryError as exn:
+            noise ("ERROR: target path “%s” is not a directory" % outsspec)
+            noise ("")
+            usage (err=True)
+            raise Unreachable
+    else:
+        outs = deptdcrypt_mk_stream (PDTCRYPT_SINK  , outsspec or "-")
+    return mode, pw, ins, outs
 
 
 def main (argv):
-    pw, ins, outs  = parse_argv (argv)
+    mode, pw, ins, outs  = parse_argv (argv)
     try:
         total_read, total_obj, total_ct, total_pt = \
-            depdtcrypt (pw, ins, outs)
+            depdtcrypt (mode, pw, ins, outs)
     except DecryptionError as exn:
         noise ("PDT: Decryption failed:")
         noise ("PDT:")