paperback 0.0.2

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,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