sia 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ebc7889507ffd61937befed163603fd261be52713a58773f383c97ea97fc2fb3
4
+ data.tar.gz: 6f28ba64af933e94ab98e9a1971465f98175962b9c54e801e678aad516a6900e
5
+ SHA512:
6
+ metadata.gz: 81982428ae81e1959808ca11aa9656b663f4f401e70b5950f9f9cfcb03a8fcb72c1e709e43f2cbcb57230abade888ce4110f0a6922189d58c426a147bc5c227c
7
+ data.tar.gz: 74ee62469206fce29bbb3489d7bf68adb03c2542b89d336407e8991f294d6500935fd07007bd03b00ad4bf08a30aea79231fd594f81c5fa72d7ecc26e9114d08
@@ -0,0 +1,57 @@
1
+ # Ruby CircleCI 2.0 configuration file
2
+ #
3
+ # Check https://circleci.com/docs/2.0/language-ruby/ for more details
4
+ #
5
+ version: 2
6
+ jobs:
7
+ build:
8
+ docker:
9
+ # specify the version you desire here
10
+ - image: circleci/ruby:2.5.1
11
+
12
+ # Specify service dependencies here if necessary
13
+ # CircleCI maintains a library of pre-built images
14
+ # documented at https://circleci.com/docs/2.0/circleci-images/
15
+ # - image: circleci/postgres:9.4
16
+
17
+ working_directory: ~/repo
18
+
19
+ steps:
20
+ - checkout
21
+
22
+ # Download and cache dependencies
23
+ - restore_cache:
24
+ keys:
25
+ - v1-dependencies-{{ checksum "Gemfile.lock" }}
26
+ # fallback to using the latest cache if no exact match is found
27
+ - v1-dependencies-
28
+
29
+ - run:
30
+ name: install dependencies
31
+ command: |
32
+ bundle install --jobs=4 --retry=3 --path vendor/bundle
33
+
34
+ - save_cache:
35
+ paths:
36
+ - ./vendor/bundle
37
+ key: v1-dependencies-{{ checksum "Gemfile.lock" }}
38
+
39
+ # run tests!
40
+ - run:
41
+ name: run tests
42
+ command: |
43
+ mkdir /tmp/test-results
44
+ TEST_FILES="$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)"
45
+
46
+ bundle exec rspec --format progress \
47
+ --format RspecJunitFormatter \
48
+ --out /tmp/test-results/rspec.xml \
49
+ --format progress \
50
+ $TEST_FILES
51
+
52
+ # collect reports
53
+ - store_test_results:
54
+ path: /tmp/test-results
55
+ - store_artifacts:
56
+ path: /tmp/test-results
57
+ destination: test-results
@@ -0,0 +1,11 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+
10
+ # rspec failure tracking
11
+ .rspec_status
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_config
@@ -0,0 +1,5 @@
1
+ sudo: false
2
+ language: ruby
3
+ rvm:
4
+ - 2.5.1
5
+ before_install: gem install bundler -v 1.16.1
@@ -0,0 +1,2 @@
1
+ --markup markdown
2
+ --no-private
@@ -0,0 +1,75 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ In the interest of fostering an open and welcoming environment, we as
6
+ contributors and maintainers pledge to making participation in our project and
7
+ our community a harassment-free experience for everyone, regardless of age, body
8
+ size, disability, ethnicity, gender identity and expression, level of
9
+ experience, nationality, personal appearance, race, religion, or sexual identity
10
+ and orientation.
11
+
12
+ ## Our Standards
13
+
14
+ Examples of behavior that contributes to creating a positive environment
15
+ include:
16
+
17
+ * Using welcoming and inclusive language
18
+ * Being respectful of differing viewpoints and experiences
19
+ * Gracefully accepting constructive criticism
20
+ * Focusing on what is best for the community
21
+ * Showing empathy towards other community members
22
+
23
+ Examples of unacceptable behavior by participants include:
24
+
25
+ * The use of sexualized language or imagery and unwelcome sexual attention or
26
+ advances
27
+ * Trolling, insulting/derogatory comments, and personal or political attacks
28
+ * Public or private harassment
29
+ * Publishing others' private information, such as a physical or electronic
30
+ address, without explicit permission
31
+ * Other conduct which could reasonably be considered inappropriate in a
32
+ professional setting
33
+
34
+ ## Our Responsibilities
35
+
36
+ Project maintainers are responsible for clarifying the standards of acceptable
37
+ behavior and are expected to take appropriate and fair corrective action in
38
+ response to any instances of unacceptable behavior.
39
+
40
+ Project maintainers have the right and responsibility to remove, edit, or
41
+ reject comments, commits, code, wiki edits, issues, and other contributions
42
+ that are not aligned to this Code of Conduct, or to ban temporarily or
43
+ permanently any contributor for other behaviors that they deem inappropriate,
44
+ threatening, offensive, or harmful.
45
+
46
+ ## Scope
47
+
48
+ This Code of Conduct applies both within project spaces and in public spaces
49
+ when an individual is representing the project or its community. Examples of
50
+ representing a project or community include using an official project e-mail
51
+ address, posting via an official social media account, or acting as an appointed
52
+ representative at an online or offline event. Representation of a project may be
53
+ further defined and clarified by project maintainers.
54
+
55
+ ## Enforcement
56
+
57
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
58
+ reported by contacting the project team at jc.spencer92@gmail.com. All
59
+ complaints will be reviewed and investigated and will result in a response that
60
+ is deemed necessary and appropriate to the circumstances. The project team is
61
+ obligated to maintain confidentiality with regard to the reporter of an
62
+ incident. Further details of specific enforcement policies may be posted
63
+ separately.
64
+
65
+ Project maintainers who do not follow or enforce the Code of Conduct in good
66
+ faith may face temporary or permanent repercussions as determined by other
67
+ members of the project's leadership.
68
+
69
+ ## Attribution
70
+
71
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage],
72
+ version 1.4, available at [http://contributor-covenant.org/version/1/4][version]
73
+
74
+ [homepage]: http://contributor-covenant.org
75
+ [version]: http://contributor-covenant.org/version/1/4/
data/Gemfile ADDED
@@ -0,0 +1,6 @@
1
+ source "https://rubygems.org"
2
+
3
+ git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }
4
+
5
+ # Specify your gem's dependencies in sia.gemspec
6
+ gemspec
@@ -0,0 +1,44 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ sia (0.1.0)
5
+
6
+ GEM
7
+ remote: https://rubygems.org/
8
+ specs:
9
+ coderay (1.1.2)
10
+ diff-lcs (1.3)
11
+ method_source (0.9.0)
12
+ pry (0.11.3)
13
+ coderay (~> 1.1.0)
14
+ method_source (~> 0.9.0)
15
+ rake (10.5.0)
16
+ rspec (3.7.0)
17
+ rspec-core (~> 3.7.0)
18
+ rspec-expectations (~> 3.7.0)
19
+ rspec-mocks (~> 3.7.0)
20
+ rspec-core (3.7.1)
21
+ rspec-support (~> 3.7.0)
22
+ rspec-expectations (3.7.0)
23
+ diff-lcs (>= 1.2.0, < 2.0)
24
+ rspec-support (~> 3.7.0)
25
+ rspec-mocks (3.7.0)
26
+ diff-lcs (>= 1.2.0, < 2.0)
27
+ rspec-support (~> 3.7.0)
28
+ rspec-support (3.7.1)
29
+ rspec_junit_formatter (0.3.0)
30
+ rspec-core (>= 2, < 4, != 2.12.0)
31
+
32
+ PLATFORMS
33
+ ruby
34
+
35
+ DEPENDENCIES
36
+ bundler (~> 1.16)
37
+ pry (~> 0.11)
38
+ rake (~> 10.0)
39
+ rspec (~> 3.0)
40
+ rspec_junit_formatter (~> 0.3.0)
41
+ sia!
42
+
43
+ BUNDLED WITH
44
+ 1.16.1
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2018 Spencer Christiansen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
@@ -0,0 +1,53 @@
1
+ # Sia
2
+
3
+ [![CircleCI](https://circleci.com/gh/spejamchr/sia.svg?style=shield)](
4
+ https://circleci.com/gh/spejamchr/sia)
5
+ [![GitHub](https://img.shields.io/badge/github-spejamchr/sia-blue.svg)](
6
+ https://github.com/spejamchr/sia)
7
+ [![Documentation](https://img.shields.io/badge/docs-rubydoc.org-blue.svg)](
8
+ https://www.rubydoc.info/github/spejamchr/sia)
9
+
10
+ Encrypt files by storing them in digital safes. Each safe has a name and a
11
+ password and can store as many files as you want.
12
+
13
+ ## Installation
14
+
15
+ Add this line to your application's Gemfile:
16
+
17
+ ```ruby
18
+ gem 'sia'
19
+ ```
20
+
21
+ And then execute:
22
+
23
+ $ bundle
24
+
25
+ Or install it yourself as:
26
+
27
+ $ gem install sia
28
+
29
+ ## Usage
30
+
31
+ TODO: Write usage instructions here
32
+
33
+ ## Development
34
+
35
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run
36
+ `rake spec` to run the tests. You can also run `bin/console` for an interactive
37
+ prompt that will allow you to experiment.
38
+
39
+ To install this gem onto your local machine, run `bundle exec rake install`. To
40
+ release a new version, update the version number in `version.rb`, and then run
41
+ `bundle exec rake release`, which will create a git tag for the version, push
42
+ git commits and tags, and push the `.gem` file to
43
+ [rubygems.org](https://rubygems.org).
44
+
45
+ ## Contributing
46
+
47
+ Bug reports and pull requests are welcome on GitHub at
48
+ https://github.com/spejamchr/sia.
49
+
50
+ ## License
51
+
52
+ The gem is available as open source under the terms of the [MIT
53
+ License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "sia"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ require "pry"
10
+ Pry.start
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,114 @@
1
+ require 'yaml'
2
+ require 'securerandom'
3
+ require 'openssl'
4
+ require 'base64'
5
+
6
+ require 'sia/version'
7
+ require 'sia/error'
8
+ require 'sia/configurable'
9
+ require 'sia/persisted_config'
10
+
11
+ # Encrypt files with digital safes
12
+ #
13
+ module Sia
14
+
15
+ class << self
16
+
17
+ include Configurable
18
+
19
+ # Configure Sia, returning the final options
20
+ #
21
+ # Sia.config(
22
+ # root_dir: '/path/to/the/safes/',
23
+ # index_name: 'my_index',
24
+ # buffer_bytes: 2048,
25
+ # )
26
+ # # => {:root_dir=>"/path/to/the/safes/", :index_name=>"my_index", ...}
27
+ #
28
+ # Allows partial or piecemeal configuration.
29
+ #
30
+ # Sia.options
31
+ # # => {:root_dir=>"~/.sia_safes"", :index_name=>".sia_index", ...}
32
+ #
33
+ # Sia.config(root_dir: '/new_dir')
34
+ # # => {:root_dir=>"/new_dir", :index_name=>".sia_index", ...}
35
+ #
36
+ # Sia.config(index_name: 'my_index')
37
+ # # => {:root_dir=>"/new_dir", :index_name=>"my_index", ...}
38
+ #
39
+ # See {Sia::Configurable::DEFAULTS} for all available options.
40
+ #
41
+ # @see Configurable::DEFAULTS
42
+ #
43
+ # @param [Hash] opt
44
+ # @return [Hash]
45
+ #
46
+ def config(**opt)
47
+ @options.merge!(clean_options(opt))
48
+ options
49
+ end
50
+
51
+ # Persist the current Sia-wide options
52
+ #
53
+ # The next time Sia is loaded it will use the current config values.
54
+ # Consequently, all new safes will use the current configuration as
55
+ # defaults.
56
+ #
57
+ def persist!
58
+ PersistedConfig.new.persist(options)
59
+ end
60
+
61
+ # Reset the options to default and return the options
62
+ #
63
+ # Sia.config(root_dir: '/hi', index_name: 'there')
64
+ # # => {:root_dir=>'/hi', :index_name=>'there', ...}
65
+ # Sia.set_default_options!
66
+ # # => {:root_dir=>"~/.sia_safes", :index_name=>".sia_index", ...}
67
+ #
68
+ # With arguments, resets only the option(s) provided
69
+ #
70
+ # Sia.config(root_dir: '/hi', index_name: 'there')
71
+ # # => {:root_dir=>'/hi', :index_name=>'there', ...}
72
+ # Sia.set_default_options!(:index_name)
73
+ # # => {:root_dir=>'/hi', :index_name=>".sia_index", ...}
74
+ #
75
+ # Optionally takes a `:source` keyword argument that determines whether the
76
+ # default values should be taken from the gem or from the persisted config.
77
+ # Values should be either `:persisted` or `:gem`. Default is `:persisted`.
78
+ # If there is no persisted config the gem defaults are used.
79
+ #
80
+ # @note This change is not persisted.
81
+ #
82
+ # @param [Array<Symbol>] specifics Optionally reset only specific options
83
+ # @param [:persisted|:gem] source Load defaults from gem or persisted config
84
+ #
85
+ # @return [Hash] The new options
86
+ #
87
+ def set_default_options!(*specifics, source: :persisted)
88
+ specifics = DEFAULTS.keys if specifics.empty?
89
+ keepers = (@options || {}).slice(*DEFAULTS.keys - specifics)
90
+ @options = defaults(source).merge(keepers)
91
+ options
92
+ end
93
+
94
+ private
95
+
96
+ # Used by Sia::Configurable
97
+ #
98
+ # @param [:persisted|:gem] source Load defaults from gem or persisted config
99
+ #
100
+ def defaults(source=:persisted)
101
+ case source
102
+ when :persisted
103
+ PersistedConfig.new.options
104
+ when :gem
105
+ DEFAULTS
106
+ else
107
+ raise "Unrecognized source: #{source.inspect}, must be :source or :gem"
108
+ end
109
+ end
110
+ end # class << self
111
+ end
112
+
113
+ require 'sia/lock'
114
+ require 'sia/safe'
@@ -0,0 +1,175 @@
1
+ # Sia-wide and Safe-specific configuration
2
+ #
3
+ # Any Sia-wide custom configuration is passed along to new safes.
4
+ #
5
+ # Sia.options
6
+ # # => {:root_dir=>"~/.sia_safes", :index_name=>".sia_index", ...}
7
+ # Sia.config(root_dir: '/custom/dir')
8
+ # # => {:root_dir=>"/custom/dir", :index_name=>".sia_index", ...}
9
+ # Sia::Safe.new(name: 'test', password: 'secret').options
10
+ # # => {:root_dir=>"/custom/dir", :index_name=>".sia_index", ...}
11
+ #
12
+ # Safes can only be configured at creation. There is intentionally no API for
13
+ # configuring safes that already exist.
14
+ #
15
+ # safe = Sia::Safe.new(name: 'test', password: 'secret', index_name: 'hi')
16
+ # safe.options
17
+ # # => {:root_dir=>"/custom/dir", :index_name=>"hi", ...}
18
+ #
19
+ # Hmm... the `:root_dir` option is still set to our custom directory before, but
20
+ # we don't want to use that anymore. We could manually reset it back to its
21
+ # default, or we can just use {Sia.set_default_options!}.
22
+ #
23
+ # Sia.set_default_options!(:root_dir)
24
+ # safe = Sia::Safe.new(name: 'test', password: 'secret', index_name: 'hi')
25
+ # safe.options
26
+ # # => {:root_dir=>"~/.sia_safes", :index_name=>"hi", ...}
27
+ #
28
+ module Sia::Configurable
29
+
30
+ # Configuration defaults for Sia as a whole and for individual safes
31
+ #
32
+ # `:root_dir` - The directory holding all the safes. Within this directory,
33
+ # each safe will have its own directory
34
+ #
35
+ # `:index_name` - The name of the encrypted index file within the safe
36
+ # directory. It hold information like which files are in the safe and when
37
+ # they were last opened/closed.
38
+ #
39
+ # `:salt_name` - The name of the file within the safe directory that holds the
40
+ # salt string.
41
+ #
42
+ # `:digest_iterations` - Changes how long computing the symmetric key from the
43
+ # password will take. The longer the computation takes, the harder for
44
+ # someone to break into the safe.
45
+ #
46
+ # `:buffer_bytes` - The buffer size to use when reading/writing files.
47
+ #
48
+ # `:in_place` - If true, closed files will be encrypted where they are with
49
+ # a Sia file extension attached to the name. If false, closed files will
50
+ # be moved to the safe dir and renamed to a url-safe hash.
51
+ #
52
+ # `:extension` - In-place safes will attach this extension to closed files.
53
+ # Ignored unless `:in_place` is truthy. Can include the period or not (so
54
+ # `'.thing'` and `'thing'` will both work the same).
55
+ #
56
+ # `:portable` - If true, all clear files must be children of the safe dir.
57
+ # Useful if the safe will be shared.
58
+ #
59
+ DEFAULTS = {
60
+ root_dir: Pathname(Dir.home) / '.sia_safes',
61
+ index_name: '.sia_index',
62
+ salt_name: '.sia_salt',
63
+ digest_iterations: 200_000,
64
+ buffer_bytes: 512,
65
+ in_place: false,
66
+ extension: '.sia_closed',
67
+ portable: false,
68
+ }.freeze
69
+
70
+ # The configuration options
71
+ #
72
+ # @return [Hash]
73
+ #
74
+ def options
75
+ (@options ||= defaults).transform_values(&:dup)
76
+ end
77
+
78
+ private
79
+
80
+ def persisted_config
81
+ Sia::PERSISTED_CONFIG.exist? ?
82
+ (YAML.load(Sia::PERSISTED_CONFIG.read) || {}) : {}
83
+ end
84
+
85
+ def clean_options(opt)
86
+ opt = opt.dup
87
+ illegals = opt.keys - DEFAULTS.keys
88
+ unless illegals.empty?
89
+ raise Sia::Error::InvalidOptionError.new(illegals, DEFAULTS.keys)
90
+ end
91
+
92
+ tentatives = options.merge(opt)
93
+ if tentatives[:index_name] == tentatives[:salt_name]
94
+ raise Sia::Error::ConfigurationError,
95
+ ":index_name and :salt_name cannot be equal, but were both " +
96
+ tentatives[:salt_name].inspect
97
+ end
98
+
99
+ validation_for(opt) do
100
+ safe_path :root_dir
101
+ safe_filename :index_name, :salt_name, :extension
102
+
103
+ convert(:root_dir) { |v| Pathname(v).expand_path }
104
+ convert(:index_name, :salt_name) { |v| v.to_s }
105
+ convert(:digest_iterations, :buffer_bytes) { |v| v.to_i }
106
+ convert(:in_place, :portable) { |v| !!v }
107
+ convert(:extension) { |v| ".#{v.to_s.reverse.chomp('.').reverse}" }
108
+ end
109
+
110
+ opt
111
+ end
112
+
113
+ def validation_for(opt, &block)
114
+ Validator.new(opt).instance_eval(&block).done
115
+ end
116
+
117
+ # Validate the options
118
+ # @private
119
+ class Validator
120
+
121
+ SAFE_PATH_REGEX = /\A[A-Za-z0-9\._-]+\z/
122
+
123
+ def initialize(opt)
124
+ @opt = opt
125
+ @converted = []
126
+ end
127
+
128
+ def safe_path(*keys)
129
+ (keys & @opt.keys).each do |k|
130
+ if @opt[k].to_s.empty?
131
+ raise Sia::Error::ConfigurationError, "#{k.inspect} must not be empty"
132
+ end
133
+
134
+ slash = File::SEPARATOR
135
+ path = @opt[k].to_s.chomp(slash).reverse.chomp(slash).reverse
136
+ path.split(slash).all? { |s| portable_file(s) }
137
+ end
138
+ end
139
+
140
+ def safe_filename(*keys)
141
+ (keys & @opt.keys).each { |k| portable_file(@opt[k].to_s) }
142
+ end
143
+
144
+ def convert(*keys, &block)
145
+ @converted += keys
146
+ (keys & @opt.keys).each do |k|
147
+ @opt[k] = yield @opt[k]
148
+ rescue NoMethodError => nme
149
+ raise Sia::Error::ConfigurationError,
150
+ "#{k.inspect} was #{nme.args} and could not be converted using " +
151
+ "`#{nme.name.inspect}`"
152
+ end
153
+ self
154
+ end
155
+
156
+ def portable_file(string)
157
+ return if string =~ SAFE_PATH_REGEX
158
+ raise Sia::Error::ConfigurationError,
159
+ "Filenames must match the regex #{SAFE_PATH_REGEX.inspect}, " +
160
+ "but was #{string.inspect}"
161
+ end
162
+
163
+ # Make sure Sia converts each option exactly one time. Any time a new
164
+ # option is added, it needs to be converted.
165
+ def done
166
+ return if DEFAULTS.keys.sort == @converted.sort
167
+
168
+ bads = DEFAULTS.transform_values { 0 }.merge(
169
+ @converted.group_by(&:itself).transform_values(&:count)
170
+ ).select { |k, v| v != 1 }
171
+
172
+ raise "Options were not converted exactly once! #{bads}"
173
+ end
174
+ end # class Validator
175
+ end # module Sia::Configurable
@@ -0,0 +1,38 @@
1
+ module Sia
2
+ # Sia-specific errors live here
3
+ #
4
+ # To catch any Sia error, just rescue `Sia::Error`.
5
+ #
6
+ # begin
7
+ # # Code stuffs
8
+ # rescue Sia::Error
9
+ # # Handle the exceptiom
10
+ # end
11
+ #
12
+ class Error < StandardError
13
+
14
+ # Raised when creating a safe with the incorrect password
15
+ class PasswordError < Error; end
16
+
17
+ # Raised when attempting to set bad configuration
18
+ class ConfigurationError < Error; end
19
+
20
+ # Raised when trying to set invalid option(s)
21
+ class InvalidOptionError < ConfigurationError
22
+ def initialize(invalids, available)
23
+ msg = <<~MSG
24
+ Got invalid option(s):
25
+ #{invalids.map(&:inspect).join("\n ")}
26
+ Available options:
27
+ #{available.map(&:inspect).join("\n ")}
28
+ MSG
29
+
30
+ super(msg)
31
+ end
32
+ end
33
+
34
+ # Raised with portable safes when trying to close a file not in the safe_dir
35
+ class FileOutsideScopeError < Error; end
36
+
37
+ end
38
+ end
@@ -0,0 +1,137 @@
1
+ module Sia
2
+ # Every good safe needs a safe lock
3
+ #
4
+ # Used by Sia::Safe to do the heavy cryptographical lifting
5
+ #
6
+ # * Securely derives its symmetric key from a user's password using
7
+ # OpenSSL::PKCS5
8
+ # * Uses an OpenSSL::Cipher for encryption
9
+ #
10
+ # Ex:
11
+ #
12
+ # lock = Sia::Lock.new('pass', 'salt', 1_000, 1_000_000)
13
+ # lock.encrypt_to_file('Hello World!', '/path/to/secure/file')
14
+ # File.read('/path/to/secure/file')
15
+ # # => "\u0016\x8A\x88/%\x90\xDF\u007F\xFC@\xCB\t\u001FTp`(\xBF\x8DR\x9E\x91\x8F\xC1FX\x8F7\xF6-+2"
16
+ # lock.decrypt_from_file('/path/to/secure/file')
17
+ # # => "Hello World!"
18
+ #
19
+ class Lock
20
+
21
+ # The digest to use. Safes use the length of the digest to make the salt.
22
+ DIGEST = OpenSSL::Digest::SHA256
23
+
24
+ # @param [String] password
25
+ # @param [String] salt
26
+ # @param [Integer] buffer_bytes The buffer size for reading/writing to file.
27
+ # @param [Integer] digest_iterations Increase this to increase hashing time.
28
+ # @return [Lock]
29
+ #
30
+ def initialize(password, salt, buffer_bytes, digest_iterations)
31
+ @buffer_bytes = buffer_bytes
32
+
33
+ # Don't let the symmetric_key accidentally show up in logs or in the
34
+ # console. Instead of storing it in an instance variable store it in a
35
+ # private method.
36
+ key = digest_password(password, salt, digest_iterations)
37
+ define_singleton_method(:symmetric_key) { key }
38
+ self.singleton_class.send(:private, :symmetric_key)
39
+ end
40
+
41
+ # Used for encrypting the index file from memory
42
+ #
43
+ # @param [Pathname] string The string to encrypt
44
+ # @param [Pathname] secure Absolute path to the secure file
45
+ #
46
+ def encrypt_to_file(string, secure)
47
+ secure.open('wb') { |s| basic_encrypt(StringIO.new(string), s) }
48
+ end
49
+
50
+ # Used for decrypting the index file into memory
51
+ #
52
+ # @param [Pathname] secure Absolute path to the secure file
53
+ # @return [String]
54
+ #
55
+ def decrypt_from_file(secure)
56
+ secure.open('rb') { |s| basic_decrypt(StringIO.new, s).string }
57
+ end
58
+
59
+ # Encrypt a clear file into a secure file, removing the clear file.
60
+ #
61
+ # This is better set up to handle large amounts of data than
62
+ # {#encrypt_to_file}, which has to hold the entire clear string in memory.
63
+ #
64
+ # @param [Pathname] clear Absolute path to the clear file.
65
+ # @param [Pathname] secure Absolute path to the secure file.
66
+ #
67
+ def encrypt(clear, secure)
68
+ clear.open('rb') { |c| secure.open('wb') { |s| basic_encrypt(c, s) } }
69
+ clear.delete
70
+ end
71
+
72
+ # Decrypt a secure file into a clear file, removing the secure file.
73
+ #
74
+ # This is better set up to handle large amounts of data than
75
+ # {#decrypt_from_file}, which has to hold the entire clear string in memory.
76
+ #
77
+ # @param [Pathname] clear Absolute path to the clear file.
78
+ # @param [Pathname] secure Absolute path to the secure file.
79
+ #
80
+ def decrypt(clear, secure)
81
+ clear.open('wb') { |c| secure.open('rb') { |s| basic_decrypt(c, s) } }
82
+ secure.delete
83
+ end
84
+
85
+ private
86
+
87
+ def new_cipher
88
+ OpenSSL::Cipher.new('AES-256-CBC')
89
+ end
90
+
91
+ # Get a password digest from the password
92
+ #
93
+ # @param [String] password The password to digest
94
+ # @return [String] The digested password, a binary string
95
+ #
96
+ def digest_password(password, salt, iter)
97
+ len = DIGEST.new.digest_length
98
+ OpenSSL::PKCS5.pbkdf2_hmac(password, salt, iter, len, DIGEST.new)
99
+ end
100
+
101
+ def basic_encrypt(clear_io, secure_io)
102
+ cipher = new_cipher.encrypt
103
+ cipher.key = symmetric_key
104
+ iv = cipher.random_iv
105
+ cipher.iv = iv
106
+
107
+ secure_io << iv
108
+ until clear_io.eof?
109
+ secure_io << cipher.update(clear_io.read(@buffer_bytes))
110
+ end
111
+ secure_io << cipher.final
112
+ end
113
+
114
+ def basic_decrypt(clear_io, secure_io)
115
+ decipher = new_cipher.decrypt
116
+ decipher.key = symmetric_key
117
+ first_block = true
118
+
119
+ until secure_io.eof?
120
+ if first_block
121
+ decipher.iv = secure_io.read(decipher.iv_len)
122
+ first_block = false
123
+ else
124
+ clear_io << decipher.update(secure_io.read(@buffer_bytes))
125
+ end
126
+ end
127
+
128
+ clear_io << decipher.final
129
+
130
+ clear_io
131
+
132
+ rescue OpenSSL::Cipher::CipherError
133
+ raise Sia::Error::PasswordError, 'Invalid password'
134
+ end
135
+
136
+ end # class Lock
137
+ end # module Sia
@@ -0,0 +1,75 @@
1
+ module Sia
2
+ # Sia-wide and safe-specific persisted config
3
+ #
4
+ # Sia can read and write Sia-wide persisted config, but can't read or write
5
+ # any safe-specific config. Safes can read Sia-wide config, and can read and
6
+ # write their own config, but can't access other safes' configs.
7
+ #
8
+ class PersistedConfig
9
+
10
+ PATH = Pathname(Dir.home) / '.sia_config'
11
+
12
+ # Provide a name for safe-specific access. Call w/o args for Sia-wide access
13
+ #
14
+ # @param [#to_sym|nil] name The name of the safe to be given access.
15
+ # Leave blank if Sia is to be given access.
16
+ #
17
+ def initialize(name=nil)
18
+ @access_key = name ? :"safe #{name}" : :sia
19
+ end
20
+
21
+ # Persist some options into the persisted entry
22
+ #
23
+ # An attempt is made to not over-write things on accident by merging the
24
+ # previously persisted options with Sia's defaults, and merging the provided
25
+ # options into the result. This way, providing partial updates to the
26
+ # options won't over-write all the other options, and if new options are
27
+ # added to the gem later they will be effortlessly merged into the persisted
28
+ # config without having to pass them explicitly.
29
+ #
30
+ # @param [Hash] opt The options to persist
31
+ #
32
+ def persist(opt)
33
+ opt = Configurable::DEFAULTS.merge(options).merge(opt)
34
+
35
+ whole_hash.merge!(@access_key => opt)
36
+
37
+ PATH.write(YAML.dump(whole_hash))
38
+ end
39
+
40
+ # Load the persisted options from the persisted entry
41
+ #
42
+ def options
43
+ entry = whole_hash.fetch(@access_key) do
44
+ @access_key == :sia ? {} : Sia.options
45
+ end
46
+
47
+ Configurable::DEFAULTS.merge(entry)
48
+ end
49
+
50
+ def exist?
51
+ whole_hash.has_key?(@access_key)
52
+ end
53
+
54
+ def delete
55
+ return unless exist?
56
+
57
+ whole_hash.delete(@access_key)
58
+
59
+ PATH.write(YAML.dump(whole_hash))
60
+ end
61
+
62
+ def refresh
63
+ @whole_hash = PATH.file? ? YAML.load(PATH.read) : {}
64
+ nil
65
+ end
66
+
67
+ private
68
+
69
+ # The entire persisted config hash, without access limitations
70
+ #
71
+ def whole_hash
72
+ @whole_hash || refresh || @whole_hash
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,293 @@
1
+ module Sia
2
+ # Keep all the files safe
3
+ #
4
+ # Encrypt files and store them in a digital safe. Have one safe for
5
+ # everything, or use individual safes for each file to be encrypted.
6
+ #
7
+ # When creating a safe provide at least a name and a password, and the
8
+ # defaults will take care of the rest.
9
+ #
10
+ # safe = Sia::Safe.new(name: 'test', password: 'secret')
11
+ #
12
+ # With a safe in hand, {close} an existing file to keep it safe. (Note, any
13
+ # type of file can be closed, not just `.txt` files.)
14
+ #
15
+ # safe.close('~/secret.txt')
16
+ #
17
+ # The file will not longer be present at `/path/to/the/secret.txt`; instead,
18
+ # it will now be encrypted in the default Sia directory with a new name.
19
+ # Restore it by using {open}.
20
+ #
21
+ # safe.open('~/secret.txt')
22
+ #
23
+ # Notice that {open} requires the path (relative or absolute) to the file as
24
+ # it existed before being encrypted, even though there's no file at that
25
+ # location anymore. To see all files available to open in the safe, take a
26
+ # peak in the {index}.
27
+ #
28
+ # pp safe.index
29
+ # {:files=>
30
+ # {"/Users/spencer/secret.txt"=>
31
+ # {:secure_file=>"0nxntvTLteCTZ8cmZdX848gGaYHRAOHqir-1RuJ-n-E",
32
+ # :last_closed=>2018-04-29 19:58:24 -0600,
33
+ # :safe=>true}}}
34
+ #
35
+ # The {fill} and {empty} methods are also helpful. {fill} will close all files
36
+ # that belong to the safe, and {empty} will open all the files.
37
+ #
38
+ # safe.fill
39
+ # safe.empty
40
+ #
41
+ # Finally, if the safe has outlived its usefulness, {delete} is there to help.
42
+ # {delete} will remove a safe as-is, without opening or closing any files.
43
+ # This means that **all currently closed files will be lost** when using
44
+ # {delete}.
45
+ #
46
+ # safe.delete
47
+ #
48
+ # FYI, the safe directory for this example has the structure:
49
+ #
50
+ # ~/
51
+ # └── .sia_safes/
52
+ # └── test/
53
+ # ├── .sia_index
54
+ # ├── .sia_salt
55
+ # └── 0nxntvTLteCTZ8cmZdX848gGaYHRAOHqir-1RuJ-n-E
56
+ #
57
+ # The `.sia_safes/` directory holds all the safes, in this case the `test`
58
+ # safe. Its name and location can be customized using {Configurable}. The
59
+ # `test/` directory where the `test` safe lives. `.sia_index` is an encrypted
60
+ # file that stores information about the safe. Its name cam be customized:
61
+ # {Configurable}. The `.sia_salt` file stores the salt used to make a good
62
+ # symmetric key out of the password. Its name cam be customized:
63
+ # {Configurable}. The last file,
64
+ # `0nxntvTLteCTZ8cmZdX848gGaYHRAOHqir-1RuJ-n-E`, is the newly encrypted file.
65
+ # Its name is a `SHA256` digest of the full pathname of the clearfile (in this
66
+ # case, `"/Users/spencer/secret.txt"`) encoded in url-safe base 64 without
67
+ # padding (ie, not ending `'='`).
68
+ #
69
+ class Safe
70
+
71
+ include Sia::Configurable
72
+
73
+ attr_reader :name
74
+
75
+ # @param [#to_sym] name
76
+ # @param [#to_s] password
77
+ # @param [Hash] opt Configure new safes as shown in {Configurable}.
78
+ # When instantiating existing safes, configuration here must match the
79
+ # persisted config, or be absent.
80
+ # @return [Safe]
81
+ #
82
+ def initialize(name:, password:, **opt)
83
+ @name = name.to_sym
84
+ @persisted_config = PersistedConfig.new(@name)
85
+
86
+ options # Initialize the options with defaults
87
+ assign_options(opt)
88
+
89
+ @lock = Lock.new(
90
+ password.to_s,
91
+ salt,
92
+ options[:buffer_bytes],
93
+ options[:digest_iterations]
94
+ )
95
+
96
+ # Don't let initialization succeed if the password was invalid
97
+ index
98
+ end
99
+
100
+ # Persist the safe and its configuration
101
+ #
102
+ # This doesn't have any effect once a file has been closed in the safe.
103
+ #
104
+ def persist!
105
+ return if @persisted_config.exist?
106
+
107
+ safe_dir.mkpath unless safe_dir.directory?
108
+ salt_path.write(salt) unless salt_path.file?
109
+
110
+ @persisted_config.persist(options)
111
+
112
+ update_index(:files, files)
113
+ end
114
+
115
+ # The directory where this safe is stored
116
+ #
117
+ # @return [Pathname]
118
+ #
119
+ def safe_dir
120
+ options[:root_dir] / name.to_s
121
+ end
122
+
123
+ # The absolute path to the encrypted index file
124
+ #
125
+ # @return [Pathname]
126
+ #
127
+ def index_path
128
+ safe_dir / options[:index_name]
129
+ end
130
+
131
+ # Information about the files in the safe
132
+ #
133
+ # @return [Hash]
134
+ #
135
+ def index
136
+ return {} unless index_path.file?
137
+
138
+ YAML.load(@lock.decrypt_from_file(index_path))
139
+ rescue Psych::SyntaxError
140
+ # A Psych::SyntaxError was raised in my integration test once when an
141
+ # incorrect password was used. This raises the right error if that ever
142
+ # happens again.
143
+ raise Sia::Error::PasswordError, 'Invalid password'
144
+ end
145
+
146
+ # The absolute path to the file storing the salt
147
+ #
148
+ def salt_path
149
+ safe_dir / options[:salt_name]
150
+ end
151
+
152
+ # The salt in binary encoding
153
+ #
154
+ def salt
155
+ if salt_path.file?
156
+ salt_path.read
157
+ else
158
+ @salt ||= SecureRandom.bytes(Sia::Lock::DIGEST.new.digest_length)
159
+ end
160
+ end
161
+
162
+ # Secure a file in the safe
163
+ #
164
+ # @param [String] filename Relative or absolute path to file to secure.
165
+ #
166
+ def close(filename)
167
+ clearpath = clear_filepath(filename)
168
+ check_file_is_in_safe_dir(clearpath) if options[:portable]
169
+ persist!
170
+
171
+ @lock.encrypt(clearpath, secure_filepath(clearpath))
172
+
173
+ info = files.fetch(clearpath, {}).merge(
174
+ secure_file: secure_filepath(clearpath),
175
+ last_closed: Time.now,
176
+ safe: true
177
+ )
178
+ update_index(:files, files.merge(clearpath => info))
179
+ end
180
+
181
+ # Extract a file from the safe
182
+ #
183
+ # @param [String] filename Relative or absolute path to file to extract.
184
+ # Note: For in-place safes, the closed path may be used. Otherwise, this
185
+ # the path to the file as it existed before being closed.
186
+ #
187
+ def open(filename)
188
+ clearpath = clear_filepath(filename)
189
+ check_file_is_in_safe_dir(clearpath) if options[:portable]
190
+
191
+ @lock.decrypt(clearpath, secure_filepath(clearpath))
192
+
193
+ info = files.fetch(clearpath, {}).merge(
194
+ secure_file: secure_filepath(clearpath),
195
+ last_opened: Time.now,
196
+ safe: false
197
+ )
198
+ update_index(:files, files.merge(clearpath => info))
199
+ end
200
+
201
+ # Open all files in the safe
202
+ #
203
+ def empty
204
+ files.each { |filename, data| open(filename) if data[:safe] }
205
+ end
206
+
207
+ # Close all files in the safe
208
+ #
209
+ def fill
210
+ files.each { |filename, data| close(filename) unless data[:safe] }
211
+ end
212
+
213
+ # Delete the safe as-is, without opening or closing files
214
+ #
215
+ # All closed files are deleted. Open files are not deleted. The safe dir is
216
+ # deleted if there is nothing besides closed files, the {#index_path}, and
217
+ # the {#salt_path} in it.
218
+ #
219
+ def delete
220
+ return unless @persisted_config.exist?
221
+
222
+ files.each { |_, d| d[:secure_file].delete if d[:safe] }
223
+ index_path.delete
224
+ salt_path.delete
225
+ safe_dir.delete if safe_dir.empty?
226
+
227
+ @persisted_config.delete
228
+ end
229
+
230
+ private
231
+
232
+ # Used by Sia::Configurable
233
+ #
234
+ def defaults
235
+ @persisted_config.options.dup
236
+ end
237
+
238
+ def assign_options(opt)
239
+ if @persisted_config.exist?
240
+ news = options.merge(clean_options(opt))
241
+ unless options == news
242
+ differences = (news.to_a - options.to_a).map { |k, v|
243
+ ":#{k} changed from `#{options[k]}` to `#{news[k]}`"
244
+ }.join("\n ")
245
+ raise Sia::Error::ConfigurationError,
246
+ "Cannot change safe configuration\n #{differences}"
247
+ end
248
+ else
249
+ @options.merge!(clean_options(opt))
250
+ end
251
+ @options.freeze
252
+ end
253
+
254
+ def files
255
+ index.fetch(:files, {}).freeze
256
+ end
257
+
258
+ def update_index(k, v)
259
+ yaml = YAML.dump(index.merge(k => v))
260
+ @lock.encrypt_to_file(yaml, index_path)
261
+ end
262
+
263
+ # Generate a urlsafe filename for storage in the safe
264
+ def digest_filename(filename)
265
+ digest = Digest::SHA256.digest(filename.to_s)
266
+ filename = Base64.urlsafe_encode64(digest, padding: false)
267
+ end
268
+
269
+ def secure_filepath(filename)
270
+ if options[:in_place]
271
+ Pathname(filename.to_s + options[:extension])
272
+ else
273
+ safe_dir / digest_filename(filename)
274
+ end
275
+ end
276
+
277
+ def clear_filepath(filename)
278
+ filename = Pathname(filename).expand_path
279
+ return filename unless options[:in_place]
280
+
281
+ filename.extname == options[:extension] ? filename.sub_ext('') : filename
282
+ end
283
+
284
+ def check_file_is_in_safe_dir(filename)
285
+ filename.ascend { |f| return if f == safe_dir }
286
+
287
+ raise Sia::Error::FileOutsideScopeError, <<~MSG
288
+ Portable safes can only open or close files within the `safe_dir`
289
+ #{filename} is not a descendant of #{safe_dir}
290
+ MSG
291
+ end
292
+ end # class Safe
293
+ end # module Sia
@@ -0,0 +1,3 @@
1
+ module Sia
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,26 @@
1
+
2
+ lib = File.expand_path("../lib", __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require "sia/version"
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "sia"
8
+ spec.version = Sia::VERSION
9
+ spec.authors = ["Spencer Christiansen"]
10
+ spec.email = ["jc.spencer92@gmail.com"]
11
+
12
+ spec.summary = %q{Encrypt files with digital safes}
13
+ spec.homepage = "https://github.com/spejamchr/sia/"
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
17
+ f.match(%r{^(test|spec|features)/})
18
+ end
19
+ spec.require_paths = ["lib"]
20
+
21
+ spec.add_development_dependency "bundler", "~> 1.16"
22
+ spec.add_development_dependency "rake", "~> 10.0"
23
+ spec.add_development_dependency "rspec", "~> 3.0"
24
+ spec.add_development_dependency "pry", "~> 0.11"
25
+ spec.add_development_dependency "rspec_junit_formatter", "~> 0.3.0"
26
+ end
metadata ADDED
@@ -0,0 +1,135 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sia
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Spencer Christiansen
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2018-05-18 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.16'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.16'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '10.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '10.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: pry
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '0.11'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '0.11'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rspec_junit_formatter
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: 0.3.0
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: 0.3.0
83
+ description:
84
+ email:
85
+ - jc.spencer92@gmail.com
86
+ executables: []
87
+ extensions: []
88
+ extra_rdoc_files: []
89
+ files:
90
+ - ".circleci/config.yml"
91
+ - ".gitignore"
92
+ - ".rspec"
93
+ - ".travis.yml"
94
+ - ".yardopts"
95
+ - CODE_OF_CONDUCT.md
96
+ - Gemfile
97
+ - Gemfile.lock
98
+ - LICENSE.txt
99
+ - README.md
100
+ - Rakefile
101
+ - bin/console
102
+ - bin/setup
103
+ - lib/sia.rb
104
+ - lib/sia/configurable.rb
105
+ - lib/sia/error.rb
106
+ - lib/sia/lock.rb
107
+ - lib/sia/persisted_config.rb
108
+ - lib/sia/safe.rb
109
+ - lib/sia/version.rb
110
+ - sia.gemspec
111
+ homepage: https://github.com/spejamchr/sia/
112
+ licenses:
113
+ - MIT
114
+ metadata: {}
115
+ post_install_message:
116
+ rdoc_options: []
117
+ require_paths:
118
+ - lib
119
+ required_ruby_version: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: '0'
124
+ required_rubygems_version: !ruby/object:Gem::Requirement
125
+ requirements:
126
+ - - ">="
127
+ - !ruby/object:Gem::Version
128
+ version: '0'
129
+ requirements: []
130
+ rubyforge_project:
131
+ rubygems_version: 2.7.6
132
+ signing_key:
133
+ specification_version: 4
134
+ summary: Encrypt files with digital safes
135
+ test_files: []