class Sia::Safe

Keep all the files safe

Encrypt files and store them in a digital safe. Have one safe for everything, or use individual safes for each file to be encrypted.

When creating a safe provide at least a name and a password, and the defaults will take care of the rest.

safe = Sia::Safe.new(name: 'test', password: 'secret')

With a safe in hand, {close} an existing file to keep it safe. (Note, any type of file can be closed, not just `.txt` files.)

safe.close('~/secret.txt')

The file will not longer be present at `/path/to/the/secret.txt`; instead, it will now be encrypted in the default Sia directory with a new name. Restore it by using {open}.

safe.open('~/secret.txt')

Notice that {open} requires the path (relative or absolute) to the file as it existed before being encrypted, even though there's no file at that location anymore. To see all files available to open in the safe, take a peak in the {index}.

pp safe.index
{:files=>
  {"/Users/spencer/secret.txt"=>
    {:secure_file=>"0nxntvTLteCTZ8cmZdX848gGaYHRAOHqir-1RuJ-n-E",
     :last_closed=>2018-04-29 19:58:24 -0600,
     :safe=>true}}}

The {fill} and {empty} methods are also helpful. {fill} will close all files that belong to the safe, and {empty} will open all the files.

safe.fill
safe.empty

Finally, if the safe has outlived its usefulness, {delete} is there to help. {delete} will remove a safe as-is, without opening or closing any files. This means that **all currently closed files will be lost** when using {delete}.

safe.delete

FYI, the safe directory for this example has the structure:

~/
└── .sia_safes/
    └── test/
        ├── .sia_index
        ├── .sia_salt
        └── 0nxntvTLteCTZ8cmZdX848gGaYHRAOHqir-1RuJ-n-E

The `.sia_safes/` directory holds all the safes, in this case the `test` safe. Its name and location can be customized using {Configurable}. The `test/` directory where the `test` safe lives. `.sia_index` is an encrypted file that stores information about the safe. Its name cam be customized: {Configurable}. The `.sia_salt` file stores the salt used to make a good symmetric key out of the password. Its name cam be customized: {Configurable}. The last file, `0nxntvTLteCTZ8cmZdX848gGaYHRAOHqir-1RuJ-n-E`, is the newly encrypted file. Its name is a `SHA256` digest of the full pathname of the clearfile (in this case, `“/Users/spencer/secret.txt”`) encoded in url-safe base 64 without padding (ie, not ending `'='`).

Attributes

name[R]

Public Class Methods

new(name:, password:, **opt) click to toggle source

@param [#to_sym] name @param [#to_s] password @param [Hash] opt Configure new safes as shown in {Configurable}.

When instantiating existing safes, configuration here must match the
persisted config, or be absent.

@return [Safe]

# File lib/sia/safe.rb, line 82
def initialize(name:, password:, **opt)
  @name = name.to_sym
  @persisted_config = PersistedConfig.new(@name)

  options # Initialize the options with defaults
  assign_options(opt)

  @lock = Lock.new(
    password.to_s,
    salt,
    options[:buffer_bytes],
    options[:digest_iterations]
  )

  # Don't let initialization succeed if the password was invalid
  index
end

Public Instance Methods

close(filename) click to toggle source

Secure a file in the safe

@param [String] filename Relative or absolute path to file to secure.

# File lib/sia/safe.rb, line 166
def close(filename)
  clearpath = clear_filepath(filename)
  check_file_is_in_safe_dir(clearpath) if options[:portable]
  persist!

  @lock.encrypt(clearpath, secure_filepath(clearpath))

  info = files.fetch(clearpath, {}).merge(
    secure_file: secure_filepath(clearpath),
    last_closed: Time.now,
    safe: true
  )
  update_index(:files, files.merge(clearpath => info))
end
delete() click to toggle source

Delete the safe as-is, without opening or closing files

All closed files are deleted. Open files are not deleted. The safe dir is deleted if there is nothing besides closed files, the {#index_path}, and the {#salt_path} in it.

# File lib/sia/safe.rb, line 219
def delete
  return unless @persisted_config.exist?

  files.each { |_, d| d[:secure_file].delete if d[:safe] }
  index_path.delete
  salt_path.delete
  safe_dir.delete if safe_dir.empty?

  @persisted_config.delete
end
empty() click to toggle source

Open all files in the safe

# File lib/sia/safe.rb, line 203
def empty
  files.each { |filename, data| open(filename) if data[:safe]  }
end
fill() click to toggle source

Close all files in the safe

# File lib/sia/safe.rb, line 209
def fill
  files.each { |filename, data| close(filename) unless data[:safe]  }
end
index() click to toggle source

Information about the files in the safe

@return [Hash]

# File lib/sia/safe.rb, line 135
def index
  return {} unless index_path.file?

  YAML.load(@lock.decrypt_from_file(index_path))
rescue Psych::SyntaxError
  # A Psych::SyntaxError was raised in my integration test once when an
  # incorrect password was used. This raises the right error if that ever
  # happens again.
  raise Sia::Error::PasswordError, 'Invalid password'
end
index_path() click to toggle source

The absolute path to the encrypted index file

@return [Pathname]

# File lib/sia/safe.rb, line 127
def index_path
  safe_dir / options[:index_name]
end
open(filename) click to toggle source

Extract a file from the safe

@param [String] filename Relative or absolute path to file to extract.

Note: For in-place safes, the closed path may be used. Otherwise, this
the path to the file as it existed before being closed.
# File lib/sia/safe.rb, line 187
def open(filename)
  clearpath = clear_filepath(filename)
  check_file_is_in_safe_dir(clearpath) if options[:portable]

  @lock.decrypt(clearpath, secure_filepath(clearpath))

  info = files.fetch(clearpath, {}).merge(
    secure_file: secure_filepath(clearpath),
    last_opened: Time.now,
    safe: false
  )
  update_index(:files, files.merge(clearpath => info))
end
persist!() click to toggle source

Persist the safe and its configuration

This doesn't have any effect once a file has been closed in the safe.

# File lib/sia/safe.rb, line 104
def persist!
  return if @persisted_config.exist?

  safe_dir.mkpath unless safe_dir.directory?
  salt_path.write(salt) unless salt_path.file?

  @persisted_config.persist(options)

  update_index(:files, files)
end
safe_dir() click to toggle source

The directory where this safe is stored

@return [Pathname]

# File lib/sia/safe.rb, line 119
def safe_dir
  options[:root_dir] / name.to_s
end
salt() click to toggle source

The salt in binary encoding

# File lib/sia/safe.rb, line 154
def salt
  if salt_path.file?
    salt_path.read
  else
    @salt ||= SecureRandom.bytes(Sia::Lock::DIGEST.new.digest_length)
  end
end
salt_path() click to toggle source

The absolute path to the file storing the salt

# File lib/sia/safe.rb, line 148
def salt_path
  safe_dir / options[:salt_name]
end

Private Instance Methods

assign_options(opt) click to toggle source
# File lib/sia/safe.rb, line 238
def assign_options(opt)
  if @persisted_config.exist?
    news = options.merge(clean_options(opt))
    unless options == news
      differences = (news.to_a - options.to_a).map { |k, v|
        ":#{k} changed from `#{options[k]}` to `#{news[k]}`"
      }.join("\n  ")
      raise Sia::Error::ConfigurationError,
        "Cannot change safe configuration\n  #{differences}"
    end
  else
    @options.merge!(clean_options(opt))
  end
  @options.freeze
end
check_file_is_in_safe_dir(filename) click to toggle source
# File lib/sia/safe.rb, line 284
    def check_file_is_in_safe_dir(filename)
      filename.ascend { |f| return if f == safe_dir }

      raise Sia::Error::FileOutsideScopeError, <<~MSG
        Portable safes can only open or close files within the `safe_dir`
          #{filename} is not a descendant of #{safe_dir}
      MSG
    end
clear_filepath(filename) click to toggle source
# File lib/sia/safe.rb, line 277
def clear_filepath(filename)
  filename = Pathname(filename).expand_path
  return filename unless options[:in_place]

  filename.extname == options[:extension] ? filename.sub_ext('') : filename
end
defaults() click to toggle source

Used by Sia::Configurable

# File lib/sia/safe.rb, line 234
def defaults
  @persisted_config.options.dup
end
digest_filename(filename) click to toggle source

Generate a urlsafe filename for storage in the safe

# File lib/sia/safe.rb, line 264
def digest_filename(filename)
  digest = Digest::SHA256.digest(filename.to_s)
  filename = Base64.urlsafe_encode64(digest, padding: false)
end
files() click to toggle source
# File lib/sia/safe.rb, line 254
def files
  index.fetch(:files, {}).freeze
end
secure_filepath(filename) click to toggle source
# File lib/sia/safe.rb, line 269
def secure_filepath(filename)
  if options[:in_place]
    Pathname(filename.to_s + options[:extension])
  else
    safe_dir / digest_filename(filename)
  end
end
update_index(k, v) click to toggle source
# File lib/sia/safe.rb, line 258
def update_index(k, v)
  yaml = YAML.dump(index.merge(k => v))
  @lock.encrypt_to_file(yaml, index_path)
end