paperback 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,38 @@
1
+ # Paperback
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/paperback.svg)](https://rubygems.org/gems/paperback)
4
+ [![Build status](https://travis-ci.org/ab/paperback.svg)](https://travis-ci.org/ab/paperback)
5
+ [![Code Climate](https://codeclimate.com/github/ab/paperback.svg)](https://codeclimate.com/github/ab/paperback)
6
+ [![Inline Docs](http://inch-ci.org/github/ab/paperback.svg?branch=master)](http://www.rubydoc.info/github/ab/paperback/master)
7
+
8
+ *Paperback* is a library that facilitates the creation of paper offline backups
9
+ of small amounts of important data, such as encryption keys.
10
+
11
+ It is designed to be used for long-term paper storage. Arbitrary data to be
12
+ backed up is encoded using QR codes and
13
+ [sixword](https://github.com/ab/sixword) English text.
14
+
15
+ By default, the backup data is GPG-encrypted with a symmetric passphrase to
16
+ avoid exposing data to the printer (or scanner, assuming you cover the
17
+ passphrase when scanning).
18
+
19
+ ## Usage
20
+
21
+ Typical usage will be through the `paperback` executable.
22
+
23
+ ```sh
24
+ # Back up the content in data.key
25
+ paperback data.key out.pdf
26
+ ```
27
+
28
+ ### More complex patterns
29
+
30
+ See the [YARD documentation](http://www.rubydoc.info/github/ab/paperback/master).
31
+
32
+ ## Contributing
33
+
34
+ 1. Fork it
35
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
36
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
37
+ 4. Push to the branch (`git push origin my-new-feature`)
38
+ 5. Create new Pull Request
@@ -0,0 +1,15 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'bundler/setup'
3
+ require 'rspec/core/rake_task'
4
+
5
+ task :default do
6
+ sh 'rake -T'
7
+ end
8
+
9
+ RSpec::Core::RakeTask.new(:spec)
10
+
11
+ def alias_task(alias_task, original)
12
+ desc "Alias for rake #{original}"
13
+ task alias_task, Rake.application[original].arg_names => original
14
+ end
15
+ alias_task(:test, :spec)
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env ruby
2
+ $VERBOSE = true
3
+
4
+ require 'optparse'
5
+ require_relative '../lib/paperback'
6
+
7
+ BaseName = File.basename($0)
8
+
9
+ def main
10
+ options = {}
11
+
12
+ optparse = OptionParser.new do |opts|
13
+ opts.banner = <<-EOM
14
+ usage: #{BaseName} [OPTION]... INPUT OUT_PDF
15
+
16
+ Create a printable PDF backup of INPUT file and write to OUT_PDF. The saved PDF
17
+ file will be suitable for printing. By default, the paper backup will contain
18
+ the input data encoded as a QR code for ease of scanning and as sixword-encoded
19
+ English text for more natural paper printing.
20
+
21
+ In order to prevent the input content from being exposed to the printer, by
22
+ default, the input content will be encrypted with a symmetric passphrase that
23
+ is echoed to the terminal. There will be a placeholder in the PDF where you can
24
+ manually add the passphrase by hand and pen.
25
+
26
+ For example:
27
+
28
+ # Create a backup of secret.key in backup.pdf
29
+ #{BaseName} secret.key backup.pdf
30
+
31
+ Options:
32
+ EOM
33
+
34
+ opts.on('-h', '--help', 'Display this message', ' ') do
35
+ STDERR.puts opts, ''
36
+ exit 0
37
+ end
38
+ opts.on('--version', 'Print version number', ' ') do
39
+ puts 'paperback ' + Paperback::VERSION
40
+ exit 0
41
+ end
42
+ opts.on('-v', '--verbose', 'Be more verbose', ' ') do
43
+ Paperback.log_level -= 1
44
+ end
45
+
46
+ #
47
+
48
+ opts.on('--no-encrypt', 'Skip encryption of input') do |val|
49
+ options[:encrypt] = val
50
+ end
51
+
52
+ opts.on('-c', '--comment TEXT', 'Add TEXT comment to output') do |val|
53
+ options[:comment] = val
54
+ end
55
+
56
+ opts.on('--passphrase-out FILE',
57
+ 'Write generated passphrase to FILE') do |val|
58
+ options[:passphrase_file] = val
59
+ end
60
+ end
61
+
62
+ optparse.parse!
63
+
64
+ case ARGV.length
65
+ when 2
66
+ options[:input] = ARGV.fetch(0)
67
+ options[:output] = ARGV.fetch(1)
68
+ else
69
+ STDERR.puts optparse, ''
70
+ exit 1
71
+ end
72
+
73
+ Paperback::CLI.create_backup(**options)
74
+ end
75
+
76
+ main
@@ -0,0 +1,32 @@
1
+ require 'logger'
2
+
3
+ # Paperback is a library for creating paper backups of sensitive data.
4
+ module Paperback
5
+ def self.log
6
+ return @log if @log
7
+ @log = Logger.new(STDERR)
8
+ @log.progname = self.name
9
+ @log.level = log_level
10
+ @log
11
+ end
12
+
13
+ def self.class_log(klass, stream=STDERR)
14
+ log = Logger.new(stream)
15
+ log.progname = klass.name
16
+ log.level = log_level
17
+ log
18
+ end
19
+
20
+ def self.log_level
21
+ @log_level ||= Logger::INFO
22
+ end
23
+
24
+ def self.log_level=(val)
25
+ @log_level = val
26
+ end
27
+ end
28
+
29
+ require_relative 'paperback/version'
30
+ require_relative 'paperback/cli'
31
+ require_relative 'paperback/document'
32
+ require_relative 'paperback/preparer'
@@ -0,0 +1,12 @@
1
+ module Paperback
2
+ module CLI
3
+ def self.create_backup(input:, output:, encrypt: true, qr_base64: true,
4
+ qr_level: :l, comment: nil, passphrase_file: nil)
5
+ prep = Paperback::Preparer.new(filename: input, encrypt: encrypt,
6
+ qr_base64: qr_base64, qr_level: qr_level,
7
+ passphrase_file: passphrase_file,
8
+ comment: comment)
9
+ prep.render(output_filename: output)
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,174 @@
1
+ require 'prawn'
2
+
3
+ # Main class for creating and rendering PDFs
4
+ module Paperback; class Document
5
+ attr_reader :pdf, :debug
6
+
7
+ def initialize(debug: false)
8
+ log.debug('Document#initialize')
9
+ @debug = debug
10
+ @pdf = Prawn::Document.new
11
+ end
12
+
13
+ def log
14
+ @log ||= Paperback.class_log(self.class)
15
+ end
16
+
17
+ def render(output_file:, draw_opts:)
18
+ log.info('Rendering PDF')
19
+
20
+ # Create all the PDF content
21
+ draw_paperback(**draw_opts)
22
+
23
+ # Render to output file
24
+ log.info("Writing PDF to #{output_file.inspect}")
25
+ pdf.render_file(output_file)
26
+ end
27
+
28
+ # High level method to draw the paperback content on the pdf document
29
+ def draw_paperback(qr_code:, sixword_lines:, sixword_bytes:,
30
+ labels:, passphrase_sha: nil, passphrase_len: nil)
31
+ unless qr_code.is_a?(RQRCode::QRCode)
32
+ raise ArgumentError.new('qr_code must be RQRCode::QRCode')
33
+ end
34
+
35
+ # Header & QR code page
36
+ pdf.font('Times-Roman')
37
+
38
+ debug_draw_axes
39
+
40
+ draw_header(labels: labels, passphrase_sha: passphrase_sha,
41
+ passphrase_len: passphrase_len)
42
+
43
+ add_newline
44
+
45
+ draw_qr_code(qr_modules: qr_code.modules)
46
+
47
+ pdf.stroke_color '000000'
48
+ pdf.fill_color '000000'
49
+
50
+ # Sixword page
51
+
52
+ pdf.start_new_page
53
+
54
+ draw_sixword(lines: sixword_lines, sixword_bytes: sixword_bytes)
55
+
56
+ pdf.number_pages('<page> of <total>', align: :right,
57
+ at: [pdf.bounds.right - 100, -2])
58
+ end
59
+
60
+ # If in debug mode, draw axes on the page to assist with layout
61
+ def debug_draw_axes
62
+ return unless debug
63
+ pdf.float { pdf.stroke_axis }
64
+ end
65
+
66
+ # Move cursor down by one line
67
+ def add_newline
68
+ pdf.move_down(pdf.font_size)
69
+ end
70
+
71
+ def draw_header(labels:, passphrase_sha:, passphrase_len:,
72
+ repo_url: 'https://github.com/ab/paperback')
73
+
74
+ intro = [
75
+ "This is a paper backup produced by `paperback`. ",
76
+ "<u><link href='#{repo_url}'>#{repo_url}</link></u>",
77
+ ].join
78
+ pdf.text(intro, inline_format: true)
79
+ add_newline
80
+
81
+ label_pad = labels.keys.map(&:length).max + 1
82
+
83
+ unless passphrase_sha && passphrase_len
84
+ labels['Encrypted'] = 'no'
85
+ end
86
+
87
+ pdf.font('Courier') do
88
+ labels.each_pair do |k, v|
89
+ pdf.text("#{(k + ':').ljust(label_pad)} #{v}")
90
+ end
91
+
92
+ if passphrase_sha
93
+ pdf.text("SHA256(passphrase)[0...16]: #{passphrase_sha}")
94
+ end
95
+ end
96
+
97
+ add_newline
98
+
99
+ if passphrase_len
100
+ pdf.font('Helvetica') do
101
+ pdf.font_size(12.8) do
102
+ pdf.text('Passphrase: ' + '_ ' * passphrase_len)
103
+ end
104
+ end
105
+
106
+ pdf.move_down(8)
107
+ pdf.indent(72) do
108
+ pdf.text('Be sure to cover the passphrase when scanning the QR code!')
109
+ end
110
+ end
111
+ end
112
+
113
+ # @param [Array<String>] lines An array of sixword sentences to print
114
+ # @param [Integer] columns The number of text columns on the page
115
+ # @param [Integer] hunks_per_row The number of 6-word sentences per line
116
+ # @param [Integer] sixword_bytes Bytesize of the sixword encoded data
117
+ def draw_sixword(lines:, sixword_bytes:, columns: 3, hunks_per_row: 1)
118
+ debug_draw_axes
119
+
120
+ numbered = lines.each_slice(hunks_per_row).each_with_index.map { |row, i|
121
+ "#{i * hunks_per_row + 1}: #{row.map(&:strip).join('. ')}"
122
+ }
123
+
124
+ header = [
125
+ "This sixword text encodes #{sixword_bytes} bytes in #{lines.length}",
126
+ " six-word sentences.",
127
+ " Decode with `sixword -d`"
128
+ ].join
129
+
130
+ pdf.font('Times-Roman') do
131
+ pdf.text(header)
132
+ add_newline
133
+ end
134
+
135
+ pdf.column_box([0, pdf.cursor], columns: columns, width: pdf.bounds.width) do
136
+ pdf.font('Times-Roman') do
137
+ pdf.font_size(11) do
138
+ pdf.text(numbered.join("\n"))
139
+ end
140
+ end
141
+ end
142
+ end
143
+
144
+ def draw_qr_code(qr_modules:)
145
+ qr_height = pdf.cursor # entire rest of page
146
+ qr_width = pdf.bounds.width # entire page width
147
+
148
+ # number of modules plus 2 for quiet area
149
+ qr_code_size = qr_modules.length + 2
150
+
151
+ pixel_height = qr_height / qr_code_size
152
+ pixel_width = qr_width / qr_code_size
153
+
154
+ pdf.bounding_box([0, pdf.cursor], width: qr_width, height: qr_height) do
155
+ if debug
156
+ pdf.stroke_color('888888')
157
+ pdf.stroke_bounds
158
+ end
159
+
160
+ qr_modules.each do |row|
161
+ pdf.move_down(pixel_height)
162
+
163
+ row.each_with_index do |pixel_val, col_i|
164
+ pdf.stroke do
165
+ pdf.stroke_color(pixel_val ? '000000' : 'ffffff')
166
+ pdf.fill_color(pixel_val ? '000000' : 'ffffff')
167
+ xy = [(col_i + 1) * pixel_width, pdf.cursor]
168
+ pdf.fill_and_stroke_rectangle(xy, pixel_width, pixel_height)
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end; end
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'digest/sha2'
5
+ require 'securerandom'
6
+
7
+ require 'rqrcode'
8
+ require 'sixword'
9
+ require 'subprocess'
10
+
11
+ module Paperback
12
+ # Class wrapping functions to prepare data for paperback storage, including
13
+ # QR code and sixword encoding.
14
+ class Preparer
15
+ attr_reader :data
16
+ attr_reader :labels
17
+ attr_reader :qr_base64
18
+ attr_reader :encrypt
19
+ attr_reader :passphrase_file
20
+
21
+ def initialize(filename:, encrypt: true, qr_base64: false, qr_level: nil,
22
+ comment: nil, passphrase_file: nil)
23
+
24
+ log.debug('Preparer#initialize')
25
+
26
+ log.info("Reading #{filename.inspect}")
27
+ plain_data = File.read(filename)
28
+
29
+ log.debug("Read #{plain_data.bytesize} bytes")
30
+
31
+ @encrypt = encrypt
32
+
33
+ if encrypt
34
+ @data = self.class.gpg_encrypt(filename: filename, password: passphrase)
35
+ else
36
+ @data = plain_data
37
+ end
38
+ @sha256 = Digest::SHA256.hexdigest(plain_data)
39
+
40
+ @qr_base64 = qr_base64
41
+ @qr_level = qr_level
42
+
43
+ @passphrase_file = passphrase_file
44
+
45
+ @labels = {}
46
+ @labels['Filename'] = filename
47
+ @labels['Backed up'] = Time.now.to_s
48
+
49
+ stat = File.stat(filename)
50
+ @labels['Mtime'] = stat.mtime
51
+ @labels['Bytes'] = plain_data.bytesize
52
+ @labels['Comment'] = comment if comment
53
+
54
+ @labels['SHA256'] = Digest::SHA256.hexdigest(plain_data)
55
+
56
+ @document = Paperback::Document.new
57
+ end
58
+
59
+ def log
60
+ @log ||= Paperback.class_log(self.class)
61
+ end
62
+ def self.log
63
+ @log ||= Paperback.class_log(self)
64
+ end
65
+
66
+ def render(output_filename:)
67
+ log.debug('Preparer#render')
68
+
69
+ opts = {
70
+ labels: labels,
71
+ qr_code: qr_code,
72
+ sixword_lines: sixword_lines,
73
+ sixword_bytes: data.bytesize,
74
+ }
75
+
76
+ if encrypt
77
+ opts[:passphrase_sha] = self.class.truncated_sha256(passphrase)
78
+ opts[:passphrase_len] = passphrase.length
79
+ if passphrase_file
80
+ File.open(passphrase_file, File::CREAT|File::EXCL|File::WRONLY,
81
+ 0400) do |f|
82
+ f.write(passphrase)
83
+ end
84
+ log.info("Wrote passphrase to #{passphrase_file.inspect}")
85
+ end
86
+ end
87
+
88
+ @document.render(output_file: output_filename, draw_opts: opts)
89
+
90
+ log.info('Render complete')
91
+
92
+ if encrypt && !passphrase_file
93
+ puts "Passphrase: #{passphrase}"
94
+ end
95
+ end
96
+
97
+ def passphrase
98
+ raise "Can't have passphrase without encrypt" unless encrypt
99
+ @passphrase ||= self.class.random_passphrase
100
+ end
101
+
102
+ PassChars = [*'a'..'z', *'A'..'Z', *'0'..'9'].freeze
103
+
104
+ def self.random_passphrase(entropy_bits: 256, char_set: PassChars)
105
+ chars_needed = (entropy_bits / Math.log2(char_set.length)).ceil
106
+ (0...chars_needed).map {
107
+ PassChars.fetch(SecureRandom.random_number(char_set.length))
108
+ }.join
109
+ end
110
+
111
+ # Compute a truncated SHA256 digest
112
+ def self.truncated_sha256(content)
113
+ Digest::SHA256.hexdigest(content)[0...16]
114
+ end
115
+
116
+ def self.gpg_encrypt(filename:, password:)
117
+ cmd = %w[
118
+ gpg -c -o - --batch --cipher-algo aes256 --passphrase-fd 0 --
119
+ ] + [filename]
120
+ out = nil
121
+
122
+ log.debug('+ ' + cmd.join(' '))
123
+ Subprocess.check_call(cmd, stdin: Subprocess::PIPE,
124
+ stdout: Subprocess::PIPE) do |p|
125
+ out, _err = p.communicate(password)
126
+ end
127
+
128
+ out
129
+ end
130
+
131
+ def self.gpg_ascii_enarmor(data, strip_comments: true)
132
+ cmd = %w[gpg --batch --enarmor]
133
+ out = nil
134
+
135
+ log.debug('+ ' + cmd.join(' '))
136
+ Subprocess.check_call(cmd, stdin: Subprocess::PIPE,
137
+ stdout: Subprocess::PIPE) do |p|
138
+ out, _err = p.communicate(data)
139
+ end
140
+
141
+ if strip_comments
142
+ out = out.each_line.select { |l| !l.start_with?('Comment: ') }.join
143
+ end
144
+
145
+ out
146
+ end
147
+
148
+ def self.gpg_ascii_dearmor(data)
149
+ cmd = %w[gpg --batch --dearmor]
150
+ out = nil
151
+
152
+ log.debug('+ ' + cmd.join(' '))
153
+ Subprocess.check_call(cmd, stdin: Subprocess::PIPE,
154
+ stdout: Subprocess::PIPE) do |p|
155
+ out, _err = p.communicate(data)
156
+ end
157
+
158
+ out
159
+ end
160
+
161
+ private
162
+
163
+ def qr_code
164
+ @qr_code ||= qr_code!
165
+ end
166
+
167
+ def qr_code!
168
+ log.info('Generating QR code')
169
+
170
+ # Base64 encode data prior to QR encoding as requested
171
+ if qr_base64
172
+ if encrypt
173
+ # If data is already GPG encrypted, use GPG's base64 armor
174
+ input = self.class.gpg_ascii_enarmor(data)
175
+ else
176
+ # Otherwise do plain Base64
177
+ input = Base64.encode64(data)
178
+ end
179
+ else
180
+ input = data
181
+ end
182
+
183
+ # If QR level not specified, pick highest level of redundancy possible
184
+ # given the size of the input, up to Q (25% redundancy)
185
+ unless @qr_level
186
+ if input.bytesize <= 1663
187
+ @qr_level = :q
188
+ elsif input.bytesize <= 2331
189
+ @qr_level = :m
190
+ else
191
+ @qr_level = :l
192
+ end
193
+ end
194
+
195
+ log.debug("qr_level: #{@qr_level.inspect}")
196
+ RQRCode::QRCode.new(input, level: @qr_level)
197
+ end
198
+
199
+ def sixword_lines
200
+ log.info('Encoding with Sixword')
201
+ @sixword_lines ||=
202
+ Sixword.pad_encode_to_sentences(data).map(&:downcase)
203
+ end
204
+
205
+ end
206
+ end