lenc 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,108 @@
1
+ require 'json'
2
+ require_relative 'tools'
3
+
4
+ module LEnc
5
+
6
+ # Manages a configuration file, which
7
+ # is a set of key/value pairs that can be saved to the file system.
8
+ #
9
+ class ConfigFile
10
+
11
+ attr_reader :path
12
+
13
+ # @param filename where configuration file is to be found (or written to,
14
+ # if it doesn't yet exist)
15
+ # @param parentDir directory containing file; if nil, uses user's home directory
16
+ #
17
+ def initialize(filename, parentDir=nil)
18
+
19
+ parentDir ||= Dir.home
20
+
21
+ @path = File.join(parentDir, filename)
22
+
23
+ @content = {}
24
+ @origContentStr = "!!!"
25
+ if exists()
26
+ contents = read_text_file(@path).strip
27
+
28
+ @content = JSON.parse(contents)
29
+ @origContentStr = JSON.dump(@content)
30
+ end
31
+ end
32
+
33
+ # Determine if configuration file exists on disk
34
+ def exists()
35
+ File.exists?(@path)
36
+ end
37
+
38
+ # Get the directory containing the configuration file
39
+ def get_directory()
40
+ File.dirname(@path)
41
+ end
42
+
43
+ # Store a key => value pair (any existing value for this key is overwritten)
44
+ def set(key,val)
45
+ @content[key] = val
46
+ end
47
+
48
+ # Remove key (and value), if it exists
49
+ def remove(key)
50
+ @content.remove(key)
51
+ end
52
+
53
+ # Write configuration file, if it has changed
54
+ def write
55
+ newStr = JSON.dump(@content)
56
+
57
+ if newStr != @origContentStr
58
+ @origContentStr = newStr
59
+ write_text_file(@path,@origContentStr + "\n")
60
+ end
61
+ end
62
+
63
+ # Get value for a key
64
+ # @param defVal value to return if key doesn't exist
65
+ def val(key, defVal = nil)
66
+ @content[key] || defVal
67
+ end
68
+
69
+ def to_s
70
+ s = 'ConfigFile '
71
+ s << path()
72
+ s << dh(@content)
73
+ # " [\n"
74
+ # @content.each_pair do |k,v|
75
+ # s << d(k) << ' ==> ' << d(v) << "\n"
76
+ # end
77
+ # s << "]\n"
78
+ s
79
+ end
80
+
81
+ def inspect
82
+ to_s
83
+ end
84
+
85
+ end # Class
86
+ end # Module
87
+
88
+
89
+ if main? __FILE__
90
+
91
+ include LEnc
92
+
93
+ f = ConfigFile.new("__testrbconfig__.txt")
94
+
95
+ pr("constructed:\n%s\n",d(f))
96
+
97
+ srand
98
+ "alpha bravo echo geronimo".split.each do |s|
99
+ n = s.size
100
+ if rand() > 0.8
101
+ n = rand(12)
102
+ end
103
+ f.set(s,n)
104
+ end
105
+
106
+ f.write
107
+
108
+ end
@@ -0,0 +1,70 @@
1
+ require_relative 'repo'
2
+
3
+ # The application script (i.e., the 'main program')
4
+ #
5
+ class LEncApp
6
+ include LEnc
7
+
8
+ def run(argv = ARGV)
9
+
10
+ req 'trollop'
11
+ p = Trollop::Parser.new do
12
+ opt :init, "create new encryption repository: KEY ENCDIR ", :type => :strings
13
+ opt :orignames, "leave filenames unencrypted"
14
+ opt :update, "update encrypted repository (default operation)"
15
+ opt :recover, "recover files from an encrypted repository: KEY ENCDIR RECDIR", :type => :strings
16
+ opt :where, "specify source directory (default = current directory)", :type => :string
17
+ opt :verbose,"verbose operation"
18
+ opt :quiet, "quiet operation"
19
+ opt :dryrun, "show which files will be modified, but make no changes"
20
+ end
21
+
22
+ options = Trollop::with_standard_exception_handling p do
23
+ p.parse argv
24
+ end
25
+
26
+ v = 0
27
+ v = -1 if options[:quiet]
28
+ v = 1 if options[:verbose]
29
+
30
+ nOpt = 0
31
+ nOpt += 1 if options[:init]
32
+ nOpt += 1 if options[:update]
33
+ nOpt += 1 if options[:recover]
34
+
35
+ Trollop::die("Only one operation can be performed at a time." ) if nOpt > 1
36
+
37
+ r = Repo.new(:dryrun => options[:dryrun],
38
+ :verbosity => v)
39
+
40
+ begin
41
+
42
+ if (a = options[:init])
43
+ Trollop::die("Expecting: KEY ENCDIR") if a.size != 2
44
+ pwd,encDir = a
45
+ r.create(options[:where], pwd, encDir, options[:orignames])
46
+ elsif (a = options[:recover])
47
+ Trollop::die("Expecting: KEY ENCDIR RECDIR") if a.size != 3
48
+ r.perform_recovery(a[0],a[1],a[2])
49
+ else
50
+ r.open(options[:where])
51
+ r.perform_update(options[:verifyenc])
52
+ end
53
+
54
+ r.close()
55
+ rescue Exception =>e
56
+ puts("\nProblem encountered: #{e.message}")
57
+ end
58
+
59
+ end
60
+ end
61
+
62
+ if __FILE__ == $0
63
+ args = ARGV
64
+
65
+ # warn("trying special")
66
+ # args = "-w /Users/jeff/Desktop/_testdirs_ -r feefiefoefumaldkjsdaflkj /Users/jeff/Desktop/_testdirs_/encr /Users/jeff/Desktop/_testdirs_/resc2".split
67
+ # args = "-h".split
68
+ LEncApp.new().run(args)
69
+ end
70
+
@@ -0,0 +1,1024 @@
1
+ require 'base64'
2
+ require 'pathname'
3
+ require 'fileutils'
4
+
5
+ require_relative 'tools'
6
+ req('aes config_file')
7
+
8
+ module RepoInternal
9
+ class IgnoreEntry
10
+ attr_accessor :dirOnly, :negated, :pathMode, :rexp, :dbPattern
11
+
12
+ # Representation of a .lencignore entry
13
+ def initialize
14
+ @dirOnly, @negated, @pathMode, @rexp, @dbPattern = nil
15
+ end
16
+
17
+ def inspect
18
+ to_s
19
+ end
20
+ def to_s
21
+ s = "Ign<"
22
+ s << df(@dirOnly,"dirOnly") << "expr: " << @rexp.to_s << ">"
23
+ s
24
+ end
25
+ end
26
+ end
27
+
28
+
29
+ module LEnc
30
+
31
+ class RepoNotFoundException < Exception
32
+ end
33
+
34
+ class EncryptionVerificationException < Exception
35
+ end
36
+
37
+ class VersionException < Exception
38
+ end
39
+
40
+ class RecoveryException < Exception
41
+ end
42
+
43
+ class UpdateException < Exception
44
+ end
45
+
46
+ # Represents an encrypted repository
47
+ #
48
+ class Repo
49
+
50
+ include RepoInternal
51
+
52
+ # The filename that represents a repository; it is
53
+ # stored in the repository's root directory. """
54
+ if windows?
55
+ LENC_REPO_FILENAME = "__lenc_repo__.txt"
56
+ else
57
+ LENC_REPO_FILENAME = ".lenc"
58
+ end
59
+
60
+ ENCRFILENAMEPREFIX = "_#"
61
+
62
+
63
+ private
64
+
65
+ RESPECIALPAT = Regexp.new('^[\.\^\$\*\+\?\{\}\\\|\!\:\)\(\[\]]$')
66
+
67
+ if windows?
68
+ IGNOREFILENAME = "__lencignore__.txt"
69
+ TEMPFILENAME = "__tmp_file__.bin"
70
+ else
71
+ IGNOREFILENAME = ".lencignore"
72
+ TEMPFILENAME = ".lenc__temp__file"
73
+ end
74
+
75
+
76
+ DEFAULTIGNORE = \
77
+ "#{LENC_REPO_FILENAME}\n #{TEMPFILENAME}\n" + \
78
+ ".DS_Store\n" + \
79
+ ".recoverdefaults\n"
80
+
81
+ STATE_CLOSED = 0
82
+ STATE_OPEN = 1
83
+
84
+ public
85
+
86
+ # Construct a repository object. It is created in a 'closed' state, in that it
87
+ # is not associated with a particular repository in the file system.
88
+ #
89
+ # @param options hash table of optional parameters; e.g.
90
+ # r = LEnc::Repo(:verbosity => 2, strict => True)
91
+ #
92
+ # :dryrun if true, no files on the filesystem will be affected by this
93
+ # object throughout its lifetime. Useful for showing the user what
94
+ # would happen if dryrun were false.
95
+ # :verbosity controls the amount of feedback during this object's lifetime.
96
+ # default 0; if < 0, silent; if > 0, talkative
97
+ #
98
+ def initialize(options = {})
99
+
100
+ reset_state()
101
+
102
+ @dryrun = options.delete :dryrun
103
+ @verbosity = (options.delete :verbosity) || 0
104
+
105
+ # During recovery, we will use the first file we encounter to
106
+ # verify if the supplied password is correct, and abort if not.
107
+ @password_verified = false
108
+
109
+ if options.size > 0
110
+ raise ArgumentError, "Unrecognized options: " + d2(options)
111
+ end
112
+ end
113
+
114
+
115
+ # Create a new encryption repository, and open it.
116
+ #
117
+ # @param repo_dir directory of new repository (nil for current directory)
118
+ # @param key encryption key, a string from 20 to 56 characters in length
119
+ # @param enc_dir directory to store encrypted files; must not yet exist, and must
120
+ # not represent a directory lying within the repo_dir tree
121
+ # @param original_names if true, the filenames are not encrypted, only the file contents
122
+ #
123
+ # @raise ArgumentError if appropriate
124
+ #
125
+ def create(repo_dir, key, enc_dir, original_names=false)
126
+ raise IllegalStateException if @state != STATE_CLOSED
127
+
128
+ db = warndb 0
129
+ !db || pr("Repo.create, %s\n",da( [repo_dir,key,enc_dir,original_names]))
130
+ repo_dir ||= Dir.pwd
131
+
132
+ if !File.directory?(repo_dir)
133
+ raise ArgumentError, "Not a directory: #{repo_dir}"
134
+ end
135
+
136
+ # Verify that there is no repository.
137
+ # Construct a ConfigFile object to determine if it already exists
138
+ @confFile = ConfigFile.new(LENC_REPO_FILENAME, repo_dir)
139
+
140
+ if @confFile.exists()
141
+ raise ArgumentError, 'Encryption repository already exists: ' \
142
+ + @confFile.path
143
+ end
144
+
145
+ @confFile.set('version', @version)
146
+
147
+ @orignames = original_names
148
+
149
+ edir = File.absolute_path(enc_dir)
150
+ @confFile.set('orignames', @orignames)
151
+
152
+ if @verbosity >= 0
153
+ pr("Creating encryption repository %s\n", @confFile.path)
154
+ end
155
+
156
+ pp = verifyDirsDistinct([repo_dir, edir])
157
+
158
+ if pp
159
+ raise ArgumentError, "Directory " + pp[0] + \
160
+ " is a subdirectory of " + pp[1]
161
+ end
162
+
163
+ if (key.size < 20 || key.size > 56)
164
+ raise ArgumentError, "Password length " + key.size.to_s \
165
+ + " is illegal"
166
+ end
167
+
168
+ @confFile.set('key', key)
169
+
170
+ # Create encryption directory
171
+ if File.exists?(edir)
172
+ raise ArgumentError, \
173
+ "Encryption directory or file already exists: '#{edir}'"
174
+ end
175
+
176
+ @confFile.set('encDir', edir)
177
+
178
+ if not @dryrun
179
+ Dir.mkdir(edir)
180
+ @confFile.write()
181
+ end
182
+ end
183
+
184
+
185
+ # Open the repository, by associating it with one in the file system.
186
+ #
187
+ # @param startDirectory directory lying within repository tree; if nil, uses
188
+ # current directory
189
+ #
190
+ # @raise IllegalStateException if repository is already open
191
+ # @raise ArgumentError if directory doesn't exist, or does not lie in a repository
192
+ #
193
+ def open(startDirectory=nil)
194
+ db = warndb 0
195
+ !db || pr("Repo.open startDir=%s\n",d(startDirectory))
196
+
197
+ raise IllegalStateException if @state != STATE_CLOSED
198
+
199
+ startDirectory ||= Dir.pwd
200
+
201
+ if not File.directory?(startDirectory)
202
+ raise ArgumentError,"Not a directory: '" + startDirectory + "'"
203
+ end
204
+
205
+ cfile = Repo.findRepository(startDirectory)
206
+ !db || pr(" find repo (%s) => %s\n",d(startDirectory),d(cfile))
207
+
208
+ if !cfile
209
+ raise RepoNotFoundException, "Can't find repository"
210
+ end
211
+
212
+ @confFile = cfile
213
+ @startDir = startDirectory
214
+ @repoBaseDir = cfile.get_directory
215
+
216
+ cfVersion = @confFile.val('version', 0)
217
+ if cfVersion > @version
218
+ raise(VersionError,"Repository was built with a more recent version of the program.")
219
+ end
220
+
221
+ if cfVersion.floor < @version.floor
222
+ raise(VersionError,"Repository was built with an older version; rebuild it")
223
+ end
224
+
225
+ # Read values from configuration to instance vars
226
+ @encrDir = @confFile.val('encDir')
227
+ @encrKey = @confFile.val('key')
228
+ @orignames = @confFile.val('orignames')
229
+
230
+ prepareKeys()
231
+
232
+ @state = STATE_OPEN
233
+ end
234
+
235
+ # Close the repository, if it is open
236
+ def close
237
+ return if @state == STATE_CLOSED
238
+
239
+ raise IllegalStateException if @state != STATE_OPEN
240
+
241
+ removeTempFile()
242
+ reset_state()
243
+ end
244
+
245
+ # Update the repository. Finds files that need to be re-encrypted and does so.
246
+ # Repository must be open.
247
+ #
248
+ # @param verifyEncryption for debug purposes; if true, each file that is encrypted is tested to confirm that
249
+ # it decrypts correctly.
250
+ #
251
+ # @raise IllegalStateException if repository isn't open.
252
+ #
253
+ def perform_update(verifyEncryption=false)
254
+ raise IllegalStateException if @state != STATE_OPEN
255
+
256
+ setInputOutputDirs(@startDir,@encrDir)
257
+
258
+ @verifyEncryption = verifyEncryption
259
+
260
+ puts("Encrypting...") if @verbosity >= 1
261
+
262
+ begin
263
+ encryptDir(@repoBaseDir, @encrDir)
264
+ puts("...done.") if @verbosity >= 1
265
+ end
266
+ end
267
+
268
+
269
+ # Recover files from a repository's encryption folder.
270
+ #
271
+ # @param key encryption key
272
+ # @param eDir encryption directory
273
+ # @param rDir directory to write decrypted files to; creates it if necessary.
274
+ # Must not lie within eDir tree.
275
+ #
276
+ # @raise ArgumentError if problem with the directory arguments;
277
+ # @raise DecryptionError if incorrect password provided, and strict mode in effect
278
+ #
279
+ def perform_recovery(key, eDir, rDir)
280
+ raise IllegalStateException if @state != STATE_CLOSED
281
+
282
+ ret = nil
283
+
284
+ @encrKey = key
285
+
286
+ rd = File.absolute_path(rDir)
287
+ prepareKeys()
288
+
289
+ if not File.directory?(eDir)
290
+ raise ArgumentError, "Not a directory: '" + eDir + "'"
291
+ end
292
+
293
+ # There must not exist a repository in the recovery directory
294
+ cf = Repo.findRepository(rd)
295
+
296
+ if cf
297
+ raise ArgumentError, "Recovery directory lies within repository: " + cf.getPath()
298
+ end
299
+
300
+ puts("Recovering...") if @verbosity >= 1
301
+
302
+ setInputOutputDirs(eDir,rd)
303
+
304
+ begin
305
+ recover(eDir, rd)
306
+ print("...done.") if @verbosity >= 1
307
+ end
308
+
309
+ ret
310
+ end
311
+
312
+ private
313
+
314
+ def setInputOutputDirs(inp,outp)
315
+ @inputDir = File.absolute_path(inp)
316
+ @outputDir = File.absolute_path(outp)
317
+ end
318
+
319
+ # Determine if no paths in a list lie in a subdirectory of another
320
+ def verifyDirsDistinct(dirList)
321
+ dirList.each_with_index do |di,i|
322
+ dirList.each_with_index do |dj,j|
323
+ next if i == j
324
+ if di.start_with? dj
325
+ return [di,dj]
326
+ end
327
+ end
328
+ end
329
+ nil
330
+ end
331
+
332
+ def renameTempFile(newFilename)
333
+ if !File.exists?(TEMPFILENAME)
334
+ raise IOError, "Temporary file missing or of wrong type"
335
+ end
336
+
337
+ File.rename(TEMPFILENAME, newFilename)
338
+ end
339
+
340
+ # Starting in a particular directory, attempt to find the nearest
341
+ # parent repository.
342
+ #
343
+ # returns ConfigFile, or nil
344
+ #
345
+ def self.findRepository(startDir)
346
+ bp = File.absolute_path(startDir)
347
+ while true
348
+
349
+ # Construct a ConfigFile object to determine if it already exists
350
+ cfile = ConfigFile.new(LENC_REPO_FILENAME, bp)
351
+ return cfile if cfile.exists
352
+
353
+ prev_bp = bp
354
+ bp = File.dirname(bp)
355
+ return nil if prev_bp == bp
356
+ end
357
+ end
358
+
359
+
360
+ # Parse an ignore list into a list of IgnoreEntries.
361
+ #
362
+ # @param text a script, each line of which describes a pattern
363
+ #
364
+ def self.parseIgnoreList(text, ignPath="(unknown)")
365
+
366
+ db = false
367
+
368
+ ret = []
369
+
370
+ text.split("\n").each do |ln|
371
+ begin
372
+
373
+ ln.strip!
374
+ !db || pr("...parsing line [#{ln}]...\n")
375
+
376
+ # Determine if it's a comment
377
+ if ln.start_with?("\\#")
378
+ ln = ln[1..-1]
379
+ else
380
+ if ln.start_with?("#")
381
+ ln = ""
382
+ end
383
+ end
384
+
385
+ ient = IgnoreEntry.new
386
+
387
+ # Determine if it's a negated pattern
388
+
389
+ if ln.start_with?("\\!")
390
+ ln = ln[1..-1]
391
+ else
392
+ if ln.start_with?("!")
393
+ ln = ln[1..-1]
394
+ ient.negated = true
395
+ end
396
+ end
397
+
398
+ # Determine if it should represent directories only
399
+
400
+ if ln.end_with?('/')
401
+ ient.dirOnly = true
402
+ ln = ln[0..-2]
403
+ end
404
+
405
+ next if not ln # comment or blank line, skip
406
+
407
+ # Now we see if there are any path separators in the expression.
408
+ # If so, we set pathMode.
409
+
410
+
411
+ ient.pathMode = ln.include? '/'
412
+
413
+ # Convert expression to regular expression.
414
+ #
415
+ # * => .*
416
+ # ? => .
417
+ # [...] => [...]
418
+ # [!..] => [^...]
419
+
420
+
421
+ pat = ''
422
+
423
+ inBrace = false
424
+ i = -1
425
+ while true
426
+ i += 1
427
+ break if i >= ln.size
428
+
429
+ ch = ln[i]
430
+ ch2 = (i+1 < ln.size) ? ln[i+1] : ''
431
+
432
+ if inBrace
433
+ if ch == ']'
434
+ pat << ']'
435
+ inBrace = false
436
+ next
437
+ end
438
+ else
439
+ if ch == '['
440
+ inBrace = true
441
+ pat << '['
442
+ if ch2 == '!'
443
+ i += 1
444
+ pat << '^'
445
+ end
446
+ next
447
+ end
448
+
449
+ if ch == '*'
450
+ if ient.pathMode
451
+ pat << '[^/]*'
452
+ else
453
+ pat << '.*'
454
+ end
455
+ next
456
+ end
457
+
458
+ if ch == '?'
459
+ if ient.pathMode
460
+ pat << '[^/]?'
461
+ else
462
+ pat << '.?'
463
+ end
464
+ next
465
+ end
466
+ end
467
+
468
+ if '[]'.include? ch
469
+ raise Exception, "Problem with ignore pattern"
470
+ end
471
+
472
+ if RESPECIALPAT.match(ch)
473
+ pat << '\\' << ch
474
+ next
475
+ end
476
+
477
+ pat << ch
478
+
479
+ end
480
+
481
+ ient.rexp = Regexp.new('^' + pat + '$')
482
+ ient.dbPattern = pat
483
+
484
+ ret.push(ient)
485
+ end
486
+ end
487
+ return ret
488
+ end
489
+
490
+ def reset_state
491
+ @state = STATE_CLOSED
492
+ @encrKey = nil
493
+ @encrKey2 = nil
494
+ @confFile = nil
495
+ @repoBaseDir = nil
496
+ @encrDir = nil
497
+ @version = 1.0
498
+ @orignames = false
499
+ initIgnoreList()
500
+ end
501
+
502
+ # Construct the initial ignore list.
503
+ #
504
+ # We maintain a stack of these lists, and subsequent operations can push and pop
505
+ # additional ignore lists onto this stack as it recursively descends into subdirectories.
506
+ #
507
+ def initIgnoreList
508
+ @ignoreStack = []
509
+ pushIgnoreList('', Repo.parseIgnoreList(DEFAULTIGNORE))
510
+ end
511
+
512
+ # Push a parsed ignore list onto the stack.
513
+ #
514
+ # @param directory the name of the directory, relative to its parent; '' for the outermost directory,
515
+ # or if it represents the same directory as its parent;
516
+ # should not include any path separators (/, \)
517
+ # @param expr a list of IgnoreEntries
518
+ def pushIgnoreList(directory, expr)
519
+ @ignoreStack.push([directory + '/', expr])
520
+ end
521
+
522
+ def popIgnoreList()
523
+ @ignoreStack.pop()
524
+ end
525
+
526
+ def removeTempFile()
527
+ if not @dryrun and File.file?(TEMPFILENAME)
528
+ remove_file_or_dir(TEMPFILENAME)
529
+ end
530
+ end
531
+
532
+ # Determine the secondary key, which is used for the filenames (not their contents)
533
+ # This is found by encrypting the primary key.
534
+ def prepareKeys()
535
+
536
+ key = @encrKey
537
+
538
+ en = MyAES.new(true, key, "")
539
+
540
+ en.finish(key)
541
+
542
+ key2 = en.flush()
543
+
544
+ # Skip the nonce and header portions
545
+ key2 = en.strip_encryption_header(key2)
546
+ @encrKey2 = key2
547
+ end
548
+
549
+ # Encrypt a filename.
550
+ #
551
+ # Encrypts using the alternate key, and ensures uses the hash code
552
+ # of the (unencrypted) filename as the nonce. This means the
553
+ # encrypted version of a particular filename is always the same,
554
+ # which is desirable.
555
+ #
556
+ # The security of the filename encryption is thus not as safe as that
557
+ # of the original files (since the nonces are not guaranteed to be unique),
558
+ # and is why we use a different key for them.
559
+ #
560
+ # Encrypted filenames are also given a prefix to distinguish them
561
+ # from files not created by this program (or filenames that have not been encrypted)
562
+ #
563
+ # If filenames are not encrypted in this repository, returns filename unchanged.
564
+ #
565
+ def encryptFilename(s)
566
+
567
+ db = warndb 0
568
+ !db || pr("\n\nencryptFilename %s\n",d(s))
569
+
570
+ return s if @orignames
571
+
572
+ nonce = OpenSSL::Digest::SHA1.new(s).digest
573
+ !db || pr(" SHA1 applied, nonce=%s\n",dt(nonce))
574
+ !db || hex_dump(nonce,"SHA1 nonce")
575
+
576
+ bf = MyAES.new(true, @encrKey2, nonce)
577
+ bf.finish(s)
578
+ b = bf.flush()
579
+
580
+ !db || hex_dump(b,"AES encrypted")
581
+
582
+ s3 = Base64.urlsafe_encode64(b)
583
+ !db || hex_dump(s3,"Base64 encoded")
584
+
585
+ s2 = ENCRFILENAMEPREFIX.dup
586
+
587
+ s2 << s3
588
+ !db || pr("encr fname: %s\n",d(s2))
589
+
590
+ s2
591
+ end
592
+
593
+ # Decrypt a filename suffix; raises DecryptionError if unsuccessful
594
+ def decryptFilenameAux(suffix)
595
+ db = warndb 0
596
+ !db || pr("decryptFilenameAux: %s\n",d(suffix))
597
+ begin
598
+ b = Base64.urlsafe_decode64(suffix)
599
+ !db || hex_dump(b,"after base64 decode")
600
+
601
+ bf = MyAES.new(false, @encrKey2)
602
+ bf.finish(b)
603
+
604
+ b = bf.flush()
605
+ !db || hex_dump(b,"after decrypt")
606
+
607
+ s = bytes_to_str(b)
608
+ !db || hex_dump(s,"after cvt to string (#{s})")
609
+
610
+ rescue ArgumentError => e
611
+ pr("caught exception during decrypt: %s\n",dt(e))
612
+ raise DecryptionError(e)
613
+ end
614
+
615
+ set_password_verified()
616
+
617
+ return s
618
+ end
619
+
620
+ def set_password_verified
621
+ if !@password_verified
622
+ @password_verified = true
623
+ end
624
+ end
625
+
626
+ # Decrypt a filename; if filenames are not encrypted in this repository,
627
+ # If encrypted, and failed to decrypt, returns nil
628
+ # returns unchanged
629
+ def decryptFilename(s, assumeEncrypted=true)
630
+ db = warndb 0
631
+
632
+ return s if (not assumeEncrypted) and @orignames
633
+
634
+ if not s.start_with?(ENCRFILENAMEPREFIX)
635
+ raise ArgumentError, "Encrypted filename has unexpected prefix"
636
+ end
637
+
638
+ !db || pr("decryptFilename %s\n",d(s) )
639
+ s = s[ENCRFILENAMEPREFIX.size .. -1]
640
+ decryptFilenameAux(s)
641
+ end
642
+
643
+
644
+ # Update a single source file if necessary (not a directory)
645
+ def encryptFile(sourceFile, encryptFile)
646
+
647
+ # If encrypted file is a directory, delete it
648
+ if File.directory?(encryptFile)
649
+ pth = rel_path(encryptFile, @outputDir)
650
+
651
+ if @verbosity >= 1
652
+ msg = "Encrypting file " + rel_path(sourceFile, @inputDir) + " is overwriting existing directory: " + pth
653
+ end
654
+
655
+ if not @dryrun
656
+ remove_file_or_dir(encryptFile)
657
+ end
658
+ end
659
+
660
+ # Determine if existing encrypted version exists
661
+ # and is up to date
662
+ mustUpdate = (not File.file?(encryptFile)) \
663
+ or (File.mtime(encryptFile) < File.mtime(sourceFile))
664
+
665
+ if mustUpdate
666
+ showProgress = (@verbosity >= 0)
667
+
668
+ srcDisp = rel_path(sourceFile, @inputDir)
669
+ if showProgress
670
+ pr("%s", srcDisp)
671
+ end
672
+
673
+ convertFile(sourceFile, encryptFile, true, showProgress, false)
674
+
675
+ if @verifyEncryption
676
+ # Verify that the original and decoded files are identical
677
+ convertFile(encryptFile, TEMPFILENAME, false, showProgress, true)
678
+ if !FileUtils.compare_file(sourceFile, TEMPFILENAME)
679
+ raise EncryptionVerificationException, \
680
+ "File '#{srcDisp}' did not encrypt/decrypt correctly"
681
+ end
682
+ if @verbosity >= 0
683
+ pr(" (file #{srcDisp} encrypted correctly)\n")
684
+ end
685
+ removeTempFile()
686
+ end
687
+ end
688
+ end
689
+
690
+ # Determine if a file matches one of the expressions in the ignore stack.
691
+ # Searches the stack from top to bottom (i.e., the outermost elements are examined last)
692
+ def shouldFileBeIgnored(f)
693
+ f2 = f
694
+ @ignoreStack.reverse.each do |dir,ients|
695
+ ients.each do |ient|
696
+ fArg = ient.pathMode ? f2 : f
697
+
698
+ matches = ient.rexp.match(fArg)
699
+
700
+ if matches
701
+ return !ient.negated
702
+ end
703
+ end
704
+ f2 = dir + f2
705
+ end
706
+
707
+ return false
708
+ end
709
+
710
+ # Get path relative to an absolute path
711
+ # @param path
712
+ # @param abs_path
713
+ # @return path expressed relative to abs_path
714
+ #
715
+ def rel_path(path, abs_path)
716
+ pth = Pathname.new(path)
717
+ return pth.relative_path_from(Pathname.new(abs_path)).to_s
718
+ end
719
+
720
+ # Examine all files in repository; reencrypt those that have changed
721
+ # (by comparing their time stamps with the time stamps of the encyrypted versions)
722
+ def encryptDir(sourceDir, encryptDir)
723
+ db = warndb 0
724
+ !db || pr("\n\nencryptDir\n %s =>\n %s\n",d(sourceDir),d(encryptDir))
725
+
726
+ # Add contents of .lencignore to stack. If none exists, treat as if empty
727
+ lst = []
728
+ ignPath = File.join(sourceDir, IGNOREFILENAME)
729
+ if File.file?(ignPath)
730
+ f1 = read_text_file(ignPath)
731
+ lst = Repo.parseIgnoreList(f1, ignPath)
732
+ end
733
+ pushIgnoreList(File.basename(sourceDir), lst)
734
+
735
+ # Create set of encrypted filenames that belong to this directory, so
736
+ # we can delete encrypted versions of files that are no longer in the source
737
+ # directory.
738
+ encFilenameSet = Set.new
739
+
740
+ # If no encrypted directory exists, create one
741
+ if not File.directory?(encryptDir)
742
+ if @verbosity >= 1
743
+ puts("Creating encrypted directory: " + d(rel_path(encryptDir, @outputDir)))
744
+ end
745
+
746
+ if not @dryrun
747
+ if File.file?(encryptDir)
748
+ pth = rel_path(encryptDir, @outputDir)
749
+
750
+ if @verbosity >= 1
751
+ pr("Encrypting directory is overwriting existing file: " + pth)
752
+ end
753
+
754
+ remove_file_or_dir(encryptDir)
755
+ end
756
+ Dir.mkdir(encryptDir)
757
+ end
758
+ end
759
+
760
+ # Examine each file in source dir
761
+ dirc = dir_entries(sourceDir)
762
+
763
+ dirc.each do |f2|
764
+ # Convert string to ASCII-8BIT encoding.
765
+ f = to_ascii8(f2)
766
+
767
+ ignore = shouldFileBeIgnored(f)
768
+
769
+ filePath = File.join(sourceDir,f)
770
+
771
+ if ignore
772
+ !db || pr("(ignoring %s)\n", rel_path(filePath, @inputDir)) if @verbosity >= 1
773
+ next
774
+ end
775
+
776
+ if f.start_with?(ENCRFILENAMEPREFIX)
777
+ if @verbosity >= 0
778
+ msg = "Source file/dir found with name that looks encrypted: " + d(f)
779
+ pr("(%s)\n", f)
780
+ end
781
+ end
782
+
783
+ encrName = encryptFilename(f)
784
+ !db || pr(" encrypted filename %s => %s\n",d(f),d(encrName))
785
+
786
+ if @verifyEncryption
787
+ decrName = decryptFilename(encrName, false)
788
+ if !decrName || decrName != f
789
+ !db || pr("decrName encoding=#{decrName.encoding}\n f encoding=#{f.encoding}\n")
790
+ !db || hex_dump(decrName,"decrName")
791
+ !db || hex_dump(f,"f")
792
+
793
+ raise EncryptionVerificationException, \
794
+ "Filename #{f} did not encrypt/decrypt properly"
795
+ end
796
+ pr(" (filename #{f} encrypted correctly)\n") if @verbosity >= 0
797
+ end
798
+
799
+ encFilenameSet.add(encrName)
800
+ encrPath = File.join(encryptDir, encrName)
801
+
802
+ if File.directory?(filePath)
803
+ encryptDir(filePath, encrPath)
804
+ else
805
+ encryptFile(filePath, encrPath)
806
+ end
807
+ end
808
+
809
+ # Truncate global ignore list to original length
810
+ popIgnoreList()
811
+
812
+ # Examine every file in encrypted directory; delete those that don't correspond to source dir
813
+
814
+ # (if doing dry run, encrypt dir may not exist)
815
+
816
+ if File.directory?(encryptDir)
817
+ dire = dir_entries(encryptDir)
818
+ else
819
+ dire = []
820
+ end
821
+
822
+ dire.each do |f|
823
+ next if not f.start_with?(ENCRFILENAMEPREFIX)
824
+
825
+ if not encFilenameSet.member? f
826
+ begin
827
+ orphanOrigName = decryptFilename(f)
828
+ next if !orphanOrigName
829
+ orphanPath = os.path.join(encryptDir, f)
830
+ if @verbosity >= 1
831
+ printf("Removing encrypted version of missing (or ignored) file " \
832
+ + rel_path(os.path.join(sourceDir, orphanOrigName), @inputDir) + ": " + orphanPath)
833
+ end
834
+ if !@dryrun
835
+ remove_file_or_dir(orphanPath)
836
+ end
837
+ rescue DecryptionError
838
+ # ignore...
839
+ end
840
+ end
841
+ end
842
+ end
843
+
844
+
845
+ # Decrypt all files (and, recursively, folders) within a directory to _recover folder
846
+ def recover(encryptDir, recoverDir)
847
+ db = warndb 0
848
+ !db || pr("recover enc_dir %s\n recoverDir %s\n",d(encryptDir),d(recoverDir))
849
+
850
+ # If no _recover directory exists, create one
851
+ if not File.directory?(recoverDir)
852
+ if File.file?(recoverDir)
853
+ raise RecoveryError, "Cannot replace existing file '" + recoverDir + "' with directory"
854
+ end
855
+ Dir.mkdir(recoverDir) if not @dryrun
856
+ end
857
+
858
+ if not File.directory?(encryptDir)
859
+ raise ArgumentError, "encrypt dir not found"
860
+ end
861
+
862
+ # Examine each file in encrypt dir
863
+ dirc = dir_entries(encryptDir)
864
+ !db || pr("files in encrypt dir=%s\n",d2(dirc))
865
+
866
+ dirc.each do |f|
867
+
868
+ !db || pr("...file=%s\n",d(f))
869
+ if shouldFileBeIgnored(f)
870
+ if @verbosity >= 1
871
+ pr("(ignoring %s)\n", rel_path(f, @inputDir))
872
+ end
873
+ next
874
+ end
875
+
876
+ # Only decrypt the filename if it is actually encrypted
877
+ origName = f
878
+ if origName.start_with?(ENCRFILENAMEPREFIX)
879
+ begin
880
+ decrName = decryptFilename(origName, true)
881
+ rescue DecryptionError
882
+ encPath = File.absolute_path(File.join(encryptDir,f))
883
+ pth = rel_path(encPath, @inputDir)
884
+ if !@password_verified
885
+ raise(DecryptionError,"Wrong password (cannot decrypt filename #{pth})")
886
+ end
887
+
888
+ if @verbosity >= 0
889
+ puts "Unable to decrypt filename: #{pth}"
890
+ end
891
+ next
892
+ end
893
+ origName = decrName
894
+ end
895
+ origPath = File.join(recoverDir, origName)
896
+ encrFullPath = File.join(encryptDir, f)
897
+
898
+
899
+ # If decrypted version already exists, and is more recent than the
900
+ # encrypted one, don't restore it.
901
+
902
+ if File.file?(encrFullPath) && File.file?(origPath) \
903
+ && File.mtime(origPath) >= File.mtime(encrFullPath)
904
+ if @verbosity >= 1
905
+ puts(" " + rel_path(origPath,@outputDir) + " (still valid)")
906
+ end
907
+ next
908
+ end
909
+
910
+ if File.directory?(encrFullPath)
911
+ recover(encrFullPath, origPath)
912
+ else
913
+ pth = rel_path(origPath, @outputDir)
914
+ showProgress = @verbosity >= 0
915
+ if showProgress
916
+ pr("%s", pth)
917
+ end
918
+ begin
919
+ convertFile(encrFullPath, TEMPFILENAME, false, showProgress)
920
+ set_password_verified()
921
+ if not @dryrun
922
+ if File.file?(origPath)
923
+ remove_file_or_dir(origPath)
924
+ end
925
+ renameTempFile(origPath)
926
+ end
927
+ rescue DecryptionError => e
928
+
929
+ if !@password_verified
930
+ raise(DecryptionError,"Wrong password (cannot decrypt file #{pth})")
931
+ end
932
+
933
+ if @verbosity >= 0
934
+ msg = "Unable to decrypt file: #{pth} (cause: #{e.message})"
935
+ pr("\n%s\n", msg)
936
+ end
937
+ end
938
+ end
939
+ end
940
+ end
941
+
942
+ # Encrypt or decrypt a file (not a directory)
943
+ def convertFile(srcPath, destPath, encrypt, showProgress=false, verifying=false)
944
+
945
+ db = warndb 0
946
+ !db||pr("\n\n\n\nconvertFile\n %s =>\n %s;\n %s\n",d(srcPath),d(destPath),df(encrypt,"encrypt"))
947
+
948
+ showDots = false
949
+ if not @dryrun
950
+
951
+ fSize = File.size(srcPath)
952
+
953
+ fr = File.open(srcPath, 'rb')
954
+ fw = File.open(TEMPFILENAME, 'wb')
955
+
956
+ cSize = 100000
957
+
958
+ # Predict number of chunks required
959
+ chunksRemaining = [1, (fSize / cSize.to_f + 0.5).to_i].max
960
+ dotSize = 30
961
+ !db || pr(" fSize=%d, chunksRem=%d\n",fSize,chunksRemaining)
962
+
963
+ showDots = showProgress && chunksRemaining >= dotSize
964
+ !db || (showDots = false)
965
+
966
+ if showDots and verifying
967
+ pr(" verifying:")
968
+ end
969
+
970
+ !db || pr("\n encrKey=%s\n",dt(@encrKey))
971
+
972
+ bf = MyAES.new(encrypt, @encrKey)
973
+
974
+ proc = 0
975
+
976
+ pr(" [") if showDots
977
+
978
+ last = false
979
+ while not last
980
+
981
+ if showDots and (chunksRemaining % dotSize) == 0
982
+ pr(".")
983
+ end
984
+ chunksRemaining -= 1
985
+
986
+ chunkSize = fSize - proc
987
+ last = true
988
+
989
+ if chunksRemaining > 0
990
+ last = false
991
+ chunkSize = cSize
992
+ end
993
+
994
+ proc += chunkSize
995
+ chunk = fr.read(chunkSize)
996
+
997
+ if (!chunk) or chunk.size != chunkSize
998
+ raise IOError,"Failed to read bytes from file"
999
+ end
1000
+
1001
+ bf.add(chunk)
1002
+ if last
1003
+ bf.finish()
1004
+ end
1005
+ fw.write(bf.flush())
1006
+ end
1007
+
1008
+ fr.close()
1009
+ fw.close()
1010
+
1011
+ renameTempFile(destPath)
1012
+
1013
+ pr("]") if showDots
1014
+
1015
+ end
1016
+
1017
+ if showProgress and (showDots or not verifying)
1018
+ pr("\n")
1019
+ end
1020
+ end
1021
+
1022
+ end # class Repo
1023
+
1024
+ end # module LEnc