trocla-0.6.0/0000755000004100000410000000000014756064121013037 5ustar www-datawww-datatrocla-0.6.0/bin/0000755000004100000410000000000014756064121013607 5ustar www-datawww-datatrocla-0.6.0/bin/trocla0000755000004100000410000001110414756064121015016 0ustar www-datawww-data#!/usr/bin/env ruby # CLI client for Trocla. # require 'rubygems' require 'trocla' require 'optparse' require 'yaml' options = { :config_file => nil, :ask_password => true, :trace => false } OptionParser.new do |opts| opts.on('--version', '-V', 'Version information') do puts Trocla::VERSION::STRING exit end opts.on('--config CONFIG', '-c', 'Configuration file') do |v| if File.exist?(v) options[:config_file] = v else STDERR.puts "Cannot find config file: #{v}" exit 1 end end opts.on('--trace', 'Show stack trace on failure') do options[:trace] = true end opts.on('--no-random', 'Do not generate a random password if there is no plain text password available') do options['random'] = false end opts.on('--no-format', 'Do not format a password when setting it using `set`') do options['no_format'] = true end opts.on('--length LENGTH', 'Length for a randomly created password') do |v| options['length'] = v.to_i end opts.on('--password [PASSWORD]', '-p', 'Provide password at command line or STDIN') do |pass| options[:ask_password] = false options[:password] = pass end end.parse! def create(options) [Trocla.new(options.delete(:config_file)).password( options.delete(:trocla_key), options.delete(:trocla_format), options.merge(YAML.safe_load(options.delete(:other_options).shift.to_s) || {}) ), 0] end def get(options) res = Trocla.new(options.delete(:config_file)).get_password( options.delete(:trocla_key), options.delete(:trocla_format), options.merge(YAML.safe_load(options.delete(:other_options).shift.to_s) || {}) ) [res, res.nil? ? 1 : 0] end def set(options) if options.delete(:ask_password) require 'highline/import' password = ask('Enter your password: ') { |q| q.echo = 'x' }.to_s pwd2 = ask('Repeat password: ') { |q| q.echo = 'x' }.to_s unless password == pwd2 STDERR.puts 'Passwords did not match, exiting!' return [nil, 1] end else password = options.delete(:password) || STDIN.read.chomp end format = options.delete(:trocla_format) no_format = options.delete('no_format') trocla = Trocla.new(options.delete(:config_file)) value = if no_format password else trocla.formats(format).format(password, (YAML.safe_load(options.delete(:other_options).shift.to_s) || {})) end trocla.set_password( options.delete(:trocla_key), format, value ) ['', 0] end def reset(options) [Trocla.new(options.delete(:config_file)).reset_password( options.delete(:trocla_key), options.delete(:trocla_format), options.merge(YAML.safe_load(options.delete(:other_options).shift.to_s) || {}) ), 0] end def delete(options) res = Trocla.new(options.delete(:config_file)).delete_password( options.delete(:trocla_key), options.delete(:trocla_format) ) [res, res.nil? ? 1 : 0] end def formats(options) key = (options.delete(:trocla_key) || '') if key.empty? "Available formats: #{Trocla::Formats.all.join(', ')}" else res = Trocla.new(options.delete(:config_file)).available_format( key, options.merge(YAML.safe_load(options.delete(:other_options).shift.to_s) || {}) ) [res.nil? ? res : res.join(', '), res.nil? ? 1 : 0] end end def search(options) res = Trocla.new(options.delete(:config_file)).search_key( options.delete(:trocla_key) ) [res.nil? ? res : res.join("\n"), res.nil? ? 1 : 0] end def check_format(format_name) if format_name.nil? STDERR.puts 'Missing format, exiting...' exit 1 elsif !Trocla::Formats.available?(format_name) STDERR.puts "Error: The format #{format_name} is not available" exit 1 end end actions = ['create', 'get', 'set', 'reset', 'delete', 'formats', 'search'] if (action=ARGV.shift) && actions.include?(action) options[:trocla_key] = ARGV.shift options[:trocla_format] = ARGV.shift options[:other_options] = ARGV check_format(options[:trocla_format]) unless ['delete','formats','search'].include?(action) begin result, excode = send(action, options) if result puts result.is_a?(String) ? result : result.inspect end rescue Exception => e unless e.message == 'exit' STDERR.puts "Action failed with the following message: #{e.message}" STDERR.puts '(See full trace by running task with --trace)' end raise e if options[:trace] exit 1 end exit excode.nil? ? 0 : excode else STDERR.puts "Please supply one of the following actions: #{actions.join(', ')}" STDERR.puts "Use #{$0} --help to get a list of options for these actions" exit 1 end trocla-0.6.0/.document0000644000004100000410000000004114756064121014651 0ustar www-datawww-datalib/**/*.rb bin/* - LICENSE.txt trocla-0.6.0/.github/0000755000004100000410000000000014756064121014377 5ustar www-datawww-datatrocla-0.6.0/.github/workflows/0000755000004100000410000000000014756064121016434 5ustar www-datawww-datatrocla-0.6.0/.github/workflows/ruby.yml0000644000004100000410000000117514756064121020144 0ustar www-datawww-data--- name: Ruby on: [push, pull_request] jobs: spec: runs-on: ubuntu-latest steps: - name: check out repository uses: actions/checkout@v3 - name: set up Ruby uses: ruby/setup-ruby@v1 with: bundler-cache: true ruby-version: ${{ matrix.ruby }} - name: install dependencies run: bundle install - name: install wireguard run: sudo apt install -y wireguard - name: run rspec run: bundle exec rake spec strategy: fail-fast: false matrix: ruby: ['2.5','2.7', '3.0', '3.1','3.2','3.3','head','jruby','jruby-head'] trocla-0.6.0/lib/0000755000004100000410000000000014756064121013605 5ustar www-datawww-datatrocla-0.6.0/lib/trocla.rb0000644000004100000410000001200714756064121015416 0ustar www-datawww-datarequire 'trocla/version' require 'trocla/util' require 'trocla/formats' require 'trocla/encryptions' require 'trocla/stores' require 'trocla/hooks' # Trocla class class Trocla def initialize(config_file = nil) if config_file @config_file = File.expand_path(config_file) elsif File.exist?(def_config_file = File.expand_path('~/.troclarc.yaml')) || File.exist?(def_config_file = File.expand_path('/etc/troclarc.yaml')) @config_file = def_config_file end end def self.open(config_file = nil) trocla = Trocla.new(config_file) if block_given? yield trocla trocla.close else trocla end end def password(key, format, options={}) # respect a default profile, but let the # profiles win over the default options options['profiles'] ||= config['options']['profiles'] options = merge_profiles(options['profiles']).merge(options) if options['profiles'] options = config['options'].merge(options) raise "Format #{format} is not supported! Supported formats: #{Trocla::Formats.all.join(', ')}" unless Trocla::Formats::available?(format) unless (password = get_password(key, format, options)).nil? return password end plain_pwd = get_password(key, 'plain', options) if options['random'] && plain_pwd.nil? plain_pwd = Trocla::Util.random_str(options['length'].to_i, options['charset']) set_password(key, 'plain', plain_pwd, options) unless format == 'plain' elsif !options['random'] && plain_pwd.nil? raise "Password must be present as plaintext if you don't want a random password" end pwd = self.formats(format).format(plain_pwd, options) # it's possible that meanwhile another thread/process was faster in # formating the password. But we want todo that second lookup # only for expensive formats if self.formats(format).expensive? get_password(key, format, options) || set_password(key, format, pwd, options) else set_password(key, format, pwd, options) end end def get_password(key, format, options = {}) render(format, decrypt(store.get(key, format)), options) end def reset_password(key, format, options = {}) delete_password(key, format) password(key, format, options) end def delete_password(key, format = nil, options = {}) v = store.delete(key, format) hooks_runner.run('delete', key, format, options) if v.is_a?(Hash) Hash[*v.map do |f, encrypted_value| [f, render(format, decrypt(encrypted_value), options)] end.flatten] else render(format, decrypt(v), options) end end def set_password(key, format, password, options = {}) store.set(key, format, encrypt(password), options) hooks_runner.run('set', key, format, options) render(format, password, options) end def available_format(key, options = {}) render(false, store.formats(key), options) end def search_key(key, options = {}) render(false, store.search(key), options) end def formats(format) (@format_cache ||= {})[format] ||= Trocla::Formats[format].new(self) end def encryption @encryption ||= Trocla::Encryptions[config['encryption']].new(config['encryption_options'], self) end def config @config ||= read_config end def close store.close end private def store @store ||= build_store end def build_store s = config['store'] clazz = if s.is_a?(Symbol) Trocla::Stores[s] else require config['store_require'] if config['store_require'] eval(s) end clazz.new(config['store_options'], self) end def hooks_runner @hooks_runner ||= begin Trocla::Hooks::Runner.new(self) end end def read_config if @config_file.nil? default_config else raise "Configfile #{@config_file} does not exist!" unless File.exist?(@config_file) c = default_config.merge(YAML.load(File.read(@config_file))) c['profiles'] = default_config['profiles'].merge(c['profiles']) # migrate all options to new store options # TODO: remove workaround in 0.3.0 c['store_options']['adapter'] = c['adapter'] if c['adapter'] c['store_options']['adapter_options'] = c['adapter_options'] if c['adapter_options'] c['encryption_options'] = c['ssl_options'] if c['ssl_options'] c end end def encrypt(value) encryption.encrypt(value) end def decrypt(value) return nil if value.nil? encryption.decrypt(value) end def render(format, output, options = {}) if format && output && f = self.formats(format) f.render(output, options['render'] || {}) else output end end def default_config require 'yaml' YAML.load(File.read(File.expand_path(File.join(File.dirname(__FILE__), 'trocla', 'default_config.yaml')))) end def merge_profiles(profiles) Array(profiles).inject({}) do |res, profile| raise "No such profile #{profile} defined" unless profile_hash = config['profiles'][profile] profile_hash.merge(res) end end end trocla-0.6.0/lib/VERSION0000644000004100000410000000003714756064121014655 0ustar www-datawww-datamajor:0 minor:6 patch:0 build: trocla-0.6.0/lib/trocla/0000755000004100000410000000000014756064121015071 5ustar www-datawww-datatrocla-0.6.0/lib/trocla/store.rb0000644000004100000410000000431314756064121016553 0ustar www-datawww-data# implements the default store behavior class Trocla::Store attr_reader :store_config, :trocla def initialize(config, trocla) @store_config = config @trocla = trocla end # closes the store # when called do whatever "closes" your # store, e.g. close database connections. def close; end # should return value for key & format # returns nil if nothing or a nil value # was found. # If a key is expired it must return nil. def get(key, format) raise 'not implemented' end # sets value for key & format # setting the plain format must invalidate # all other formats as they should either # be derived from plain or set directly. # options is a hash containing further # information for the store. e.g. expiration # of a key. Keys can have an expiration / # timeout by setting `expires` within # the options hashs. Value of `expires` # must be an integer indicating the # amount of seconds a key can live with. # This mechanism is expected to be # be implemented by the backend. def set(key, format, value, options = {}) if format == 'plain' set_plain(key, value, options) else set_format(key, format, value, options) end end # deletes the value for format # if format is nil everything is deleted # returns value of format or hash of # format => value # if everything is # deleted. def delete(key, format = nil) format.nil? ? (delete_all(key) || {}) : delete_format(key, format) end # returns all formats for a key def formats(_) raise 'not implemented' end # def searches for a key def search(_) raise 'not implemented' end private # sets a new plain value # *must* invalidate all # other formats def set_plain(key, value, options) raise 'not implemented' end # sets a value of a format def set_format(key, format, value, options) raise 'not implemented' end # deletes all entries of this key # and returns a hash with all # formats and values # or nil if nothing is found def delete_all(_) raise 'not implemented' end # deletes the value of the passed # key & format and returns the # value. def delete_format(key, format) raise 'not implemented' end end trocla-0.6.0/lib/trocla/encryptions/0000755000004100000410000000000014756064121017446 5ustar www-datawww-datatrocla-0.6.0/lib/trocla/encryptions/ssl.rb0000644000004100000410000000204314756064121020573 0ustar www-datawww-datarequire 'openssl' require 'base64' class Trocla::Encryptions::Ssl < Trocla::Encryptions::Base def encrypt(value) ciphertext = '' value.scan(/.{0,#{chunksize}}/m).each do |chunk| ciphertext += Base64.encode64(public_key.public_encrypt(chunk)).gsub("\n", '') + "\n" if chunk end ciphertext end def decrypt(value) plaintext = '' value.split(/\n/).each do |line| plaintext += private_key.private_decrypt(Base64.decode64(line)) if line end plaintext end private def chunksize public_key.n.num_bytes - 11 end def private_key @private_key ||= begin file = require_option(:private_key) OpenSSL::PKey::RSA.new(File.read(file), nil) end end def public_key @public_key ||= begin file = require_option(:public_key) OpenSSL::PKey::RSA.new(File.read(file), nil) end end def option(key) config[key] end def require_option(key) val = option(key) raise "Config error: 'ssl_options' => :#{key} is not defined" if val.nil? val end end trocla-0.6.0/lib/trocla/encryptions/none.rb0000644000004100000410000000021314756064121020726 0ustar www-datawww-dataclass Trocla::Encryptions::None < Trocla::Encryptions::Base def encrypt(value) value end def decrypt(value) value end end trocla-0.6.0/lib/trocla/default_config.yaml0000644000004100000410000000127614756064121020734 0ustar www-datawww-data--- store: :moneta store_options: adapter: :YAML adapter_options: :file: '/tmp/trocla.yaml' encryption: :none options: random: true length: 16 charset: default profiles: rootpw: charset: consolesafe length: 32 mysql: charset: shellsafe length: 32 login: charset: consolesafe length: 16 x509veryverylong: # 15 years days: 5475 # 5475 days expires: 466560000 x509verylong: # 10 years days: 3650 # 3600 days expires: 311040000 x509long: # 5 years days: 1825 # 1800 days expires: 155520000 x509auto: days: 40 # 30 days expires: 2592000 x509short: days: 2 # 1 day expires: 86400 trocla-0.6.0/lib/trocla/hooks.rb0000644000004100000410000000131314756064121016537 0ustar www-datawww-dataclass Trocla module Hooks class Runner attr_reader :trocla def initialize(trocla) @trocla = trocla end def run(action, key, format, options) return unless hooks[action] hooks[action].each do |cmd| Trocla::Hooks.send(cmd, trocla, key, format, options) end end private def hooks @hooks ||= begin res = {} (trocla.config['hooks'] || {}).each do |action,action_hooks| res[action] ||= [] action_hooks.each do |cmd,file| require File.join(file) res[action] << cmd end end res end end end end end trocla-0.6.0/lib/trocla/formats.rb0000644000004100000410000000236314756064121017075 0ustar www-datawww-data# frozen_string_literal: true # Trocla::Formats class Trocla::Formats # Base class Base attr_reader :trocla def initialize(trocla) @trocla = trocla end def render(output, render_options = {}) output end def expensive? self.class.expensive? end class << self def expensive(is_expensive) @expensive = is_expensive end def expensive? @expensive == true end end end class << self def [](format) formats[format.downcase] end def all Dir[File.expand_path( File.join(File.dirname(__FILE__), 'formats', '*.rb') )].collect { |f| File.basename(f, '.rb').downcase } end def available?(format) all.include?(format.downcase) end private def formats @@formats ||= Hash.new do |hash, format| format = format.downcase if File.exist?(path(format)) require "trocla/formats/#{format}" hash[format] = (eval "Trocla::Formats::#{format.capitalize}") else raise "Format #{format} is not supported!" end end end def path(format) File.expand_path(File.join(File.dirname(__FILE__), 'formats', "#{format}.rb")) end end end trocla-0.6.0/lib/trocla/stores/0000755000004100000410000000000014756064121016410 5ustar www-datawww-datatrocla-0.6.0/lib/trocla/stores/vault.rb0000644000004100000410000000344114756064121020072 0ustar www-datawww-data# the default vault based store class Trocla::Stores::Vault < Trocla::Store attr_reader :vault, :mount, :destroy def initialize(config, trocla) super(config, trocla) require 'vault' @mount = (config.delete(:mount) || 'kv') @destroy = (config.delete(:destroy) || false) # load expire support by default @vault = Vault::Client.new(config) end def close; end def get(key, format) read(key)[format.to_sym] end def formats(key) read(key).keys end def search(key) arr = key.split('/') regexp = Regexp.new(arr.pop(1)[0].to_s) path = arr.join('/') list = vault.kv(mount).list(path) list.map! do |l| if regexp.match(l) path.empty? ? l : [path, l].join('/') end end list.compact end private def read(key) k = vault.kv(mount).read(key) k.nil? ? {} : k.data end def write(key, value, options = {}) vault.kv(mount).write_metadata(key, convert_metadata(options)) unless options.empty? vault.kv(mount).write(key, value) end def set_plain(key, value, options) set_format(key, 'plain', value, options) end def set_format(key, format, value, options) write( key, read(key).merge({ format.to_sym => value }), options ) end def delete_all(key) destroy ? vault.kv(mount).destroy(key) : vault.kv(mount).delete(key) end def delete_format(key, format) old = read(key) new = old.reject { |k, _| k == format.to_sym } new.empty? ? delete_all(key) : write(key, new) old[format.to_sym] end def convert_metadata(metadatas) metadatas.transform_keys!(&:to_sym) metadatas[:delete_version_after] = metadatas.delete(:expires) if metadatas[:expires] %i[random profiles expires length].each { |k| metadatas.delete(k) } metadatas end end trocla-0.6.0/lib/trocla/stores/moneta.rb0000644000004100000410000000447314756064121020230 0ustar www-datawww-data# the default moneta based store class Trocla::Stores::Moneta < Trocla::Store attr_reader :moneta def initialize(config, trocla) super(config, trocla) require 'moneta' # load expire support by default adapter_options = { :expires => true }.merge(store_config['adapter_options'] || {}) @moneta = Moneta.new(store_config['adapter'], adapter_options) end def close moneta.close end def get(key, format) moneta.fetch(key, {})[format] end def formats(key) r = moneta.fetch(key) r.nil? ? nil : r.keys end def search(key) raise 'The search option is not available for any adapter other than Sequel or YAML' unless store_config['adapter'] == :Sequel || store_config['adapter'] == :YAML r = search_keys(key) r.empty? ? nil : r end private def set_plain(key, value, options) h = { 'plain' => value } mo = moneta_options(key, options) if options['expires'] && options['expires'] > 0 h['_expires'] = options['expires'] else # be sure that we disable the existing # expires if nothing is set. mo[:expires] = false end moneta.store(key, h, mo) end def set_format(key, format, value, options) moneta.store( key, moneta.fetch(key, {}).merge({ format => value }), moneta_options(key, options) ) end def delete_all(key) moneta.delete(key) end def delete_format(key, format) old_val = (h = moneta.fetch(key, {})).delete(format) h.empty? ? moneta.delete(key) : moneta.store(key, h, moneta_options(key, {})) old_val end def moneta_options(key, options) res = {} if options.key?('expires') res[:expires] = options['expires'] elsif e = moneta.fetch(key, {})['_expires'] res[:expires] = e end res end def search_keys(key) _moneta = Moneta.new(store_config['adapter'], (store_config['adapter_options'] || {}).merge({ :expires => false })) a = [] if store_config['adapter'] == :Sequel keys = _moneta.adapter.backend[:trocla].select_order_map { from_base64(:k) } elsif store_config['adapter'] == :YAML keys = _moneta.adapter.backend.transaction(true) { _moneta.adapter.backend.roots } end _moneta.close regexp = Regexp.new("#{key}") keys.each do |k| a << k if regexp.match(k) end a end end trocla-0.6.0/lib/trocla/stores/memory.rb0000644000004100000410000000271514756064121020252 0ustar www-datawww-data# a simple in memory store just as an example class Trocla::Stores::Memory < Trocla::Store attr_reader :memory def initialize(config, trocla) super(config, trocla) @memory = Hash.new({}) end def get(key, format) unless expired?(key) memory[key][format] else delete_all(key) nil end end def set(key, format, value, options = {}) super(key, format, value, options) set_expires(key, options['expires']) end def formats(key) memory[key].empty? ? nil : memory[key].keys end def search(key) r = memory.keys.grep(/#{key}/) r.empty? ? nil : r end private def set_plain(key, value, _) memory[key] = { 'plain' => value } end def set_format(key, format, value, _) memory[key].merge!({ format => value }) end def delete_all(key) memory.delete(key) end def delete_format(key, format) old_val = (h = memory[key]).delete(format) h.empty? ? memory.delete(key) : memory[key] = h set_expires(key,nil) old_val end private def set_expires(key, expires) expires = memory[key]['_expires'] if expires.nil? if expires && expires > 0 memory[key]['_expires'] = expires memory[key]['_expires_at'] = Time.now + expires else memory[key].delete('_expires') memory[key].delete('_expires_at') end end def expired?(key) memory.key?(key) && (a = memory[key]['_expires_at']).is_a?(Time) && \ (a < Time.now) end end trocla-0.6.0/lib/trocla/util.rb0000644000004100000410000000365514756064121016404 0ustar www-datawww-datarequire 'securerandom' class Trocla # Utils class Util class << self def random_str(length = 12, charset = 'default') char = charsets[charset] || charsets['default'] charsets_size = char.size (1..length).collect { |_| char[rand_num(charsets_size)] }.join.to_s end def salt(length = 8) random_str(length, 'alphanumeric') end private def rand_num(n) SecureRandom.random_number(n) end def charsets @charsets ||= begin h = { 'default' => chars, 'alphanumeric' => alphanumeric, 'shellsafe' => shellsafe, 'windowssafe' => windowssafe, 'numeric' => numeric, 'hexadecimal' => hexadecimal, 'consolesafe' => consolesafe, 'typesafe' => typesafe } h.each { |k, v| h[k] = v.uniq } end end def chars @chars ||= shellsafe + special_chars end def shellsafe @shellsafe ||= alphanumeric + shellsafe_chars end def windowssafe @windowssafe ||= alphanumeric + windowssafe_chars end def consolesafe @consolesafe ||= alphanumeric + consolesafe_chars end def hexadecimal @hexadecimal ||= numeric + ('a'..'f').to_a end def alphanumeric @alphanumeric ||= ('a'..'z').to_a + ('A'..'Z').to_a + numeric end def numeric @numeric ||= ('0'..'9').to_a end def typesafe @typesafe ||= ('a'..'x').to_a - ['i'] - ['l'] + ('A'..'X').to_a - ['I'] - ['L'] + ('1'..'9').to_a end def special_chars @special_chars ||= '*()&![]{}-'.split(//) end def shellsafe_chars @shellsafe_chars ||= '+%/@=?_.,:'.split(//) end def windowssafe_chars @windowssafe_chars ||= '+%/@=?_.,'.split(//) end def consolesafe_chars @consolesafe_chars ||= '+.-,_'.split(//) end end end end trocla-0.6.0/lib/trocla/formats/0000755000004100000410000000000014756064121016544 5ustar www-datawww-datatrocla-0.6.0/lib/trocla/formats/bcrypt.rb0000644000004100000410000000036114756064121020374 0ustar www-datawww-dataclass Trocla::Formats::Bcrypt < Trocla::Formats::Base expensive true require 'bcrypt' def format(plain_password, options = {}) BCrypt::Password.create(plain_password, :cost => options['cost'] || BCrypt::Engine.cost).to_s end end trocla-0.6.0/lib/trocla/formats/sha256crypt.rb0000644000004100000410000000027314756064121021165 0ustar www-datawww-data# salted crypt class Trocla::Formats::Sha256crypt < Trocla::Formats::Base def format(plain_password, options = {}) plain_password.crypt('$5$' << Trocla::Util.salt << '$') end end trocla-0.6.0/lib/trocla/formats/plain.rb0000644000004100000410000000017514756064121020177 0ustar www-datawww-dataclass Trocla::Formats::Plain < Trocla::Formats::Base def format(plain_password, options = {}) plain_password end end trocla-0.6.0/lib/trocla/formats/mysql.rb0000644000004100000410000000031714756064121020237 0ustar www-datawww-dataclass Trocla::Formats::Mysql < Trocla::Formats::Base require 'digest/sha1' def format(plain_password, options = {}) '*' + Digest::SHA1.hexdigest(Digest::SHA1.digest(plain_password)).upcase end end trocla-0.6.0/lib/trocla/formats/ssha.rb0000644000004100000410000000046014756064121020027 0ustar www-datawww-data# salted crypt require 'base64' require 'digest' class Trocla::Formats::Ssha < Trocla::Formats::Base def format(plain_password, options = {}) salt = options['salt'] || Trocla::Util.salt(16) '{SSHA}' + Base64.encode64("#{Digest::SHA1.digest("#{plain_password}#{salt}")}#{salt}").chomp end end trocla-0.6.0/lib/trocla/formats/pgsql.rb0000644000004100000410000000257514756064121020230 0ustar www-datawww-dataclass Trocla::Formats::Pgsql < Trocla::Formats::Base require 'digest/md5' require 'openssl' require 'base64' def format(plain_password, options = {}) encode = (options['encode'] || 'sha256') case encode when 'md5' raise 'You need pass the username as an option to use this format' unless options['username'] 'md5' + Digest::MD5.hexdigest(plain_password + options['username']) when 'sha256' pg_sha256(plain_password) else raise 'Unkmow encode %s for pgsql password' % [encode] end end private def pg_sha256(password) salt = OpenSSL::Random.random_bytes(16) digest = digest_key(password, salt) 'SCRAM-SHA-256$%s:%s$%s:%s' % [ '4096', Base64.strict_encode64(salt), Base64.strict_encode64(client_key(digest)), Base64.strict_encode64(server_key(digest)) ] end def digest_key(password, salt) OpenSSL::KDF.pbkdf2_hmac( password, salt: salt, iterations: 4096, length: 32, hash: OpenSSL::Digest::SHA256.new ) end def client_key(digest_key) hmac = OpenSSL::HMAC.new(digest_key, OpenSSL::Digest::SHA256.new) hmac << 'Client Key' hmac.digest OpenSSL::Digest.new('SHA256').digest hmac.digest end def server_key(digest_key) hmac = OpenSSL::HMAC.new(digest_key, OpenSSL::Digest::SHA256.new) hmac << 'Server Key' hmac.digest end end trocla-0.6.0/lib/trocla/formats/x509.rb0000644000004100000410000001503014756064121017575 0ustar www-datawww-datarequire 'openssl' # Trocla::Formats::X509 class Trocla::Formats::X509 < Trocla::Formats::Base expensive true def format(plain_password,options={}) if plain_password.match(/-----BEGIN RSA PRIVATE KEY-----.*-----END RSA PRIVATE KEY-----.*-----BEGIN CERTIFICATE-----.*-----END CERTIFICATE-----/m) # just an import, don't generate any new keys return plain_password end cn = nil if options['subject'] subject = options['subject'] if cna = OpenSSL::X509::Name.parse(subject).to_a.find { |e| e[0] == 'CN' } cn = cna[1] end elsif options['CN'] subject = '' cn = options['CN'] ['C', 'ST', 'L', 'O', 'OU', 'CN', 'emailAddress'].each do |field| subject << "/#{field}=#{options[field]}" if options[field] end else raise 'You need to pass "subject" or "CN" as an option to use this format' end hash = options['hash'] || 'sha2' sign_with = options['ca'] become_ca = options['become_ca'] || false keysize = options['keysize'] || 4096 days = options['days'].nil? ? 365 : options['days'].to_i name_constraints = Array(options['name_constraints']) key_usages = options['key_usages'] key_usages = Array(key_usages) if key_usages altnames = if become_ca || (an = options['altnames']) && Array(an).empty? [] else # ensure that we have the CN with us, but only if it # it's like a hostname. # This might have to be improved. if cn.include?(' ') Array(an).collect { |v| "DNS:#{v}" }.join(', ') else (["DNS:#{cn}"] + Array(an).collect { |v| "DNS:#{v}" }).uniq.join(', ') end end begin key = mkkey(keysize) rescue Exception => e puts e.backtrace raise "Private key for #{subject} creation failed: #{e.message}" end cert = nil if sign_with # certificate signed with CA begin ca_str = trocla.get_password(sign_with, 'x509') ca = OpenSSL::X509::Certificate.new(ca_str) cakey = OpenSSL::PKey::RSA.new(ca_str) caserial = getserial(sign_with) rescue Exception => e raise "Value of #{sign_with} can't be loaded as CA: #{e.message}" end begin subj = OpenSSL::X509::Name.parse(subject) request = mkreq(subj, key.public_key) request.sign(key, signature(hash)) rescue Exception => e raise "Certificate request #{subject} creation failed: #{e.message}" end begin cert = mkcert( caserial, request.subject, ca, request.public_key, days, altnames, key_usages, name_constraints, become_ca ) cert.sign(cakey, signature(hash)) addserial(sign_with, caserial) rescue Exception => e raise "Certificate #{subject} signing failed: #{e.message}" end else # self-signed certificate begin subj = OpenSSL::X509::Name.parse(subject) cert = mkcert( getserial(subj), subj, nil, key.public_key, days, altnames, key_usages, name_constraints, become_ca ) cert.sign(key, signature(hash)) rescue Exception => e raise "Self-signed certificate #{subject} creation failed: #{e.message}" end end key.to_pem + cert.to_pem end def render(output, render_options = {}) if render_options['keyonly'] OpenSSL::PKey::RSA.new(output).to_pem elsif render_options['certonly'] OpenSSL::X509::Certificate.new(output).to_pem elsif render_options['publickeyonly'] OpenSSL::PKey::RSA.new(output).public_key.to_pem else super(output, render_options) end end private # nice help: https://gist.github.com/mitfik/1922961 def signature(hash = 'sha2') if hash == 'sha1' OpenSSL::Digest::SHA1.new elsif hash == 'sha224' OpenSSL::Digest::SHA224.new elsif hash == 'sha2' || hash == 'sha256' OpenSSL::Digest::SHA256.new elsif hash == 'sha384' OpenSSL::Digest::SHA384.new elsif hash == 'sha512' OpenSSL::Digest::SHA512.new else raise "Unrecognized hash: #{hash}" end end def mkkey(len) OpenSSL::PKey::RSA.generate(len) end def mkreq(subject, public_key) request = OpenSSL::X509::Request.new request.subject = subject request.public_key = public_key request end def mkcert(serial, subject, issuer, public_key, days, altnames, key_usages = nil, name_constraints = [], become_ca = false) cert = OpenSSL::X509::Certificate.new issuer = cert if issuer.nil? cert.subject = subject cert.issuer = issuer.subject cert.not_before = Time.now cert.not_after = Time.now + days * 24 * 60 * 60 cert.public_key = public_key cert.serial = serial cert.version = 2 ef = OpenSSL::X509::ExtensionFactory.new ef.subject_certificate = cert ef.issuer_certificate = issuer cert.extensions = [ef.create_extension('subjectKeyIdentifier', 'hash')] if become_ca cert.add_extension ef.create_extension('basicConstraints', 'CA:TRUE', true) unless (ku = key_usages || ca_key_usages).empty? cert.add_extension ef.create_extension('keyUsage', ku.join(', '), true) end if name_constraints && !name_constraints.empty? cert.add_extension ef.create_extension('nameConstraints', "permitted;DNS:#{name_constraints.join(',permitted;DNS:')}", true) end else cert.add_extension ef.create_extension('subjectAltName', altnames, true) unless altnames.empty? cert.add_extension ef.create_extension('basicConstraints', 'CA:FALSE', true) unless (ku = key_usages || cert_key_usages).empty? cert.add_extension ef.create_extension('keyUsage', ku.join(', '), true) end end cert.add_extension ef.create_extension('authorityKeyIdentifier', 'keyid:always,issuer:always') cert end def getserial(ca) newser = Trocla::Util.random_str(20, 'hexadecimal').to_i(16) all_serials(ca).include?(newser) ? getserial(ca) : newser end def all_serials(ca) if allser = trocla.get_password("#{ca}_all_serials", 'plain') YAML.safe_load(allser) else [] end end def addserial(ca,serial) serials = all_serials(ca) << serial trocla.set_password("#{ca}_all_serials", 'plain', YAML.dump(serials)) end def cert_key_usages ['nonRepudiation', 'digitalSignature', 'keyEncipherment'] end def ca_key_usages [ 'keyCertSign', 'cRLSign', 'nonRepudiation', 'digitalSignature', 'keyEncipherment' ] end end trocla-0.6.0/lib/trocla/formats/wireguard.rb0000644000004100000410000000222214756064121021060 0ustar www-datawww-datarequire 'open3' require 'yaml' class Trocla::Formats::Wireguard < Trocla::Formats::Base expensive true def format(plain_password, options={}) return YAML.safe_load(plain_password) if plain_password.match(/---/) wg_priv = nil wg_pub = nil begin Open3.popen3('wg genkey') do |_stdin, stdout, _stderr, _waiter| wg_priv = stdout.read.chomp end rescue SystemCallError => e raise 'trocla wireguard: wg binary not found' if e.message =~ /No such file or directory/ raise "trocla wireguard: #{e.message}" end begin Open3.popen3('wg pubkey') do |stdin, stdout, _stderr, _waiter| stdin.write(wg_priv) stdin.close wg_pub = stdout.read.chomp end rescue SystemCallError => e raise "trocla wireguard: #{e.message}" end YAML.dump({ 'wg_priv' => wg_priv, 'wg_pub' => wg_pub }) end def render(output, render_options = {}) data = YAML.safe_load(output) if render_options['privonly'] data['wg_priv'] elsif render_options['pubonly'] data['wg_pub'] else 'pub: ' + data['wg_pub'] + "\npriv: " + data['wg_priv'] end end end trocla-0.6.0/lib/trocla/formats/md5crypt.rb0000644000004100000410000000027014756064121020637 0ustar www-datawww-data# salted crypt class Trocla::Formats::Md5crypt < Trocla::Formats::Base def format(plain_password, options = {}) plain_password.crypt('$1$' << Trocla::Util.salt << '$') end end trocla-0.6.0/lib/trocla/formats/sshkey.rb0000644000004100000410000000211214756064121020373 0ustar www-datawww-datarequire 'sshkey' class Trocla::Formats::Sshkey < Trocla::Formats::Base expensive true def format(plain_password,options={}) if plain_password.match(/-----BEGIN RSA PRIVATE KEY-----.*-----END RSA PRIVATE KEY/m) # Import, validate ssh key begin sshkey = ::SSHKey.new(plain_password) rescue Exception => e raise "SSH key import failed: #{e.message}" end return sshkey.private_key + sshkey.ssh_public_key end type = options['type'] || 'rsa' bits = options['bits'] || 2048 begin sshkey = ::SSHKey.generate( type: type, bits: bits, comment: options['comment'], passphrase: options['passphrase'] ) rescue Exception => e raise "SSH key creation failed: #{e.message}" end sshkey.private_key + sshkey.ssh_public_key end def render(output, render_options = {}) if render_options['privonly'] ::SSHKey.new(output).private_key elsif render_options['pubonly'] ::SSHKey.new(output).ssh_public_key else super(output, render_options) end end end trocla-0.6.0/lib/trocla/formats/sha512crypt.rb0000644000004100000410000000027314756064121021160 0ustar www-datawww-data# salted crypt class Trocla::Formats::Sha512crypt < Trocla::Formats::Base def format(plain_password, options = {}) plain_password.crypt('$6$' << Trocla::Util.salt << '$') end end trocla-0.6.0/lib/trocla/formats/sha1.rb0000644000004100000410000000032714756064121017727 0ustar www-datawww-dataclass Trocla::Formats::Sha1 < Trocla::Formats::Base require 'digest/sha1' require 'base64' def format(plain_password, options = {}) '{SHA}' + Base64.encode64(Digest::SHA1.digest(plain_password)) end end trocla-0.6.0/lib/trocla/encryptions.rb0000644000004100000410000000246314756064121020000 0ustar www-datawww-data# frozen_string_literal: true # Trocla::Encryptions class Trocla::Encryptions # Base class Base attr_reader :trocla, :config def initialize(config, trocla) @trocla = trocla @config = config end def encrypt(_) raise NoMethodError.new("#{self.class.name} needs to implement 'encrypt()'") end def decrypt(_) raise NoMethodError.new("#{self.class.name} needs to implement 'decrypt()'") end end class << self def [](enc) encryptions[enc.to_s.downcase] end def all Dir[path '*'].collect do |enc| File.basename(enc, '.rb').downcase end end def available?(encryption) all.include?(encryption.to_s.downcase) end private def encryptions @@encryptions ||= Hash.new do |hash, encryption| encryption = encryption.to_s.downcase if File.exist?(path encryption) require "trocla/encryptions/#{encryption}" class_name = "Trocla::Encryptions::#{encryption.capitalize}" hash[encryption] = (eval class_name) else raise "Encryption #{encryption} is not supported!" end end end def path(encryption) File.expand_path( File.join(File.dirname(__FILE__), 'encryptions', "#{encryption}.rb") ) end end end trocla-0.6.0/lib/trocla/version.rb0000644000004100000410000000101414756064121017077 0ustar www-datawww-data# encoding: utf-8 class Trocla # VERSION class VERSION version = {} File.read(File.join(File.dirname(__FILE__), '../', 'VERSION')).each_line do |line| type, value = line.chomp.split(':') next if type =~ /^\s+$/ || value =~ /^\s+$/ version[type] = value end MAJOR = version['major'] MINOR = version['minor'] PATCH = version['patch'] BUILD = version['build'] STRING = [MAJOR, MINOR, PATCH, BUILD].compact.join('.') def self.version STRING end end end trocla-0.6.0/lib/trocla/stores.rb0000644000004100000410000000153614756064121016742 0ustar www-datawww-datarequire 'trocla/store' # store management class Trocla::Stores class << self def [](store) stores[store.to_s.downcase] end def all @all ||= Dir[path '*'].collect do |store| File.basename(store, '.rb').downcase end end def available?(store) all.include?(store.to_s.downcase) end private def stores @@stores ||= Hash.new do |hash, store| store = store.to_s.downcase if File.exist?(path(store)) require "trocla/stores/#{store}" class_name = "Trocla::Stores::#{store.capitalize}" hash[store] = (eval class_name) else raise "Store #{store} is not supported!" end end end def path(store) File.expand_path( File.join(File.dirname(__FILE__), 'stores', "#{store}.rb") ) end end end trocla-0.6.0/LICENSE.txt0000644000004100000410000000125314756064121014663 0ustar www-datawww-dataTrocla - a simple password generator and storage Copyright (C) 2011-2015 Marcel Haerry This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . trocla-0.6.0/spec/0000755000004100000410000000000014756064121013771 5ustar www-datawww-datatrocla-0.6.0/spec/data/0000755000004100000410000000000014756064121014702 5ustar www-datawww-datatrocla-0.6.0/spec/data/.keep0000644000004100000410000000000014756064121015615 0ustar www-datawww-datatrocla-0.6.0/spec/spec_helper.rb0000644000004100000410000003001514756064121016606 0ustar www-datawww-data$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) $LOAD_PATH.unshift(File.dirname(__FILE__)) require 'jruby' if Object.const_defined?(:RUBY_ENGINE) and RUBY_ENGINE == 'jruby' require 'rspec' require 'rspec/pending_for' require 'yaml' require 'trocla' # Requires supporting files with custom matchers and macros, etc, # in ./support/ and its subdirectories. Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each {|f| require f} RSpec.shared_examples "encryption_basics" do describe 'storing' do it "random passwords" do expect(@trocla.password('random1', 'plain').length).to eql(16) end it "long random passwords" do expect(@trocla.set_password('random1_long','plain',4096.times.collect{|s| 'x' }.join('')).length).to eql(4096) end end describe 'retrieve' do it "random passwords" do stored = @trocla.password('random1', 'plain') retrieved = @trocla.password('random1', 'plain') retrieved_again = @trocla.password('random1', 'plain') expect(retrieved).to eql(stored) expect(retrieved_again).to eql(stored) expect(retrieved_again).to eql(retrieved) end it "encrypted passwords" do @trocla.set_password('some_pass', 'plain', 'super secret') expect(@trocla.get_password('some_pass', 'plain')).to eql('super secret') end it "resets passwords" do @trocla.set_password('some_pass', 'plain', 'super secret') expect(@trocla.reset_password('some_pass', 'plain')).not_to eql('super secret') end end describe 'deleting' do it "plain" do @trocla.set_password('some_pass', 'plain', 'super secret') expect(@trocla.delete_password('some_pass', 'plain')).to eql('super secret') end it "delete formats" do plain = @trocla.password('some_mysqlpass', 'plain') mysql = @trocla.password('some_mysqlpass', 'mysql') expect(@trocla.delete_password('some_mysqlpass', 'mysql')).to eql(mysql) expect(@trocla.delete_password('some_mysqlpass', 'plain')).to eql(plain) expect(@trocla.get_password('some_mysqlpass','plain')).to be_nil expect(@trocla.get_password('some_mysqlpass','mysql')).to be_nil end it "all passwords" do plain = @trocla.password('some_mysqlpass', 'plain') mysql = @trocla.password('some_mysqlpass', 'mysql') deleted = @trocla.delete_password('some_mysqlpass') expect(deleted).to be_a_kind_of(Hash) expect(deleted['plain']).to eql(plain) expect(deleted['mysql']).to eql(mysql) end end end RSpec.shared_examples "verify_encryption" do it "does not store plaintext passwords" do @trocla.set_password('noplain', 'plain', 'plaintext_password') expect(File.readlines(trocla_yaml_file).grep(/plaintext_password/)).to be_empty end it "makes sure identical passwords do not match when stored" do @trocla.set_password('one_key', 'plain', 'super secret') @trocla.set_password('another_key', 'plain', 'super secret') yaml = YAML.load_file(trocla_yaml_file) expect(yaml['one_key']['plain']).not_to eq(yaml['another_key']['plain']) end end RSpec.shared_examples 'store_validation' do |store| describe '.get' do it { expect(store.get('some_key','plain')).to be_nil } end describe '.set' do it 'stores nil values' do store.set('some_nil_value','plain',nil) expect(store.get('some_nil_value','plain')).to be_nil end it 'stores plain format' do store.set('some_value','plain','value') expect(store.get('some_value','plain')).to eql('value') end it 'stores other formats' do store.set('some_value','foo','bla') expect(store.get('some_value','foo')).to eql('bla') end it 'resets other formats on setting plain' do store.set('some_value','foo','bla') store.set('some_value','plain','value') expect(store.get('some_value','plain')).to eql('value') expect(store.get('some_value','foo')).to be_nil end end describe '.delete' do it { expect(store.delete('something','foo')).to be_nil } it { expect(store.delete('something')).to be_empty } it 'deletes the value of a format' do store.set('some_value','foo','bla') expect(store.delete('some_value','foo')).to eql('bla') expect(store.get('some_value','foo')).to be_nil end it 'deletes only the value of a format' do store.set('some_value','plain','value') store.set('some_value','foo','bla') expect(store.delete('some_value','plain')).to eql('value') expect(store.get('some_value','plain')).to be_nil expect(store.get('some_value','foo')).to eql('bla') end it 'deletes all values without a format' do store.set('some_value','plain','value') store.set('some_value','foo','bla') hash = store.delete('some_value') expect(hash).to be_a_kind_of(Hash) expect(hash['plain']).to eql('value') expect(hash['foo']).to eql('bla') expect(store.get('some_value','plain')).to be_nil expect(store.get('some_value','foo')).to be_nil end end describe 'expiration' do it 'will not return an expired key' do store.set('some_expiring_value','plain','to_be_expired',{ 'expires' => 2 }) expect(store.get('some_expiring_value','plain')).to eql('to_be_expired') sleep 3 expect(store.get('some_expiring_value','plain')).to be_nil end it 'increases expiration when setting anything for that key' do store.set('some_expiring_value','plain','to_be_expired',{ 'expires' => 2 }) expect(store.get('some_expiring_value','plain')).to eql('to_be_expired') sleep 1 store.set('some_expiring_value','bla','bla_to_be_expired',{ 'expires' => 3 }) sleep 2 expect(store.get('some_expiring_value','plain')).to eql('to_be_expired') sleep 2 expect(store.get('some_expiring_value','plain')).to be_nil end it 'keeps expiration when setting another value' do store.set('some_expiring_value','plain','to_be_expired',{ 'expires' => 2 }) store.set('some_expiring_value','foo','to_be_expired_foo') expect(store.get('some_expiring_value','plain')).to eql('to_be_expired') sleep 3 expect(store.get('some_expiring_value','plain')).to be_nil expect(store.get('some_expiring_value','foo')).to be_nil end it 'setting plain clears everything including expiration' do store.set('some_expiring_value','plain','to_be_expired',{ 'expires' => 2 }) sleep 1 store.set('some_expiring_value','plain','to_be_expired2') expect(store.get('some_expiring_value','plain')).to eql('to_be_expired2') sleep 3 expect(store.get('some_expiring_value','plain')).to eql('to_be_expired2') end it 'extends expiration when setting another value' do store.set('some_expiring_value','plain','to_be_expired',{ 'expires' => 4 }) sleep 2 store.set('some_expiring_value','foo','to_be_expired_foo') expect(store.get('some_expiring_value','plain')).to eql('to_be_expired') sleep 3 expect(store.get('some_expiring_value','plain')).to eql('to_be_expired') sleep 2 expect(store.get('some_expiring_value','plain')).to be_nil end it 'extends expiration when deleting a format' do store.set('some_expiring_value','plain','to_be_expired',{ 'expires' => 4 }) store.set('some_expiring_value','foo','to_be_expired2') sleep 2 expect(store.delete('some_expiring_value','foo')).to eql('to_be_expired2') sleep 3 expect(store.get('some_expiring_value','plain')).to eql('to_be_expired') sleep 2 expect(store.get('some_expiring_value','plain')).to be_nil end it 'keeps expiration although we\'re fetching a value' do store.set('some_expiring_value','plain','to_be_expired',{ 'expires' => 3 }) sleep 2 expect(store.get('some_expiring_value','plain')).to eql('to_be_expired') sleep 2 expect(store.get('some_expiring_value','plain')).to be_nil end it 'readding a value with an expiration makes it expiring in the future' do store.set('some_expiring_value','plain','to_be_expired') store.set('some_expiring_value','plain','to_be_expired2',{ 'expires' => 2 }) expect(store.get('some_expiring_value','plain')).to eql('to_be_expired2') sleep 3 expect(store.get('some_expiring_value','plain')).to be_nil end it 'setting an expires of false removes expiration' do store.set('some_expiring_value','plain','to_be_expired2',{ 'expires' => 2 }) expect(store.get('some_expiring_value','plain')).to eql('to_be_expired2') store.set('some_expiring_value','plain','to_be_expired',{ 'expires' => false }) sleep 3 expect(store.get('some_expiring_value','plain')).to eql('to_be_expired') end it 'setting an expires of 0 removes expiration' do store.set('some_expiring_value','plain','to_be_expired2',{ 'expires' => 2 }) expect(store.get('some_expiring_value','plain')).to eql('to_be_expired2') store.set('some_expiring_value','plain','to_be_expired',{ 'expires' => 0 }) sleep 3 expect(store.get('some_expiring_value','plain')).to eql('to_be_expired') end it 'setting an expires of false removes expiration even if it\'s for a different format' do store.set('some_expiring_value','plain','to_be_expired2',{ 'expires' => 2 }) expect(store.get('some_expiring_value','plain')).to eql('to_be_expired2') store.set('some_expiring_value','foo','to_be_expired_foo',{ 'expires' => false }) sleep 3 expect(store.get('some_expiring_value','plain')).to eql('to_be_expired2') expect(store.get('some_expiring_value','foo')).to eql('to_be_expired_foo') end it 'setting an expires of 0 removes expiration even if it\'s for a different format' do store.set('some_expiring_value','plain','to_be_expired2',{ 'expires' => 2 }) expect(store.get('some_expiring_value','plain')).to eql('to_be_expired2') store.set('some_expiring_value','foo','to_be_expired_foo',{ 'expires' => 0 }) sleep 3 expect(store.get('some_expiring_value','plain')).to eql('to_be_expired2') expect(store.get('some_expiring_value','foo')).to eql('to_be_expired_foo') end end end def default_config @default_config ||= begin config_path = [ File.expand_path(base_dir+'/lib/trocla/default_config.yaml'), File.expand_path(File.dirname($LOADED_FEATURES.grep(/trocla.rb/)[0])+'/trocla/default_config.yaml'), ].find { |p| File.exist?(p) } YAML.load(File.read(config_path)) end end def test_config @test_config ||= default_config.merge({ 'store' => :memory, }) end def test_config_persistent @test_config_persistent ||= default_config.merge({ 'store_options' => { 'adapter' => :YAML, 'adapter_options' => { :file => trocla_yaml_file }, }, }) end def ssl_test_config @ssl_config ||= test_config_persistent.merge({ 'encryption' => :ssl, 'encryption_options' => { :private_key => data_dir('trocla.key'), :public_key => data_dir('trocla.pub'), }, }) end def hooks_config @hooks_config ||= test_config.merge({ 'hooks' => { 'set' => { 'set_test_hook' => File.expand_path(File.join(base_dir,'spec/fixtures/set_test_hook.rb')) }, 'delete' => { 'delete_test_hook' => File.expand_path(File.join(base_dir,'spec/fixtures/delete_test_hook.rb')) } } }) end def base_dir File.dirname(__FILE__)+'/../' end def data_dir(file = nil) File.expand_path(File.join(base_dir, 'spec/data', file)) end def trocla_yaml_file data_dir('trocla_store.yaml') end def generate_ssl_keys require 'openssl' rsa_key = OpenSSL::PKey::RSA.new(4096) File.open(data_dir('trocla.key'), 'w') { |f| f.write(rsa_key.to_pem) } File.open(data_dir('trocla.pub'), 'w') { |f| f.write(rsa_key.public_key.to_pem) } end def remove_ssl_keys File.unlink(data_dir('trocla.key')) File.unlink(data_dir('trocla.pub')) end def remove_yaml_store File.unlink(trocla_yaml_file) end class Trocla::Formats::Sleep < Trocla::Formats::Base def format(plain_password,options={}) sleep options['sleep'] ||= 0 (options['sleep'] + 1 ).times.collect{ plain_password }.join(' ') end end trocla-0.6.0/spec/fixtures/0000755000004100000410000000000014756064121015642 5ustar www-datawww-datatrocla-0.6.0/spec/fixtures/set_test_hook.rb0000644000004100000410000000032414756064121021040 0ustar www-datawww-dataclass Trocla module Hooks def self.set_test_hook(trocla, key, format, options) self.set_messages << "#{key}_#{format}" end def self.set_messages @set_messages ||= [] end end end trocla-0.6.0/spec/fixtures/delete_test_hook.rb0000644000004100000410000000034014756064121021505 0ustar www-datawww-dataclass Trocla module Hooks def self.delete_test_hook(trocla, key, format, options) self.delete_messages << "#{key}_#{format}" end def self.delete_messages @delete_messages ||= [] end end end trocla-0.6.0/spec/trocla_spec.rb0000644000004100000410000003034014756064121016614 0ustar www-datawww-data# -- encoding : utf-8 -- require File.expand_path(File.dirname(__FILE__) + '/spec_helper') describe "Trocla" do before(:each) do expect_any_instance_of(Trocla).to receive(:read_config).and_return(test_config) end context 'in normal usage with' do before(:each) do @trocla = Trocla.new @trocla.password('init','plain') end describe "password" do it "generates random passwords by default" do expect(@trocla.password('random1','plain')).not_to eq(@trocla.password('random2','plain')) end it "generates passwords of length #{default_config['options']['length']}" do expect(@trocla.password('random1','plain').length).to eq(default_config['options']['length']) end Trocla::Formats.all.each do |format| if format == 'wireguard' require 'open3' before(:each) do allow(Open3).to receive(:popen3).with('wg genkey').and_yield(nil, StringIO.new('key'), nil, nil) allow(Open3).to receive(:popen3).with('wg pubkey').and_yield(StringIO.new, StringIO.new('key'), nil, nil) end end describe "#{format} password format" do it "retursn a password hashed in the #{format} format" do expect(@trocla.password('some_test',format,format_options[format])).not_to be_empty end it "returns the same hashed for the #{format} format on multiple invocations" do expect(round1=@trocla.password('some_test',format,format_options[format])).not_to be_empty expect(@trocla.password('some_test',format,format_options[format])).to eq(round1) end it "also stores the plain password by default" do pwd = @trocla.password('some_test','plain') expect(pwd).not_to be_empty expect(pwd.length).to eq(16) end end end Trocla::Formats.all.reject{|f| f == 'plain' }.each do |format| it "raises an exception if not a random password is asked but plain password is not present for format #{format}" do expect{@trocla.password('not_random',format, 'random' => false)}.to raise_error(/Password must be present as plaintext/) end end describe 'with profiles' do it 'raises an exception on unknown profile' do expect{@trocla.password('no profile known','plain', 'profiles' => 'unknown_profile') }.to raise_error(/No such profile unknown_profile defined/) end it 'takes a profile and merge its options' do pwd = @trocla.password('some_test','plain', 'profiles' => 'rootpw') expect(pwd).not_to be_empty expect(pwd.length).to eq(32) expect(pwd).to_not match(/[={}\[\]\?%\*()&!]+/) end it 'is possible to combine profiles but first profile wins' do pwd = @trocla.password('some_test1','plain', 'profiles' => ['rootpw','login']) expect(pwd).not_to be_empty expect(pwd.length).to eq(32) expect(pwd).not_to match(/[={}\[\]\?%\*()&!]+/) end it 'is possible to combine profiles but first profile wins 2' do pwd = @trocla.password('some_test2','plain', 'profiles' => ['login','mysql']) expect(pwd).not_to be_empty expect(pwd.length).to eq(16) expect(pwd).not_to match(/[={}\[\]\?%\*()&!]+/) end it 'is possible to combine profiles but first profile wins 3' do # mysql profile uses a 32 long random pwd with shell safe characters # and we want to use a fixed random str here https://github.com/duritong/trocla/issues/55 allow(Trocla::Util).to receive(:random_str).with(32,'shellsafe') { "jmNi6+7dsUn@H?vfbXCq=ULEGPW,u:hu" } pwd = @trocla.password('some_test3','plain', 'profiles' => ['mysql','login']) expect(pwd).not_to be_empty expect(pwd.length).to eq(32) expect(pwd).to match(/[+%\/@=\?_.,:]+/) end end end describe "set_password" do it "resets hashed passwords on a new plain password" do expect(@trocla.password('set_test','mysql')).not_to be_empty expect(@trocla.get_password('set_test','mysql')).not_to be_nil expect(old_plain=@trocla.password('set_test','mysql')).not_to be_empty expect(@trocla.set_password('set_test','plain','foobar')).not_to eq(old_plain) expect(@trocla.get_password('set_test','mysql')).to be_nil end it "otherwise updates only the hash" do expect(mysql = @trocla.password('set_test2','mysql')).not_to be_empty expect(md5crypt = @trocla.password('set_test2','md5crypt')).not_to be_empty expect(plain = @trocla.get_password('set_test2','plain')).not_to be_empty expect(new_mysql = @trocla.set_password('set_test2','mysql','foo')).not_to eql(mysql) expect(@trocla.get_password('set_test2','mysql')).to eq(new_mysql) expect(@trocla.get_password('set_test2','md5crypt')).to eq(md5crypt) expect(@trocla.get_password('set_test2','plain')).to eq(plain) end it 'is able to set password with umlauts and other UTF-8 charcters' do expect(myumlaut = @trocla.set_password('set_test_umlaut','plain','Tütü')).to eql('Tütü') expect(@trocla.get_password('set_test_umlaut','plain','Tütü')).to eql('Tütü') end end describe "reset_password" do it "resets a password" do plain1 = @trocla.password('reset_pwd','plain') plain2 = @trocla.reset_password('reset_pwd','plain') expect(plain1).not_to eq(plain2) end it "does not reset other formats" do expect(mysql = @trocla.password('reset_pwd2','mysql')).not_to be_empty expect(md5crypt1 = @trocla.password('reset_pwd2','md5crypt')).not_to be_empty expect(md5crypt2 = @trocla.reset_password('reset_pwd2','md5crypt')).not_to be_empty expect(md5crypt2).not_to eq(md5crypt1) expect(@trocla.get_password('reset_pwd2','mysql')).to eq(mysql) end end describe "search_key" do it "search a specific key" do keys = ['search_key','search_key1','key_search','key_search2'] keys.each do |k| @trocla.password(k,'plain') end expect(@trocla.search_key('search_key1').length).to eq(1) end it "ensure search regex is ok" do keys = ['search_key2','search_key3','key_search2','key_search4'] keys.each do |k| @trocla.password(k,'plain') end expect(@trocla.search_key('key').length).to eq(4) expect(@trocla.search_key('^search').length).to eq(2) expect(@trocla.search_key('ch.*3').length).to eq(1) expect(@trocla.search_key('ch.*[3-4]$').length).to eq(2) expect(@trocla.search_key('ch.*1')).to be_nil end end describe "list_format" do it "list available formats for key" do formats = ['plain','mysql'] formats.each do |f| @trocla.password('list_key',f) end expect(@trocla.available_format('list_key')).to eq(formats) end it "no return if key doesn't exist" do expect(@trocla.available_format('list_key1')).to be_nil end end describe "delete_password" do it "deletes all passwords if no format is given" do expect(@trocla.password('delete_test1','mysql')).not_to be_nil expect(@trocla.get_password('delete_test1','plain')).not_to be_nil @trocla.delete_password('delete_test1') expect(@trocla.get_password('delete_test1','plain')).to be_nil expect(@trocla.get_password('delete_test1','mysql')).to be_nil end it "deletes only a given format" do expect(@trocla.password('delete_test2','mysql')).not_to be_nil expect(@trocla.get_password('delete_test2','plain')).not_to be_nil @trocla.delete_password('delete_test2','plain') expect(@trocla.get_password('delete_test2','plain')).to be_nil expect(@trocla.get_password('delete_test2','mysql')).not_to be_nil end it "deletes only a given non-plain format" do expect(@trocla.password('delete_test3','mysql')).not_to be_nil expect(@trocla.get_password('delete_test3','plain')).not_to be_nil @trocla.delete_password('delete_test3','mysql') expect(@trocla.get_password('delete_test3','mysql')).to be_nil expect(@trocla.get_password('delete_test3','plain')).not_to be_nil end end context 'concurrent access' do context 'on expensive flagged formats' do before(:each) do expect(Trocla::Formats).to receive(:[]).with('sleep').at_least(:once).and_return(Trocla::Formats::Sleep) expect(Trocla::Formats::Sleep).to receive(:expensive?).at_least(:once).and_return(true) expect(Trocla::Formats).to receive(:available?).with('sleep').at_least(:once).and_return(true) end it 'should not overwrite a value if it takes longer' do t1 = Thread.new{ @trocla.password('threadpwd','sleep','sleep' => 4) } t2 = Thread.new{ @trocla.password('threadpwd','sleep','sleep' => 1) } pwd1 = t1.value pwd2 = t2.value real_value = @trocla.password('threadpwd','sleep') # as t2 finished first this should win expect(pwd1).to eql(pwd2) expect(real_value).to eql(pwd1) expect(real_value).to eql(pwd2) end end context 'on inexpensive flagged formats' do before(:each) do expect(Trocla::Formats).to receive(:[]).with('sleep').at_least(:once).and_return(Trocla::Formats::Sleep) expect(Trocla::Formats::Sleep).to receive(:expensive?).at_least(:once).and_return(false) expect(Trocla::Formats).to receive(:available?).with('sleep').at_least(:once).and_return(true) end it 'should not overwrite a value if it takes longer' do t1 = Thread.new{ @trocla.password('threadpwd_inexp','sleep','sleep' => 4) } t2 = Thread.new{ @trocla.password('threadpwd_inexp','sleep','sleep' => 1) } pwd1 = t1.value pwd2 = t2.value real_value = @trocla.password('threadpwd_inexp','sleep') # as t2 finished first but the format is inexpensive it gets overwritten expect(pwd1).not_to eql(pwd2) expect(real_value).to eql(pwd1) expect(real_value).not_to eql(pwd2) end end context 'real world example' do it 'should store the quicker one' do t1 = Thread.new{ @trocla.password('threadpwd_real','bcrypt','cost' => 17) } t2 = Thread.new{ @trocla.password('threadpwd_real','bcrypt') } pwd1 = t1.value pwd2 = t2.value real_value = @trocla.password('threadpwd_real','bcrypt') # t2 should still win but both should be the same expect(pwd1).to eql(pwd2) expect(real_value).to eql(pwd1) expect(real_value).to eql(pwd2) end it 'should store the quicker one test 2' do t1 = Thread.new{ @trocla.password('my_shiny_selfsigned_ca', 'x509', { 'CN' => 'This is my self-signed certificate', 'become_ca' => false, }) } t2 = Thread.new{ @trocla.password('my_shiny_selfsigned_ca', 'x509', { 'CN' => 'This is my self-signed certificate', 'become_ca' => false, }) } cert1 = t1.value cert2 = t2.value real_value = @trocla.password('my_shiny_selfsigned_ca','x509') # t2 should still win but both should be the same expect(cert1).to eql(cert2) expect(real_value).to eql(cert1) expect(real_value).to eql(cert2) end end end end context 'with .open' do it 'closes the connection with a block' do expect_any_instance_of(Trocla::Stores::Memory).to receive(:close) Trocla.open{|t| t.password('plain_open','plain') } end it 'keeps the connection without a block' do expect_any_instance_of(Trocla::Stores::Memory).not_to receive(:close) Trocla.open.password('plain_open','plain') end end def format_options @format_options ||= Hash.new({}).merge({ 'pgsql' => { 'username' => 'test' }, 'x509' => { 'CN' => 'test' }, }) end end describe "VERSION" do it "returns a version" do expect(Trocla::VERSION::STRING).not_to be_empty end end trocla-0.6.0/spec/trocla/0000755000004100000410000000000014756064121015255 5ustar www-datawww-datatrocla-0.6.0/spec/trocla/encryptions/0000755000004100000410000000000014756064121017632 5ustar www-datawww-datatrocla-0.6.0/spec/trocla/encryptions/none_spec.rb0000644000004100000410000000114314756064121022127 0ustar www-datawww-datarequire File.expand_path(File.dirname(__FILE__) + '/../../spec_helper') describe "Trocla::Encryptions::None" do before(:each) do expect_any_instance_of(Trocla).to receive(:read_config).and_return(test_config_persistent) @trocla = Trocla.new end after(:each) do remove_yaml_store end describe "none" do include_examples 'encryption_basics' it "stores plaintext passwords" do @trocla.set_password('noplain', 'plain', 'plaintext_password') expect(File.readlines(trocla_yaml_file).grep(/plaintext_password/)).to eq([" plain: plaintext_password\n"]) end end end trocla-0.6.0/spec/trocla/encryptions/ssl_spec.rb0000644000004100000410000000077214756064121022000 0ustar www-datawww-datarequire File.expand_path(File.dirname(__FILE__) + '/../../spec_helper') describe "Trocla::Encryptions::Ssl" do before(:all) do generate_ssl_keys end after(:all) do remove_ssl_keys end before(:each) do expect_any_instance_of(Trocla).to receive(:read_config).and_return(ssl_test_config) @trocla = Trocla.new end after(:each) do remove_yaml_store end describe "encrypt" do include_examples 'encryption_basics' include_examples 'verify_encryption' end end trocla-0.6.0/spec/trocla/util_spec.rb0000644000004100000410000000271514756064121017576 0ustar www-datawww-datarequire File.expand_path(File.dirname(__FILE__) + '/../spec_helper') describe "Trocla::Util" do { :random_str => 12, :salt => 8 }.each do |m,length| describe m do it "is random" do expect(Trocla::Util.send(m)).not_to eq(Trocla::Util.send(m)) end it "defaults to length #{length}" do expect(Trocla::Util.send(m).length).to eq(length) end it "is possible to change length" do expect(Trocla::Util.send(m,8).length).to eq(8) expect(Trocla::Util.send(m,32).length).to eq(32) expect(Trocla::Util.send(m,1).length).to eq(1) end end end describe :numeric_generator do 10.times.each do |i| it "creates random numeric password #{i}" do expect(Trocla::Util.random_str(12, 'numeric')).to match(/^[0-9]{12}$/) end end end describe :hexadecimal_generator do 10.times.each do |i| it "creates random hexadecimal password #{i}" do expect(Trocla::Util.random_str(12, 'hexadecimal')).to match(/^[0-9a-f]{12}$/) end end end describe :typesafe_generator do 10.times.each do |i| it "creates random typesafe password #{i}" do expect(Trocla::Util.random_str(12, 'typesafe')).to match(/^[1-9a-hj-km-xA-HJ-KM-X]{12}$/) end end end describe :salt do 10.times.each do |i| it "contains only characters and numbers #{i}" do expect(Trocla::Util.salt).to match(/^[a-z0-9]+$/i) end end end end trocla-0.6.0/spec/trocla/store/0000755000004100000410000000000014756064121016411 5ustar www-datawww-datatrocla-0.6.0/spec/trocla/store/memory_spec.rb0000644000004100000410000000033114756064121021255 0ustar www-datawww-datarequire File.expand_path(File.dirname(__FILE__) + '/../../spec_helper') require 'trocla/stores/memory' describe Trocla::Stores::Memory do include_examples 'store_validation', Trocla::Stores::Memory.new({},nil) end trocla-0.6.0/spec/trocla/store/moneta_spec.rb0000644000004100000410000000037414756064121021237 0ustar www-datawww-datarequire File.expand_path(File.dirname(__FILE__) + '/../../spec_helper') require 'trocla/stores/moneta' describe Trocla::Stores::Moneta do include_examples 'store_validation', Trocla::Stores::Moneta.new({'adapter' => :Memory},{:expires => true}) end trocla-0.6.0/spec/trocla/hooks_spec.rb0000644000004100000410000000267714756064121017753 0ustar www-datawww-datarequire File.expand_path(File.dirname(__FILE__) + '/../spec_helper') describe "Trocla::Hooks::Runner" do before(:each) do expect_any_instance_of(Trocla).to receive(:read_config).and_return(hooks_config) @trocla = Trocla.new end after(:each) do Trocla::Hooks.set_messages.clear Trocla::Hooks.delete_messages.clear end describe 'running hooks' do describe 'setting password' do it "calls the set hook" do @trocla.password('random1', 'plain') expect(Trocla::Hooks.set_messages.length).to eql(1) expect(Trocla::Hooks.delete_messages.length).to eql(0) expect(Trocla::Hooks.set_messages.first).to eql("random1_plain") end end describe 'deleting password' do it "calls the delete hook" do @trocla.delete_password('random1', 'plain') expect(Trocla::Hooks.delete_messages.length).to eql(1) expect(Trocla::Hooks.set_messages.length).to eql(0) expect(Trocla::Hooks.delete_messages.first).to eql("random1_plain") end end describe 'reset password' do it "calls the delete and set hook" do @trocla.reset_password('random1', 'plain') expect(Trocla::Hooks.set_messages.length).to eql(1) expect(Trocla::Hooks.set_messages.first).to eql("random1_plain") expect(Trocla::Hooks.delete_messages.length).to eql(1) expect(Trocla::Hooks.delete_messages.first).to eql("random1_plain") end end end end trocla-0.6.0/spec/trocla/formats/0000755000004100000410000000000014756064121016730 5ustar www-datawww-datatrocla-0.6.0/spec/trocla/formats/sshkey_spec.rb0000644000004100000410000000332714756064121021602 0ustar www-datawww-datarequire File.expand_path(File.dirname(__FILE__) + '/../../spec_helper') describe "Trocla::Format::Sshkey" do before(:each) do expect_any_instance_of(Trocla).to receive(:read_config).and_return(test_config) @trocla = Trocla.new end let(:sshkey_options) do { 'type' => 'rsa', 'bits' => 4096, 'comment' => 'My ssh key' } end describe "sshkey" do it "is able to create an ssh keypair without options" do sshkey = @trocla.password('my_ssh_keypair', 'sshkey', {}) expect(sshkey).to start_with('-----BEGIN RSA PRIVATE KEY-----') expect(sshkey).to match(/ssh-/) end it "is able to create an ssh keypair with options" do sshkey = @trocla.password('my_ssh_keypair', 'sshkey', sshkey_options) expect(sshkey).to start_with('-----BEGIN RSA PRIVATE KEY-----') expect(sshkey).to match(/ssh-/) expect(sshkey).to end_with('My ssh key') end it 'supports fetching only the priv key' do sshkey = @trocla.password('my_ssh_keypair', 'sshkey', { 'render' => {'privonly' => true }}) expect(sshkey).to start_with('-----BEGIN RSA PRIVATE KEY-----') expect(sshkey).not_to match(/ssh-/) end it 'supports fetching only the pub key' do sshkey = @trocla.password('my_ssh_keypair', 'sshkey', { 'render' => {'pubonly' => true }}) expect(sshkey).to start_with('ssh-rsa') expect(sshkey).not_to match(/-----BEGIN RSA PRIVATE KEY-----/) end it "is able to create an ssh keypair with a passphrase" do sshkey = @trocla.password('my_ssh_keypair', 'sshkey', { 'passphrase' => 'spec' }) expect(sshkey).to start_with('-----BEGIN RSA PRIVATE KEY-----') expect(sshkey).to match(/ssh-/) end end end trocla-0.6.0/spec/trocla/formats/pgsql_spec.rb0000644000004100000410000000137014756064121021416 0ustar www-datawww-datarequire File.expand_path(File.dirname(__FILE__) + '/../../spec_helper') describe 'Trocla::Format::Pgsql' do before(:each) do expect_any_instance_of(Trocla).to receive(:read_config).and_return(test_config) @trocla = Trocla.new end describe 'default pgsql' do it 'create a pgsql password keypair without options in sha256' do pass = @trocla.password('pgsql_password_sh256', 'pgsql', {}) expect(pass).to match(/^SCRAM-SHA-256\$(.*):(.*)\$(.*):/) end end describe 'pgsql in md5 encode' do it 'create a pgsql password in md5 encode' do pass = @trocla.password( 'pgsql_password_md5', 'pgsql', { 'username' => 'toto', 'encode' => 'md5' } ) expect(pass).to match(/^md5/) end end end trocla-0.6.0/spec/trocla/formats/x509_spec.rb0000644000004100000410000004321114756064121020775 0ustar www-datawww-datarequire File.expand_path(File.dirname(__FILE__) + '/../../spec_helper') require 'date' describe "Trocla::Format::X509" do before(:each) do expect_any_instance_of(Trocla).to receive(:read_config).and_return(test_config) @trocla = Trocla.new end let(:ca_options) do { 'CN' => 'This is my self-signed certificate which doubles as CA', 'become_ca' => true, } end let(:cert_options) do { 'ca' => 'my_shiny_selfsigned_ca', 'subject' => '/C=ZZ/O=Trocla Inc./CN=test/emailAddress=example@example.com', } end def verify(ca,cert) store = OpenSSL::X509::Store.new store.purpose = OpenSSL::X509::PURPOSE_SSL_CLIENT Array(ca).each do |c| store.add_cert(c) end store.verify(cert) end describe "x509 selfsigned" do it "is able to create self signed cert without being a ca by default" do cert_str = @trocla.password('my_shiny_selfsigned_ca', 'x509', { 'CN' => 'This is my self-signed certificate', 'become_ca' => false, }) cert = OpenSSL::X509::Certificate.new(cert_str) # selfsigned? expect(cert.issuer.to_s).to eq(cert.subject.to_s) # default size # https://stackoverflow.com/questions/13747212/determine-key-size-from-public-key-pem-format expect(cert.public_key.n.num_bytes * 8).to eq(4096) expect((Date.parse(cert.not_after.localtime.to_s) - Date.today).to_i).to eq(365) # it's a self signed cert and NOT a CA # Change of behavior on openssl side: https://github.com/openssl/openssl/issues/15146 validates_self_even_if_no_ca = RUBY_ENGINE == 'jruby' ? true : Gem::Version.new(%x{openssl version}.split(' ')[1]) > Gem::Version.new('1.1.1g') expect(verify(cert,cert)).to be validates_self_even_if_no_ca v = cert.extensions.find{|e| e.oid == 'basicConstraints' }.value expect(v).to eq('CA:FALSE') # we want to include only CNs that look like a DNS name expect(cert.extensions.find{|e| e.oid == 'subjectAltName' }).to be_nil ku = cert.extensions.find{|e| e.oid == 'keyUsage' }.value expect(ku).not_to match(/Certificate Sign/) expect(ku).not_to match(/CRL Sign/) end it "is able to create a self signed cert that is a CA" do ca_str = @trocla.password('my_shiny_selfsigned_ca', 'x509', ca_options) ca = OpenSSL::X509::Certificate.new(ca_str) # selfsigned? expect(ca.issuer.to_s).to eq(ca.subject.to_s) expect((Date.parse(ca.not_after.localtime.to_s) - Date.today).to_i).to eq(365) expect(verify(ca,ca)).to be true v = ca.extensions.find{|e| e.oid == 'basicConstraints' }.value expect(v).to eq('CA:TRUE') ku = ca.extensions.find{|e| e.oid == 'keyUsage' }.value expect(ku).to match(/Certificate Sign/) expect(ku).to match(/CRL Sign/) end it "is able to create a self signed cert without any keyUsage restrictions" do cert_str = @trocla.password('my_shiny_selfsigned_without restrictions', 'x509', { 'CN' => 'This is my self-signed certificate', 'key_usages' => [], }) cert = OpenSSL::X509::Certificate.new(cert_str) # selfsigned? expect(cert.issuer.to_s).to eq(cert.subject.to_s) # default size # https://stackoverflow.com/questions/13747212/determine-key-size-from-public-key-pem-format expect(cert.public_key.n.num_bytes * 8).to eq(4096) expect((Date.parse(cert.not_after.localtime.to_s) - Date.today).to_i).to eq(365) # it's a self signed cert and NOT a CA, but has no keyUsage limitation expect(verify(cert,cert)).to be true v = cert.extensions.find{|e| e.oid == 'basicConstraints' }.value expect(v).to_not eq('CA:TRUE') expect(cert.extensions.find{|e| e.oid == 'keyUsage' }).to be_nil end it "is able to create a self signed cert with custom keyUsage restrictions" do cert_str = @trocla.password('my_shiny_selfsigned_without restrictions', 'x509', { 'CN' => 'This is my self-signed certificate', 'key_usages' => [ 'cRLSign', ], }) cert = OpenSSL::X509::Certificate.new(cert_str) # selfsigned? expect(cert.issuer.to_s).to eq(cert.subject.to_s) # default size # https://stackoverflow.com/questions/13747212/determine-key-size-from-public-key-pem-format expect(cert.public_key.n.num_bytes * 8).to eq(4096) expect((Date.parse(cert.not_after.localtime.to_s) - Date.today).to_i).to eq(365) # it's a self signed cert and NOT a CA, as it's key is restricted to only CRL Sign expect(verify(cert,cert)).to be false v = cert.extensions.find{|e| e.oid == 'basicConstraints' }.value expect(v).to_not eq('CA:TRUE') ku = cert.extensions.find{|e| e.oid == 'keyUsage' }.value expect(ku).to match(/CRL Sign/) expect(ku).not_to match(/Certificate Sign/) end end describe "x509 signed by a ca" do before(:each) do ca_str = @trocla.password('my_shiny_selfsigned_ca', 'x509', ca_options) @ca = OpenSSL::X509::Certificate.new(ca_str) end it 'is able to get a cert signed by the ca' do cert_str = @trocla.password('mycert', 'x509', cert_options) cert = OpenSSL::X509::Certificate.new(cert_str) expect(cert.issuer.to_s).to eq(@ca.subject.to_s) expect((Date.parse(cert.not_after.localtime.to_s) - Date.today).to_i).to eq(365) expect(verify(@ca,cert)).to be true v = cert.extensions.find{|e| e.oid == 'basicConstraints' }.value expect(v).to eq('CA:FALSE') ku = cert.extensions.find{|e| e.oid == 'keyUsage' }.value expect(ku).not_to match(/Certificate Sign/) expect(ku).not_to match(/CRL Sign/) end it 'supports fetching only the key' do cert_str = @trocla.password('mycert', 'x509', cert_options.merge('render' => {'keyonly' => true })) expect(cert_str).not_to match(/-----BEGIN CERTIFICATE-----/) expect(cert_str).to match(/-----BEGIN RSA PRIVATE KEY-----/) end it 'supports fetching only the publickey' do pkey_str = @trocla.password('mycert', 'x509', cert_options.merge('render' => {'publickeyonly' => true })) expect(pkey_str).not_to match(/-----BEGIN CERTIFICATE-----/) expect(pkey_str).to match(/-----BEGIN PUBLIC KEY-----/) end it 'supports fetching only the cert' do cert_str = @trocla.password('mycert', 'x509', cert_options.merge('render' => {'certonly' => true })) expect(cert_str).to match(/-----BEGIN CERTIFICATE-----/) expect(cert_str).not_to match(/-----BEGIN RSA PRIVATE KEY-----/) end it 'supports fetching only the cert even a second time' do cert_str = @trocla.password('mycert', 'x509', cert_options.merge('render' => {'certonly' => true })) expect(cert_str).to match(/-----BEGIN CERTIFICATE-----/) expect(cert_str).not_to match(/-----BEGIN RSA PRIVATE KEY-----/) cert_str = @trocla.password('mycert', 'x509', cert_options.merge('render' => {'certonly' => true })) expect(cert_str).to match(/-----BEGIN CERTIFICATE-----/) expect(cert_str).not_to match(/-----BEGIN RSA PRIVATE KEY-----/) end it 'does not simply increment the serial' do cert_str = @trocla.password('mycert', 'x509', cert_options) cert1 = OpenSSL::X509::Certificate.new(cert_str) cert_str = @trocla.password('mycert2', 'x509', cert_options) cert2 = OpenSSL::X509::Certificate.new(cert_str) expect(cert1.serial.to_i).not_to eq(1) expect(cert2.serial.to_i).not_to eq(2) expect((cert2.serial - cert1.serial).to_i).not_to eq(1) end it 'is able to get a cert signed by the ca that is again a ca' do cert_str = @trocla.password('mycert', 'x509', cert_options.merge({ 'become_ca' => true, })) cert = OpenSSL::X509::Certificate.new(cert_str) expect(cert.issuer.to_s).to eq(@ca.subject.to_s) expect((Date.parse(cert.not_after.localtime.to_s) - Date.today).to_i).to eq(365) expect(verify(@ca,cert)).to be true expect(cert.extensions.find{|e| e.oid == 'basicConstraints' }.value).to eq('CA:TRUE') ku = cert.extensions.find{|e| e.oid == 'keyUsage' }.value expect(ku).to match(/Certificate Sign/) expect(ku).to match(/CRL Sign/) end it 'supports simple name constraints for CAs' do ca2_str = @trocla.password('mycert_with_nc', 'x509', cert_options.merge({ 'name_constraints' => ['example.com','bla.example.net'], 'become_ca' => true, })) ca2 = OpenSSL::X509::Certificate.new(ca2_str) expect(ca2.issuer.to_s).to eq(@ca.subject.to_s) expect((Date.parse(ca2.not_after.localtime.to_s) - Date.today).to_i).to eq(365) pending_for(:engine => 'jruby',:reason => 'NameConstraints verification seem to be broken in jRuby: https://github.com/jruby/jruby/issues/3502') do expect(verify(@ca,ca2)).to be true end expect(ca2.extensions.find{|e| e.oid == 'basicConstraints' }.value).to eq('CA:TRUE') ku = ca2.extensions.find{|e| e.oid == 'keyUsage' }.value expect(ku).to match(/Certificate Sign/) expect(ku).to match(/CRL Sign/) nc = ca2.extensions.find{|e| e.oid == 'nameConstraints' }.value pending_for(:engine => 'jruby',:reason => 'NameConstraints verification seem to be broken in jRuby: https://github.com/jruby/jruby/issues/3502') do expect(nc).to match(/Permitted:\n DNS:example.com\n DNS:bla.example.net/) end valid_cert_str = @trocla.password('myvalidexamplecert','x509', { 'subject' => '/C=ZZ/O=Trocla Inc./CN=foo.example.com/emailAddress=example@example.com', 'ca' => 'mycert_with_nc' }) valid_cert = OpenSSL::X509::Certificate.new(valid_cert_str) expect(valid_cert.issuer.to_s).to eq(ca2.subject.to_s) expect(verify([@ca,ca2],valid_cert)).to be true expect((Date.parse(valid_cert.not_after.localtime.to_s) - Date.today).to_i).to eq(365) false_cert_str = @trocla.password('myfalseexamplecert','x509', { 'subject' => '/C=ZZ/O=Trocla Inc./CN=foo.example.net/emailAddress=example@example.com', 'ca' => 'mycert_with_nc' }) false_cert = OpenSSL::X509::Certificate.new(false_cert_str) expect(false_cert.issuer.to_s).to eq(ca2.subject.to_s) expect(verify([@ca,ca2],false_cert)).to be false expect((Date.parse(false_cert.not_after.localtime.to_s) - Date.today).to_i).to eq(365) end it 'supports simple name constraints for CAs with leading dots' do ca2_str = @trocla.password('mycert_with_nc', 'x509', cert_options.merge({ 'name_constraints' => ['.example.com','.bla.example.net'], 'become_ca' => true, })) ca2 = OpenSSL::X509::Certificate.new(ca2_str) expect(ca2.issuer.to_s).to eq(@ca.subject.to_s) expect((Date.parse(ca2.not_after.localtime.to_s) - Date.today).to_i).to eq(365) pending_for(:engine => 'jruby',:reason => 'NameConstraints verification seem to be broken in jRuby: https://github.com/jruby/jruby/issues/3502') do expect(verify(@ca,ca2)).to be true end expect(ca2.extensions.find{|e| e.oid == 'basicConstraints' }.value).to eq('CA:TRUE') ku = ca2.extensions.find{|e| e.oid == 'keyUsage' }.value expect(ku).to match(/Certificate Sign/) expect(ku).to match(/CRL Sign/) nc = ca2.extensions.find{|e| e.oid == 'nameConstraints' }.value expect(nc).to match(/Permitted:\n DNS:.example.com\n DNS:.bla.example.net/) valid_cert_str = @trocla.password('myvalidexamplecert','x509', { 'subject' => '/C=ZZ/O=Trocla Inc./CN=foo.example.com/emailAddress=example@example.com', 'ca' => 'mycert_with_nc' }) valid_cert = OpenSSL::X509::Certificate.new(valid_cert_str) expect(valid_cert.issuer.to_s).to eq(ca2.subject.to_s) expect((Date.parse(valid_cert.not_after.localtime.to_s) - Date.today).to_i).to eq(365) # workaround broken openssl if Gem::Version.new(%x{openssl version}.split(' ')[1]) < Gem::Version.new('1.0.2') skip_for(:engine => 'ruby',:reason => 'NameConstraints verification is broken on older openssl versions https://rt.openssl.org/Ticket/Display.html?id=3562') do expect(verify([@ca,ca2],valid_cert)).to be true end else expect(verify([@ca,ca2],valid_cert)).to be true end false_cert_str = @trocla.password('myfalseexamplecert','x509', { 'subject' => '/C=ZZ/O=Trocla Inc./CN=foo.example.net/emailAddress=example@example.com', 'ca' => 'mycert_with_nc' }) false_cert = OpenSSL::X509::Certificate.new(false_cert_str) expect(false_cert.issuer.to_s).to eq(ca2.subject.to_s) expect((Date.parse(false_cert.not_after.localtime.to_s) - Date.today).to_i).to eq(365) expect(verify([@ca,ca2],false_cert)).to be false end it 'is able to get a cert signed by the ca that is again a ca that is able to sign certs' do ca2_str = @trocla.password('mycert_and_ca', 'x509', cert_options.merge({ 'become_ca' => true, })) ca2 = OpenSSL::X509::Certificate.new(ca2_str) expect(ca2.issuer.to_s).to eq(@ca.subject.to_s) expect((Date.parse(ca2.not_after.localtime.to_s) - Date.today).to_i).to eq(365) expect(verify(@ca,ca2)).to be true cert2_str = @trocla.password('mycert', 'x509', { 'ca' => 'mycert_and_ca', 'subject' => '/C=ZZ/O=Trocla Inc./CN=test2/emailAddress=example@example.com', 'become_ca' => true, }) cert2 = OpenSSL::X509::Certificate.new(cert2_str) expect(cert2.issuer.to_s).to eq(ca2.subject.to_s) expect((Date.parse(cert2.not_after.localtime.to_s) - Date.today).to_i).to eq(365) skip_for(:engine => 'jruby',:reason => 'Chained CA validation seems to be broken on jruby atm.') do expect(verify([@ca,ca2],cert2)).to be true end end it 'respects all options' do co = cert_options.merge({ 'hash' => 'sha512', 'keysize' => 2048, 'days' => 3650, 'subject' => nil, 'C' => 'AA', 'ST' => 'Earth', 'L' => 'Here', 'O' => 'SSLTrocla', 'OU' => 'root', 'CN' => 'www.test', 'emailAddress' => 'test@example.com', 'altnames' => [ 'test', 'test1', 'test2', 'test3' ], }) cert_str = @trocla.password('mycert', 'x509', co) cert = OpenSSL::X509::Certificate.new(cert_str) expect(cert.issuer.to_s).to eq(@ca.subject.to_s) ['C','ST','L','O','OU','CN'].each do |field| expect(cert.subject.to_s).to match(/#{field}=#{co[field]}/) end expect(cert.subject.to_s).to match(/(Email|emailAddress)=#{co['emailAddress']}/) expect(cert.signature_algorithm).to eq('sha512WithRSAEncryption') expect(cert.not_before).to be < Time.now expect((Date.parse(cert.not_after.localtime.to_s) - Date.today).to_i).to eq(3650) # https://stackoverflow.com/questions/13747212/determine-key-size-from-public-key-pem-format expect(cert.public_key.n.num_bytes * 8).to eq(2048) expect(verify(@ca,cert)).to be true skip_for(:engine => 'jruby',:reason => 'subjectAltName represenation is broken in jruby-openssl -> https://github.com/jruby/jruby-openssl/pull/123') do expect(cert.extensions.find{|e| e.oid == 'subjectAltName' }.value).to eq('DNS:www.test, DNS:test, DNS:test1, DNS:test2, DNS:test3') end expect(cert.extensions.find{|e| e.oid == 'basicConstraints' }.value).to eq('CA:FALSE') ku = cert.extensions.find{|e| e.oid == 'keyUsage' }.value expect(ku).not_to match(/Certificate Sign/) expect(ku).not_to match(/CRL Sign/) end it 'shold not add subject alt name on empty array' do co = cert_options.merge({ 'CN' => 'www.test', 'altnames' => [] }) cert_str = @trocla.password('mycert', 'x509', co) cert = OpenSSL::X509::Certificate.new(cert_str) expect(cert.issuer.to_s).to eq(@ca.subject.to_s) expect((Date.parse(cert.not_after.localtime.to_s) - Date.today).to_i).to eq(365) expect(verify(@ca,cert)).to be true expect(cert.extensions.find{|e| e.oid == 'subjectAltName' }).to be_nil end it 'prefers full subject of single subject parts' do co = cert_options.merge({ 'C' => 'AA', 'ST' => 'Earth', 'L' => 'Here', 'O' => 'SSLTrocla', 'OU' => 'root', 'CN' => 'www.test', 'emailAddress' => 'test@example.net', }) cert_str = @trocla.password('mycert', 'x509', co) cert = OpenSSL::X509::Certificate.new(cert_str) ['C','ST','L','O','OU','CN'].each do |field| expect(cert.subject.to_s).not_to match(/#{field}=#{co[field]}/) end expect(cert.subject.to_s).not_to match(/(Email|emailAddress)=#{co['emailAddress']}/) expect((Date.parse(cert.not_after.localtime.to_s) - Date.today).to_i).to eq(365) expect(verify(@ca,cert)).to be true end it "is able to create a signed cert with custom keyUsage restrictions" do cert_str = @trocla.password('mycert_without_restrictions', 'x509', cert_options.merge({ 'CN' => 'sign only test', 'key_usages' => [ ], })) cert = OpenSSL::X509::Certificate.new(cert_str) # default size # https://stackoverflow.com/questions/13747212/determine-key-size-from-public-key-pem-format expect(cert.public_key.n.num_bytes * 8).to eq(4096) expect((Date.parse(cert.not_after.localtime.to_s) - Date.today).to_i).to eq(365) expect(cert.issuer.to_s).to eq(@ca.subject.to_s) expect(verify(@ca,cert)).to be true v = cert.extensions.find{|e| e.oid == 'basicConstraints' }.value expect(v).to_not eq('CA:TRUE') expect(cert.extensions.find{|e| e.oid == 'keyUsage' }).to be_nil end end end trocla-0.6.0/.rspec0000644000004100000410000000003714756064121014154 0ustar www-datawww-data--color --format documentation trocla-0.6.0/Rakefile0000644000004100000410000000277314756064121014515 0ustar www-datawww-data# encoding: utf-8 require 'rubygems' require 'bundler' begin Bundler.setup(:default, :development) rescue Bundler::BundlerError => e $stderr.puts e.message $stderr.puts "Run `bundle install` to install missing gems" exit e.status_code end require 'rake' require 'jeweler' $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), 'lib')) require 'trocla' Jeweler::Tasks.new do |gem| # gem is a Gem::Specification... see http://docs.rubygems.org/read/chapter/20 for more options gem.name = "trocla" gem.homepage = "https://tech.immerda.ch/2011/12/trocla-get-hashed-passwords-out-of-puppet-manifests/" gem.license = "GPLv3" gem.summary = "Trocla a simple password generator and storage" gem.description = "Trocla helps you to generate random passwords and to store them in various formats (plain, MD5, bcrypt) for later retrival." gem.email = "mh+trocla@immerda.ch" gem.authors = ["mh"] gem.version = Trocla::VERSION::STRING # dependencies defined in Gemfile end Jeweler::RubygemsDotOrgTasks.new require 'rspec/core' require 'rspec/core/rake_task' RSpec::Core::RakeTask.new(:spec) do |spec| spec.pattern = FileList['spec/**/*_spec.rb'] end RSpec::Core::RakeTask.new(:rcov) do |spec| spec.pattern = 'spec/**/*_spec.rb' spec.rcov = true end task :default => :spec gem 'rdoc' require 'rdoc/task' RDoc::Task.new do |rdoc| version = Trocla::VERSION::STRING rdoc.rdoc_dir = 'rdoc' rdoc.title = "trocla #{version}" rdoc.rdoc_files.include('README*') rdoc.rdoc_files.include('lib/**/*.rb') end trocla-0.6.0/Gemfile0000644000004100000410000000102214756064121014325 0ustar www-datawww-datasource 'http://rubygems.org' # Add dependencies required to use your gem here. # Example: # gem "activesupport", ">= 2.3.5" gem 'highline', '~> 2.0.0' gem 'moneta', '~> 1.0' if defined?(RUBY_ENGINE) && (RUBY_ENGINE == 'jruby') gem 'jruby-openssl' end gem 'bcrypt' gem 'sshkey' # Add dependencies to develop your gem here. # Include everything needed to run rake, tests, features, etc. group :development do gem 'rake' gem 'addressable' gem 'jeweler', '~> 2.0' gem 'rdoc' gem 'rspec' gem 'rspec-pending_for' end trocla-0.6.0/ext/0000755000004100000410000000000014756064121013637 5ustar www-datawww-datatrocla-0.6.0/ext/redhat/0000755000004100000410000000000014756064121015106 5ustar www-datawww-datatrocla-0.6.0/ext/redhat/rubygem-trocla.spec0000644000004100000410000000576414756064121020732 0ustar www-datawww-data# Generated from trocla-0.1.2.gem by gem2rpm -*- rpm-spec -*- %global gem_name trocla Name: rubygem-%{gem_name} Version: 0.3.0 Release: 1%{?dist} Summary: Trocla a simple password generator and storage Group: Development/Languages License: GPLv3 URL: https://tech.immerda.ch/2011/12/trocla-get-hashed-passwords-out-of-puppet-manifests/ Source0: https://rubygems.org/gems/%{gem_name}-%{version}.gem Requires: rubygem-moneta Requires: rubygem-bcrypt Requires: rubygem-highline BuildRequires: rubygem-moneta = 0.7.20 BuildRequires: rubygem-bcrypt BuildRequires: rubygem-highline %if 0%{?rhel} >= 7 BuildRequires: ruby(release) %endif BuildRequires: rubygems-devel BuildRequires: ruby # BuildRequires: rubygem(mocha) # BuildRequires: rubygem(rspec) => 2.4 # BuildRequires: rubygem(rspec) < 3 # BuildRequires: rubygem(jeweler) => 1.6 # BuildRequires: rubygem(jeweler) < 2 BuildArch: noarch %description Trocla helps you to generate random passwords and to store them in various formats (plain, MD5, bcrypt) for later retrival. %package doc Summary: Documentation for %{name} Group: Documentation Requires: %{name} = %{version}-%{release} BuildArch: noarch %description doc Documentation for %{name}. %prep gem unpack %{SOURCE0} %setup -q -D -T -n %{gem_name}-%{version} gem spec %{SOURCE0} -l --ruby > %{gem_name}.gemspec %build # Create the gem as gem install only works on a gem file gem build %{gem_name}.gemspec # %%gem_install compiles any C extensions and installs the gem into ./%%gem_dir # by default, so that we can move it into the buildroot in %%install %gem_install %install mkdir -p %{buildroot}%{gem_dir} cp -a .%{gem_dir}/* \ %{buildroot}%{gem_dir}/ mkdir -p %{buildroot}%{_bindir} mkdir -p %{buildroot}%{_sysconfdir} mkdir -p %{buildroot}/%{_sharedstatedir}/%{gem_name} touch %{buildroot}/%{_sharedstatedir}/%{gem_name}/%{gem_name}_data.yaml cp -pa .%{_bindir}/* \ %{buildroot}%{_bindir}/ chmod a+x %{buildroot}%{gem_instdir}/bin/%{gem_name} cat < %{buildroot}/%{_sysconfdir}/%{gem_name}rc.yaml --- store: :moneta store_options: adapter: :YAML adapter_options: :file: '%{_sharedstatedir}/%{gem_name}/%{gem_name}_data.yaml' EOF # Run the test suite %check pushd .%{gem_instdir} popd %files %dir %{gem_instdir} %{_bindir}/trocla %{gem_instdir}/.rspec %exclude %{gem_instdir}/.travis.yml %exclude %{gem_instdir}/.rspec %exclude %{gem_instdir}/ext/redhat/%{name}.spec %license %{gem_instdir}/LICENSE.txt %{gem_instdir}/bin %{gem_libdir} %exclude %{gem_cache} %{gem_spec} %config(noreplace) %{_sysconfdir}/%{gem_name}rc.yaml %dir %attr(-, -, -) %{_sharedstatedir}/%{gem_name} %config(noreplace) %attr(660, root, root) %{_sharedstatedir}/%{gem_name}/%{gem_name}_data.yaml %files doc %doc %{gem_docdir} %doc %{gem_instdir}/.document %{gem_instdir}/Gemfile %doc %{gem_instdir}/README.md %doc %{gem_instdir}/CHANGELOG.md %{gem_instdir}/Rakefile %{gem_instdir}/spec %{gem_instdir}/trocla.gemspec %changelog * Mon Dec 21 2015 mh - 0.2.0-1 - Release of v0.2.0 * Sun Jun 21 2015 mh - 0.1.2-1 - Initial package trocla-0.6.0/README.md0000644000004100000410000004671114756064121014327 0ustar www-datawww-data# trocla [![Ruby](https://github.com/duritong/trocla/actions/workflows/ruby.yml/badge.svg)](https://github.com/duritong/trocla/actions/workflows/ruby.yml) Trocla provides you a simple way to create and store (random) passwords on a central server, which can be retrieved by other applications. An example for such an application is puppet and trocla can help you to not store any plaintext or hashed passwords in your manifests by keeping these passwords only on your puppetmaster. Furthermore it provides you a simple cli that helps you to modify the password storage from the cli. Trocla does not only create and/or store a plain password, it is also able to generate (and store) any kind of hashed passwords based on the plain password. As long as the plain password is preset, trocla is able to generate any kind of hashed passwords through an easy extendible plugin system. It is not necessary to store the plain password on the server, you can also just feed trocla with the hashed password and use that in your other tools. A common example for that is that you let puppet retrieve (and hence create) a salted sha512 password for a user. This will then store the salted sha512 of a random password AND the plain text password in trocla. Later you can retrieve (by deleting) the plain password and send it to the user. Puppet will still simply retrieve the hashed password that is stored in trocla, while the plain password is not anymore stored on the server. By default trocla uses moneta to store the passwords and can use any kind of key/value based storage supported by moneta for trocla. By default it uses a simple yaml file. However, since version 0.2.0 trocla also supports a pluggable storage backend which allows you to write your custom backend. See more about stores below. Trocla can also be integrated into [Hiera](https://docs.puppetlabs.com/hiera/) by using ZeroPointEnergy's [hiera-backend](https://github.com/ZeroPointEnergy/hiera-backend-trocla). ## Usage ### create Assuming that we have an empty trocla storage. trocla create user1 plain This will create (if not already stored in trocla) a random password and store its plain text under key user1. The password will also be returned by trocla. trocla create user2 mysql This will create a random password and store its plain and mysql-style hashed sha1 password in trocla. The hashed password is returned. trocla create user1 mysql This will take the already stored plain text password of key user1 and generate and store the mysql-style hashed sha1 password. It is possible that certain hash formats require additional options. For example the pgsql hash requires also the user to create the md5 hash for the password. You can pass these additional requirements as yaml-based strings to the format: trocla create user1 pgsql 'username: user1' This will create a pgsql password hash using the username user1. Valid global options are: * length: int - Define any lenght that a newly created password should have. Default: 16 - or whatever you define in your global settings. * charset: (default|alphanumeric|shellsafe) - Which set of chars should be used for a random password? Default: default - or whatever you define in your global settings. * profiles: a profile name or an array of profiles matching a profile_name in your configuration. Learn more about profiles below. * random: boolean - Whether we allow creation of random passwords or we expect a password to be preset. Default: true - or whatever you define in your global settings. * expires: An integer indicating the amount of seconds a value (e.g. password) is available. After expiration a value will not be available anymore and trying to `get` this key will return no value (nil). Meaning that calling create after expiration, would create a new password automatically. There is more about expiration in the storage backends section. * render: A hash providing flags for formats to render the output specifially. This is a global option, but support depends on a per format basis. Example: trocla create some_shellsafe_password plain 'charset: shellsafe' trocla create another_alphanumeric_20_char_password plain "charset: alphanumeric length: 20" ### get Get simply returns a stored password. It will not create a new password. Assuming that we are still working with the same storage trocla get user2 plain will return the plain text password of the key user2. trocla get user3 plain This will return nothing, as no password with this format have been stored so far. ### set trocla set user3 plain This will ask you for a password and set it under the appropriate key/format. We expect a plain password to be entered and will format the password with the selected format before storing it. trocla set --password mysupersecretpassword user4 plain This will take the password from the cli without asking you. trocla set user5 mysql -p mysuperdbpassword This will store a mysql sha1 hash for the key user5, without storing any kind of plain text password. If you like trocla not to format a password, as you are passing in an already formatted password (like the sha512 hash), then you must use `--no-format` to skip formatting. Like: trocla set user5 sha512crypt --no-format -p '$6$1234$xxxx....' You can also pipe in a password: echo -n foo | trocla set user6 plain -p or a file cat some_file | trocla set user6 plain -p trocla set user6 plain -p < some_file ### reset trocla reset user1 md5crypt This will recreate the salted md5 shadow-style hash. However, it will not create a new plain text passwords. Hence, this is mainly usefull to create new hashed passwords based on new salts. If the plain password of a key is resetted, every already hashed password is deleted as well, as the hashes wouldn't match anymore the plain text password. ### delete trocla delete user1 plain This will delete the plain password of the key user1 and return it. ### formats trocla formats This will list all available and supported formats. ## Attention If you don't feed trocla initially with a hash and/or delete the generated plain text passwords trocla will likely create a lot of plain text passwords and store them on your machine/server. This is by intend and is all about which problems (mainly passwords in configuration management manifests) trocla tries to address. It is possible to store all passwords encrypted in the specific backend. See backend encryption for more information, however be aware that the key must always also reside on the trocla node. So it mainly makes sense if you store them on a remote backend like a central database server. ## Formats Most formats are straight forward to use. Some formats require some additional options to work properly. These are documented here: ### pgsql Password hashes for PostgreSQL servers. Since postgesql 10 you can use the sha256 hash, you have two options: * Create a ssh256 hash password with option `encode: sha256` (default value) * Create a md5 hash, the username is require for the salt key, with option `encode: md5` and `username: your_user` ### bcrypt You are able to tune the [cost factor of bcrypt](https://github.com/codahale/bcrypt-ruby#cost-factors) by passing the option `cost`. Note: ruby bcrypt does not support a [cost > 31](https://github.com/codahale/bcrypt-ruby/blob/master/lib/bcrypt/password.rb#L45). ### x509 This format takes a set of additional options. Required are: subject: A subject for the target certificate. E.g. /C=ZZ/O=Trocla Inc./CN=test/emailAddress=example@example.com OR CN: The CN of the the target certificate. E.g. 'This is my self-signed certificate which doubles as CA' Additional options are: ca The trocla key of CA (imported into or generated within trocla) that will be used to sign that certificate. become_ca Whether the certificate should become a CA or not. Default: false, to enable set it to true. hash Hash to be used. Default sha2 keysize Keysize for the new key. Default is: 4096 serial Serial to be used, default is selecting a random one. days How many days should the certificate be valid. Default 365 C instead within the subject string ST instead within the subject string L instead within the subject string O instead within the subject string OU instead within the subject string emailAddress instead within the subject string key_usages Any specific key_usages different than the default ones. If you specify any, you must specify all that you want. If you don't want to have any, you must specify an empty array. altnames An array of subjectAltNames. By default for non CA certificates we ensure that the CN ends up here as well. If you don't want that. You need to pass an empty array. name_constraints An array of domains that are added as permitted x509 NameConstraint. By default, we do not add any contraint, meaning all domains are signable by the CA, as soon as we have one item in the list, only DNS entries matching this list are allowed. Be aware, that older openssl versions have a bug with [leading dots](https://rt.openssl.org/Ticket/Display.html?id=3562) for name constraints. So using them might not work everywhere as expected. Output render options are: certonly If set to true the x509 format will return only the certificate keyonly If set to true the x509 format will return only the private key publickeyonly If set to true the x509 format will return only the public key ### sshkey This format generate a ssh keypair Additional options are: type The ssh key type (rsa, dsa). Default: rsa bits Specifies the number of bits in the key to create. Default: 2048 comment Specifies a comment. passphrase Specifies a passphrase. Output render options are: pubonly If set to true the sshkey format will return only the ssh public key privonly If set to true the sshkey format will return only the ssh private key ### wireguard This format generate a keypair for WireGuard. The format requires the wg binary from WireGuard userland utilities. Output render options are: pubonly If set to true the wireguard format will return only the public key privonly If set to true the wireguard format will return only the private key ## Installation * Debian has trocla within its sid-release: `apt-get install trocla` * For RHEL/CentOS 7 there is a [copr reporisotry](https://copr.fedoraproject.org/coprs/duritong/trocla/). Follow the help there to integrate the repository and install trocla. * Trocla is also distributed as gem: `gem install trocla` ## Configuration Trocla can be configured in /etc/troclarc.yaml and in ~/.troclarc.yaml. A sample configuration file can be found in `lib/trocla/default_config.yaml`. By default trocla configures moneta to store all data in /tmp/trocla.yaml ### Profiles It is possible to define profiles within the configuration file. The idea behind profiles are to make it easy to group together certain options for automatic password generation. Trocla ships with a default set of profiles, which are part of the `lib/trocla/default_config.yaml` configuration file. It is possible to override the existing profiles within your own configuration file, as well as adding more. Note that the profiles part of the configuration file is merged together and your configuration file has precedence. The profiles part in the config is a hash where each entry consist of a name (key) and a hash of options (value). Profiles make it especially easy to define a preset of options for SSL certificates as you will only need to set the certificate specific options, while global options such as C, O or OU can be preset within the profile. Profiles are used by setting the profiles option to a name of the pre-configured profiles, when passing options to the password option. On the cli this looks like: trocla create foo plain 'profiles: rootpw' It is possible to pass mutliple profiles as an array, while the order will also reflect the precedence of the options. Also it is possible to set a default profiles option in the options part of the configuration file. ### Storage backends Trocla has a pluggable storage backend, which allows you to choose the way that values are stored (persistently). Such a store is a simple class that implements Trocla::Store and at the moment there are the following store implementations: * Moneta - the default store using [moneta](https://rubygems.org/gems/moneta) to delegate storing the values * Memory - simple inmemory backend. Mainly used for testing. * Vault - modern secrets storage by HashiCorp, require the ruby gem [vault](https://github.com/hashicorp/vault-ruby) The backend is chosen based on the `store` configuration option. If it is a symbol, we expect it to be a store that we ship with trocla. Otherwise, we assume it to be a fully qualified ruby class name, that inherits from Trocla::Store. If trocla should load an additional library to be able to find your custom store class, you can set `store_require` to whatever should be passed to a ruby require statement. Store backends can be configured through the `store_options` configuration. #### Expiration We expect storage backends to implement support for the `expires` option, so that keys expire after the passed amount of seconds. Furthermore a storage backend needs to implement the behaviour described by the rspec shared_example 'store_validation' section 'expiration'. Mainly: * Expiration is always for all formats per key. * Adding, deleting or updating a format will keep the existing expiration, but reset the planned expiration. * While setting a new plain format will not only erase all other formats, but also erase/reset any expires. * Setting a value with an expires option of 0 or false, will remove any existent expiration. New backends should be tested using the provided shared example. > **WARNING**: Vault backend use metadatas. It's set if an option is define. `expires` is automaticly change to `delete_version_after`, and you can use an interger or [format string](https://www.vaultproject.io/api-docs/secret/kv/kv-v2#parameters) #### Moneta backends Trocla uses moneta as its default storage backend and hence can store your passwords in any of moneta's supported backends. By default it uses the yaml backend, which is configured as followed: ```YAML store_options: adapter: :YAML adapter_options: :file: '/tmp/trocla.yaml' ``` In environments with multiple Puppet masters using an existing DB cluster might make sense. The configured user needs to be granted at least SELECT, INSERT, UPDATE, DELETE and CREATE permissions on your database: ```YAML store_options: adapter: :Sequel adapter_options: :db: 'mysql://db.server.name' :user: 'trocla' :password: '***' :database: 'trocladb' :table: 'trocla' ``` These examples are by no way complete, moneta has much more to offer. Please have a look at [moneta's documentation](https://github.com/minad/moneta/blob/master/README.md) for further information. #### Vault backend [Vault](https://www.vaultproject.io/) is a modern secret storage supported by HashiCorp, which works with a REST API. You can create multiple storage engine. To use vault with trocla you need to create a kv (key/value) storage engine on the vault side. Trocla can use [v1](https://www.vaultproject.io/docs/secrets/kv/kv-v1) and [v2](https://www.vaultproject.io/docs/secrets/kv/kv-v2) API endpoints, but it's recommended to use the v2 (native hash object, history, acl...). You need to install the `vault` gem to be able to use the vault backend, which is not included in the default dependencies for trocla. With vault storage, the terminology changes: * `mount`, this is the name of your kv engine * `key`, this is the biggest change. As usual with trocla, the key is a simple string. With the vault kv engine, the key map to a path, so you can have a key like `my/path/key` for structured your data * `secret`, is the data content of your key. This is a simple hash with key (format) and value (the secret content of your format) The trocla mapping works the same way as with a moneta or file backend. The `store_options` are a dynamic argument for initializer [Vault::Client](https://github.com/hashicorp/vault-ruby/blob/master/lib/vault/client.rb) class (except `:mount`, used to defined the kv name). You can define only one kv mount. ```YAML store: :vault store_options: :mount: kv :token: s.Tok3n :address: https://vault.local ``` With Vault when you delete a key, you don't delete all key content. The metadatas, like history, are still here and the endpoint are not delete. If you prefere to destroy all key content you can add `:destroy: true` in the `store_options:` hash. ### Backend encryption By default trocla does not encrypt anything it stores. You might want to let Trocla encrypt all your passwords, at the moment the only supported way is SSL. Given that often trocla's store is on the same system at it's being used, there might be little sense to encrypt everything while the encryption keys are on the same system. However, if you are for example using an existing DB cluster using backend encryption you won't store any plaintext passwords within the database system. ### Backend SSL encryption To enable SSL encryption (e.g. by using your puppet masters SSL keys), you need to set the following configuration options: ```YAML encryption: :ssl encryption_options: :private_key: '/var/lib/puppet/ssl/private_keys/trocla.pem' :public_key: '/var/lib/puppet/ssl/public_keys/trocla.pem' ``` ## Hooks You can specify hooks to be called whenever trocla sets or deletes a password. The idea is that this allows you to run custom code that can trigger further actions based on deleting or setting a password. Enabling hooks is done through the following configuration: ```YAML hooks: set: my_hook: /path/to/my_hook_file.rb delete: other_hook: /path/to/my_other_hook_file.rb ``` A hook must have the following implementation based on the above config: ```Ruby class Trocla module Hooks def self.my_hook(trocla, key, format, options) # [... your code ...] end end end ``` You can specify only either one or both kinds of hooks. Hooks must not raise any exceptions or interrupt the flow itself. They can also not change the value that was set or revert a deletion. However, they have Trocla itself available (through `trocla`) and you must ensure to not create infinite loops. ## Update & Changes See [Changelog](CHANGELOG.md) ## Contributing to trocla * Check out the latest master to make sure the feature hasn't been implemented or the bug hasn't been fixed yet * Check out the issue tracker to make sure someone already hasn't requested it and/or contributed it * Fork the project * Start a feature/bugfix branch * Commit and push until you are happy with your contribution * Make sure to add tests for it. This is important so I don't break it in a future version unintentionally. * Please try not to mess with the Rakefile, version, or history. If you want to have your own version, or is otherwise necessary, that is fine, but please isolate to its own commit so I can cherry-pick around it. ## Copyright Copyright (c) 2011-2015 mh. See LICENSE.txt for further details. trocla-0.6.0/CHANGELOG.md0000644000004100000410000001457214756064121014661 0ustar www-datawww-data# Changelog ## to 0.6.0 * move away from sha1 since they are not supported anymore on all distributions * fix tests on various platforms and newer ruby versions * introduce hooks for set and delete actions ## to 0.5.1 * support more moneta versions (#78) - Thank you [jcharaoui](https://github.com/jcharaoui) * Fix issue with passing down expires to vault (#79) - Thank you [Steffy Fort](https://github.com/fe80) * Don't require openssl Gem and make sure we don't regress on JRuby ## to 0.5.0 * moved from travis ci to github actions (#73) - Thank you [Georg-g](https://github.com/geor-g) * Support expire in vault (#71) - Thank you [Steffy Fort](https://github.com/fe80) * Syntax improvements (#70) - Thank you [Steffy Fort](https://github.com/fe80) * Add SCRAM-SHA-256 postgres support (#69) - Thank you [Steffy Fort](https://github.com/fe80) * Support destroying and entry to properly clean up in vault (#68) - Thank you [Steffy Fort](https://github.com/fe80) * Support search with vault backend (#67) - Thank you [Steffy Fort](https://github.com/fe80) * Add wireguard format (#65) - Thank you [Jonas Genannt](https://github.com/hggh) * Expand search path for sample config - Thank you [Anarcat](https://github.com/anarcat) ## to 0.4.0 * Add vault backend (#61) - Thank you [Steffy Fort](https://github.com/fe80) * Add sshkey format similar to the OpenSSL - Thank you [Raphaël Rondeau](https://github.com/rrondeau) * format/x509 allow to render 'publickeyonly' (#62) - Thank you [Thomas Weißschuh](https://github.com/t-8ch) * Add a method to search for keys and list all formats of a key (#49) - Thank you - [Steffy Fort](https://github.com/fe80) * Proper return code on cli (#57) - Thank you [Steffy Fort](https://github.com/fe80) * expand search path for sample config file to fix autopkgtest (#64) - Thank you [anarcat](https://github.com/anarcat) * drop support for ruby < 2.7 & update dependencies * skip self-signed cert verification test on newer openssl version (#63) * Fix reseting passwords when using SSL encryption (#52) ## to 0.3.0 * Add open method to be able to immediately close a trocla store after using it - thanks martinpfeiffer * Add typesafe charset - thanks hggh * Support cost option for bcrypt * address concurrency corner cases, when 2 concurrent threads or even processes are currently calculating the same (expensive) format. * parse additional options on cli (#39 & #46) - thanks fe80 ## to 0.2.3 1. Add extended CA validity profiles 1. Make it possible to define keyUsage ## to 0.2.2 1. Bugfix to render output correctly also on an already existing set 1. Fix tests not working around midnight, due to timezone differences ## to 0.2.1 1. New Feature: Introduce a way to render specific formats, mainly this allows you to control the output of a specific format. See the x509 format for more information. ## to 0.2.0 1. New feature profiles: Introduce profiles to make it easy to have a default set of properties. See the profiles section for more information. 1. New feature expiration: Make it possible that keys can have an expiration. See the expiration section for more information. 1. Increase default password length to 16. 1. Add a console safe password charset. It should provide a subset of chars that are easier to type on a physical keyboard. 1. Fix a bug with encryptions while deleting all formats. 1. Introduce pluggable stores, so in the future we are able to talk to different backends and not only moneta. For testing and inspiration a simple in memory storage backend was added. 1. CHANGE: moneta's configuration for `adapter` & `adapter_options` now live under store_options in the configuration file. Till 0.3.0 old configuration entries will still be accepted. 1. CHANGE: ssl_options is now known as encryption_options. Till 0.3.0 old configuration entries will still be accepted. 1. Improve randomness when creating a serial number. 1. Add a new charset: hexadecimal 1. Add support for name constraints within the x509 format 1. Clarify documentation of the set action, as well as introduce `--no-format` for the set action. ## to 0.1.3 1. CHANGE: Self signed certificates are no longer CAs by default, actually they have never been due to a bug. If you want that a certificate is also a CA, you *must* pass `become_ca: true` to the options hash. But this makes it actually possible, that you can even have certificate chains. Thanks for initial hint to [Adrien Bréfort](https://github.com/abrefort) 1. Default keysize is now 4096 1. SECURITY: Do not increment serial, rather choose a random one. 1. Fixing setting of altnames, was not possible due to bug, till now. 1. Add extended tests for the x509 format, that describe all the internal specialities and should give an idea how it can be used. 1. Add cli option to list all formats ## to 0.1.1 1. fix storing data longer that public Keysize -11. Thanks [Timo Goebel](https://github.com/timogoebel) 1. add a numeric only charset. Thanks [Jonas Genannt](https://github.com/hggh) 1. fix reading key expire time. Thanks [asquelt](https://github.com/asquelt) ## to 0.1.0 1. Supporting encryption of the backends. Many thanks to Thomas Gelf 1. Adding a windows safe password charset ## to 0.0.12 1. change from sha1 signature for the x509 format to sha2 1. Fix an issue where shellsafe characters might have already been initialized with shell-unsafe characters. Plz review any shell-safe character passwords regarding this problem. See the [fix](https://github.com/duritong/trocla/pull/19) for more information. Thanks [asquelt](https://github.com/asquelt) for the fix. ## to 0.0.8 1. be sure to update as well the moneta gem, trocla now uses the official moneta releases and supports current avaiable versions. 1. Options for moneta's backends have changed. For example, if you are using the yaml-backend you will likely need to change the adapter option `:path:` to `:file:` to match moneta's new API. 1. **IMPORTANT:** If you are using the yaml backend you need to migrate the current data *before* using the new trocla version! You can migrate the datastore by using the following two sed commands: `sed -i 's/^\s\{3\}/ /' /PATH/TO/trocla_data.yaml` && `sed -i '/^\s\{2\}value\:/d' /PATH/TO/trocla_data.yaml`. 1. **SECURITY:** Previous versions of trocla used quite a simple random generator. Especially in combination with the puppet `fqdn_rand` function, you likely have very predictable random passwords and I recommend you to regenerate all randomly generated passwords! Now! trocla-0.6.0/trocla.gemspec0000644000004100000410000000645714756064121015704 0ustar www-datawww-data# Generated by jeweler # DO NOT EDIT THIS FILE DIRECTLY # Instead, edit Jeweler::Tasks in Rakefile, and run 'rake gemspec' # -*- encoding: utf-8 -*- # stub: trocla 0.6.0 ruby lib Gem::Specification.new do |s| s.name = "trocla".freeze s.version = "0.6.0".freeze s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version= s.require_paths = ["lib".freeze] s.authors = ["mh".freeze] s.date = "2024-12-30" s.description = "Trocla helps you to generate random passwords and to store them in various formats (plain, MD5, bcrypt) for later retrival.".freeze s.email = "mh+trocla@immerda.ch".freeze s.executables = ["trocla".freeze] s.extra_rdoc_files = [ "LICENSE.txt", "README.md" ] s.files = [ ".document", ".github/workflows/ruby.yml", ".rspec", "CHANGELOG.md", "Gemfile", "LICENSE.txt", "README.md", "Rakefile", "bin/trocla", "ext/redhat/rubygem-trocla.spec", "lib/VERSION", "lib/trocla.rb", "lib/trocla/default_config.yaml", "lib/trocla/encryptions.rb", "lib/trocla/encryptions/none.rb", "lib/trocla/encryptions/ssl.rb", "lib/trocla/formats.rb", "lib/trocla/formats/bcrypt.rb", "lib/trocla/formats/md5crypt.rb", "lib/trocla/formats/mysql.rb", "lib/trocla/formats/pgsql.rb", "lib/trocla/formats/plain.rb", "lib/trocla/formats/sha1.rb", "lib/trocla/formats/sha256crypt.rb", "lib/trocla/formats/sha512crypt.rb", "lib/trocla/formats/ssha.rb", "lib/trocla/formats/sshkey.rb", "lib/trocla/formats/wireguard.rb", "lib/trocla/formats/x509.rb", "lib/trocla/hooks.rb", "lib/trocla/store.rb", "lib/trocla/stores.rb", "lib/trocla/stores/memory.rb", "lib/trocla/stores/moneta.rb", "lib/trocla/stores/vault.rb", "lib/trocla/util.rb", "lib/trocla/version.rb", "spec/data/.keep", "spec/fixtures/delete_test_hook.rb", "spec/fixtures/set_test_hook.rb", "spec/spec_helper.rb", "spec/trocla/encryptions/none_spec.rb", "spec/trocla/encryptions/ssl_spec.rb", "spec/trocla/formats/pgsql_spec.rb", "spec/trocla/formats/sshkey_spec.rb", "spec/trocla/formats/x509_spec.rb", "spec/trocla/hooks_spec.rb", "spec/trocla/store/memory_spec.rb", "spec/trocla/store/moneta_spec.rb", "spec/trocla/util_spec.rb", "spec/trocla_spec.rb", "trocla.gemspec" ] s.homepage = "https://tech.immerda.ch/2011/12/trocla-get-hashed-passwords-out-of-puppet-manifests/".freeze s.licenses = ["GPLv3".freeze] s.rubygems_version = "3.5.22".freeze s.summary = "Trocla a simple password generator and storage".freeze s.specification_version = 4 s.add_runtime_dependency(%q.freeze, ["~> 2.0.0".freeze]) s.add_runtime_dependency(%q.freeze, ["~> 1.0".freeze]) s.add_runtime_dependency(%q.freeze, [">= 0".freeze]) s.add_runtime_dependency(%q.freeze, [">= 0".freeze]) s.add_development_dependency(%q.freeze, [">= 0".freeze]) s.add_development_dependency(%q.freeze, [">= 0".freeze]) s.add_development_dependency(%q.freeze, ["~> 2.0".freeze]) s.add_development_dependency(%q.freeze, [">= 0".freeze]) s.add_development_dependency(%q.freeze, [">= 0".freeze]) s.add_development_dependency(%q.freeze, [">= 0".freeze]) end