commit 85a0a65ccb6146cd769fe9fb79e679580d2754eb Author: John Nishinaga Date: Tue Mar 22 09:11:41 2011 -0400 reset repository with new author information, license and version diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..244ec54 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +*.gem +.bundle +.yardoc +Gemfile.lock +doc +pkg diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..7438fbe --- /dev/null +++ b/.rspec @@ -0,0 +1,2 @@ +--colour +--format documentation diff --git a/.yardopts b/.yardopts new file mode 100644 index 0000000..606f097 --- /dev/null +++ b/.yardopts @@ -0,0 +1,3 @@ +--markup markdown +lib/**/*.rb +README.md MIT-LICENSE.txt diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2778f69 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,8 @@ +## Version 0.1.0 / 2011-03-22 + + * updated license + * bumped version to 0.1.0 + +## Version 0.0.1 / 2011-02-19 + + * initial release diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..d65e2a6 --- /dev/null +++ b/Gemfile @@ -0,0 +1,3 @@ +source 'http://rubygems.org' + +gemspec diff --git a/MIT-LICENSE.txt b/MIT-LICENSE.txt new file mode 100644 index 0000000..0966f96 --- /dev/null +++ b/MIT-LICENSE.txt @@ -0,0 +1,19 @@ +Copyright (c) 2011 Pat Deegan, PhD & Associates, LLC. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3c589c6 --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# keepass-password-generator + +Generate passwords using KeePass password generator patterns. + +## RubyGems installation + + gem install keepass-password-generator + +## Bundler installation + +In your `Gemfile`: + + gem 'keepass-password-generator' + +Install bundled gems: + + bundle + +## Usage + + require 'keepass/password' + + KeePass::Password.generate('A{6}s') + #=> "Un2hd#t" + +See for information about KeePass patterns. + +## Examples + +A 40-bit WEP key: + + KeePass::Password.generate('h{10}') + #=> "ae6929dc0e" + +A random MAC address: + + KeePass::Password.generate('HH\-HH\-HH\-HH\-HH\-HH', :permute => false) + #=> "0D-4D-32-64-EB-7D" + +A password with 10 alphanumeric characters, where at least 2 are upper case and at least are 2 lower case characters: + + KeePass::Password.generate('uullA{6}') + #=> "us2j1nTIQT" + +A password with 20 alphanumeric and symbol characters, without any lookalike characters (e.g., I and |): + + KeePass::Password.generate('[As]{20}', :remove_lookalikes => true) + #=> "-2~[+Rze{hZezk(\\nZ-W" + +Invalid patterns raise an exception: + + KeePass::Password.generate('[\I\|]{3}', :remove_lookalikes => true) + #=> KeePass::Password::InvalidPatternError: empty character set for token 1 for "[\\I\\|]{3}" + +## Related gems + +* +* diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..a92853f --- /dev/null +++ b/Rakefile @@ -0,0 +1,12 @@ +require 'bundler' +Bundler.setup + +Bundler::GemHelper.install_tasks + +require 'yard' +YARD::Rake::YardocTask.new + +require 'rspec/core/rake_task' +RSpec::Core::RakeTask.new(:spec) + +task :default => :spec diff --git a/keepass-password-generator.gemspec b/keepass-password-generator.gemspec new file mode 100644 index 0000000..f145b28 --- /dev/null +++ b/keepass-password-generator.gemspec @@ -0,0 +1,28 @@ +# -*- encoding: utf-8 -*- +$:.push File.expand_path("../lib", __FILE__) +require "keepass/password/version" + +Gem::Specification.new do |s| + s.name = "keepass-password-generator" + s.version = KeePass::Password::VERSION + s.platform = Gem::Platform::RUBY + s.authors = ["John Nishinaga"] + s.email = ["jingoro@casa-z.org"] + s.homepage = "https://github.com/patdeegan/keepass-password-generator" + s.summary = "keepass-password-generator-#{KeePass::Password::VERSION}" + s.description = "Generate passwords using KeePass password generator patterns" + + s.rubyforge_project = "keepass-password-generator" + + s.files = `git ls-files`.split("\n") + s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") + s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } + s.require_paths = ["lib"] + + s.add_dependency 'activesupport', '>= 2.2.0' + + s.add_development_dependency 'yard' + s.add_development_dependency 'bluecloth' + s.add_development_dependency 'rspec' + +end diff --git a/lib/keepass-password-generator.rb b/lib/keepass-password-generator.rb new file mode 100644 index 0000000..7cce01a --- /dev/null +++ b/lib/keepass-password-generator.rb @@ -0,0 +1 @@ +require 'keepass/password' diff --git a/lib/keepass/password.rb b/lib/keepass/password.rb new file mode 100644 index 0000000..8317725 --- /dev/null +++ b/lib/keepass/password.rb @@ -0,0 +1,58 @@ +require 'keepass/password/char_set' +require 'keepass/password/generator' +require 'keepass/password/version' + +module KeePass + + module Password + + # Returns a generated password. + # + # @param [String] pattern the pattern + # @param [Hash] options the options + # @option options [Boolean] :permute (true) whether or not to randomly permute generated passwords + # @option options [Boolean] :remove_lookalikes (false) whether or not to remove lookalike characters + # @option options [Hash] :charset_mapping (CharSet::DEFAULT_MAPPING) the KeePass character set ID mapping + # @return [String] the new password + # @raise [InvalidPatternError] if `pattern` is invalid + def self.generate(pattern, options = {}) + Generator.new(pattern, options).generate + end + + # Returns whether or not the pattern is valid. + # + # @param [String] pattern the pattern + # @param [Hash] options the options + # @option options [Boolean] :permute (true) whether or not to randomly permute generated passwords + # @option options [Boolean] :remove_lookalikes (false) whether or not to remove lookalike characters + # @option options [Hash] :charset_mapping (CharSet::DEFAULT_MAPPING) the KeePass character set ID mapping + # @return [Boolean] whether or not the pattern is valid + def self.validate_pattern(pattern, options = {}) + begin + generate(pattern, options) + true + rescue InvalidPatternError + false + end + end + + # Returns an entropy estimate of a password. + # + # @param [String] test the password to test + # @see http://en.wikipedia.org/wiki/Password_strength + def self.estimate_entropy(test) + chars = 0 + chars += 26 if test =~ LOWERCASE_TEST_RE + chars += 26 if test =~ UPPERCASE_TEST_RE + chars += 10 if test =~ DIGITS_TEST_RE + chars += CharSet::PRINTABLE_ASCII_SPECIAL.size if test =~ SPECIAL_TEST_RE + if chars == 0 + 0 + else + (test.size * Math.log(chars) / Math.log(2)).to_i + end + end + + end + +end diff --git a/lib/keepass/password/char_set.rb b/lib/keepass/password/char_set.rb new file mode 100644 index 0000000..41585da --- /dev/null +++ b/lib/keepass/password/char_set.rb @@ -0,0 +1,91 @@ +require 'set' + +module KeePass + + module Password + + class InvalidCharSetIDError < RuntimeError; end + + # Character sets for the KeePass password generator. + # + # @see http://keepass.info/help/base/pwgenerator.html#pattern + class CharSet < Set + + UPPERCASE = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + LOWERCASE = "abcdefghijklmnopqrstuvwxyz" + DIGITS = "0123456789" + UPPER_CONSONANTS = "BCDFGHJKLMNPQRSTVWXYZ" + LOWER_CONSONANTS = "bcdfghjklmnpqrstvwxyz" + UPPER_VOWELS = "AEIOU" + LOWER_VOWELS = "aeiou" + PUNCTUATION = ",.;:" + BRACKETS = "[]{}()<>" + PRINTABLE_ASCII_SPECIAL = "!\"#\$%&'()*+,-./:;<=>?[\\]^_{|}~" + UPPER_HEX = "0123456789ABCDEF" + LOWER_HEX = "0123456789abcdef" + HIGH_ANSI = (0x7f..0xfe).map { |i| i.chr }.join + + DEFAULT_MAPPING = { + 'a' => [LOWERCASE, DIGITS], + 'A' => [LOWERCASE, UPPERCASE, DIGITS], + 'U' => [UPPERCASE, DIGITS], + 'c' => [LOWER_CONSONANTS], + 'C' => [LOWER_CONSONANTS, UPPER_CONSONANTS], + 'z' => [UPPER_CONSONANTS], + 'd' => [DIGITS], + 'h' => [LOWER_HEX], + 'H' => [UPPER_HEX], + 'l' => [LOWERCASE], + 'L' => [LOWERCASE, UPPERCASE], + 'u' => [UPPERCASE], + 'p' => [PUNCTUATION], + 'b' => [BRACKETS], + 's' => [PRINTABLE_ASCII_SPECIAL], + 'S' => [UPPERCASE, LOWERCASE, DIGITS, PRINTABLE_ASCII_SPECIAL], + 'v' => [LOWER_VOWELS], + 'V' => [LOWER_VOWELS, UPPER_VOWELS], + 'Z' => [UPPER_VOWELS], + 'x' => [HIGH_ANSI], + } + + ASCII_MAPPING = DEFAULT_MAPPING.reject { |k, v| k == 'x' } + + # @return [Hash] the KeePass character set ID mapping + attr_accessor :mapping + + # Instantiates a new CharSet object. + # + # @see Set#new + def initialize(*args) + @mapping = DEFAULT_MAPPING + super + end + + # Adds several characters according to the KeePass character class. + # + # @see http://keepass.info/help/base/pwgenerator.html#pattern + # @param [String] char_set_id the KeePass character set ID + # @raise [InvalidCharSetIDError] if mapping does not contain `char_set_id` + # @return [CharSet] self + def add_from_char_set_id(char_set_id) + if strings = mapping[char_set_id] + add_from_strings *strings + else + raise InvalidCharSetIDError, "no such char set ID #{char_set_id.inspect}" + end + end + + # Adds each character from one or more strings. + # + # @param [Array] *strings one or more strings to add + # @return [CharSet] self + def add_from_strings(*strings) + strings.each { |s| merge Set.new(s.split('')) } + self + end + + end + + end + +end diff --git a/lib/keepass/password/generator.rb b/lib/keepass/password/generator.rb new file mode 100644 index 0000000..ce26c66 --- /dev/null +++ b/lib/keepass/password/generator.rb @@ -0,0 +1,111 @@ +require 'keepass/password/char_set' +require 'keepass/random' + +module KeePass + + module Password + + class InvalidPatternError < RuntimeError; end + + # Generate passwords using KeePass password generator patterns. + # + # @see http://keepass.info/help/base/pwgenerator.html + class Generator + + # Available character sets + CHARSET_IDS = CharSet::DEFAULT_MAPPING.keys.join + + # ASCII printables regular expression + LITERALS_RE = /[\x20-\x7e]/ + CHAR_TOKEN_RE = Regexp.new("([#{CHARSET_IDS}])|\\\\(#{LITERALS_RE.source})") + GROUP_TOKEN_RE = Regexp.new("(#{CHAR_TOKEN_RE.source}|" + + "\\[((#{CHAR_TOKEN_RE.source})*?)\\])" + + "(\\{(\\d+)\\})?") + VALIDATOR_RE = Regexp.new("\\A(#{GROUP_TOKEN_RE.source})+\\Z") + + LOOKALIKE = "O0l1I|" + LOOKALIKE_CHARSET = CharSet.new.add_from_strings LOOKALIKE + + # @return [String] the pattern + attr_reader :pattern + + # @return [Array] the character sets from the pattern + attr_reader :char_sets + + # @return [Boolean] whether or not to permute the password + attr_accessor :permute + + # Instantiates a new PasswordGenerator object. + # + # @param [String] pattern the pattern + # @param [Hash] options the options + # @option options [Boolean] :permute (true) whether or not to randomly permute generated passwords + # @option options [Boolean] :remove_lookalikes (false) whether or not to remove lookalike characters + # @option options [Hash] :charset_mapping (CharSet::DEFAULT_MAPPING) the KeePass character set ID mapping + # @return [PasswordGenerator] self + # @raise [InvalidPatternError] if `pattern` is invalid + def initialize(pattern, options = {}) + @permute = options.has_key?(:permute) ? options[:permute] : true + @pattern = pattern + @char_sets = pattern_to_char_sets(pattern, options) + end + + # Returns a new password. + # + # @return [String] a new password + def generate + result = char_sets.map { |c| Random.sample_array(c.to_a) } + result = Random.shuffle_array(result) if permute + result.join + end + + private + + def pattern_to_char_sets(pattern, options) #:nodoc: + remove_lookalikes = options[:remove_lookalikes] || false + mapping = options[:charset_mapping] || CharSet::DEFAULT_MAPPING + char_sets = [] + i = 1 + pattern.scan(GROUP_TOKEN_RE) do |x1, char, bs_char, char_group, x5, x6, x7, x8, repeat| + char_set = CharSet.new + char_set.mapping = mapping + begin + if char + char_set.add_from_char_set_id(char) + elsif bs_char + char_set.add(bs_char) + else + char_group.scan(CHAR_TOKEN_RE) do |c, e| + if c + char_set.add_from_char_set_id(c) + else + char_set.add(e) + end + end + end + rescue InvalidCharSetIDError => e + raise InvalidPatternError, e.message + end + char_set -= LOOKALIKE_CHARSET if remove_lookalikes + if char_set.empty? + raise InvalidPatternError, "empty character set for token #{i} for #{pattern.inspect}" + end + (repeat ? repeat.to_i : 1).times { char_sets << char_set } + i += 1 + end + + if char_sets.any? + char_sets + else + raise InvalidPatternError, "no char sets from #{pattern.inspect}" + end + + end + + # private + + end + + end + +end diff --git a/lib/keepass/password/version.rb b/lib/keepass/password/version.rb new file mode 100644 index 0000000..28c9bdf --- /dev/null +++ b/lib/keepass/password/version.rb @@ -0,0 +1,5 @@ +module KeePass + module Password + VERSION = "0.1.0" + end +end diff --git a/lib/keepass/random.rb b/lib/keepass/random.rb new file mode 100644 index 0000000..70c9c75 --- /dev/null +++ b/lib/keepass/random.rb @@ -0,0 +1,38 @@ +require 'active_support/secure_random' + +module KeePass + + module Random + + # If `n` is a positive integer, then returns a random + # integer `r` such that 0 <= `r` < `n`. + # + # If `n` is 0 or unspecified, then returns a random + # float `r` such that 0 <= `r` < 1. + # + # @param [Integer] n the upper bound + # @return [Integer|Float] the random number + # @see ActiveSupport::SecureRandom#random_number + def self.random_number(n = 0) + ActiveSupport::SecureRandom.random_number(n) + end + + # Returns a randomly sampled item from the array. + # + # @param [Array] array the array to sample from + # @return [Object] random item or nil if no items exist + def self.sample_array(array) + array[random_number(array.size)] + end + + # Returns the array shuffled randomly. + # + # @param [Array] array the array to shuffle + # @return [Array] the shuffled array + def self.shuffle_array(array) + array.sort_by { random_number } + end + + end + +end diff --git a/spec/char_set_spec.rb b/spec/char_set_spec.rb new file mode 100644 index 0000000..5318943 --- /dev/null +++ b/spec/char_set_spec.rb @@ -0,0 +1,38 @@ +require 'spec_helper' + +describe KeePass::Password::CharSet do + + describe "#add_from_strings" do + + it "should add from multiple arguments" do + subject.add_from_strings('abc', 'cde', 'QQ') + subject.should == Set.new(%w(a b c d e Q)) + end + + end + + describe "#add_from_char_set_id" do + + it "should add the digits" do + subject.add_from_char_set_id('d') + subject.should == Set.new('0'..'9') + end + + it "should support chaining" do + subject.add_from_char_set_id('l').add_from_char_set_id('u') + subject.should == (Set.new('a'..'z') + Set.new('A'..'Z')) + end + + it "should allow x with default mapping" do + subject.add_from_char_set_id('x') + subject.should include(0x7f.chr) + end + + it "should raise an error with ASCII mapping" do + subject.mapping = KeePass::Password::CharSet::ASCII_MAPPING + expect { subject.add_from_char_set_id('x') }.to raise_error(KeePass::Password::InvalidCharSetIDError) + end + + end + +end diff --git a/spec/generator_spec.rb b/spec/generator_spec.rb new file mode 100644 index 0000000..e5b6dd3 --- /dev/null +++ b/spec/generator_spec.rb @@ -0,0 +1,117 @@ +require 'spec_helper' + +describe KeePass::Password::Generator do + + def char_set(*ids) + char_set = KeePass::Password::CharSet.new + ids.each { |id| char_set.add_from_char_set_id(id) } + char_set + end + + subject { described_class.new(pattern, options) } + let(:options) { { } } + let(:random_class) { KeePass::Random } + + describe "pattern 'h{10}' (40-bit WEP key)" do + + let(:pattern) { 'h{10}' } + + its(:pattern) { should == 'h{10}' } + its(:permute) { should be_true } + its(:char_sets) { should have(10).items } + + it "should generate 10 hex digits " do + random_class.should_receive(:sample_array) do |array| + array.sort.should == char_set('h').to_a.sort + '0' + end.exactly(10).times + random_class.should_receive(:shuffle_array) do |array| + array.sort + end.once + subject.generate.should == '0000000000' + end + + end + + describe "pattern 'HH\-HH\-HH\-HH\-HH\-HH', :permute => false" do + + let(:pattern) { 'HH\-HH\-HH\-HH\-HH\-HH' } + let(:options) { { :permute => false } } + + its(:pattern) { should == 'HH\-HH\-HH\-HH\-HH\-HH' } + its(:permute) { should be_false } + its(:char_sets) { should have(17).items } + + it "should generate a MAC address" do + random_class.should_receive(:sample_array) do |array| + if array == ['-'] + '-' + else + array.sort.should == char_set('H').to_a.sort + '0' + end + end.exactly(17).times + random_class.should_not_receive(:shuffle_array) + subject.generate.should == '00-00-00-00-00-00' + end + + end + + describe "pattern 'uullA{6}'" do + + let(:pattern) { 'uullA{6}' } + + its(:pattern) { should == 'uullA{6}' } + its(:permute) { should be_true } + its(:char_sets) { should have(10).items } + + it "should generate a 10-character alphanumeric password" do + random_class.should_receive(:sample_array) do |array| + array.sort.first + end.exactly(10).times + random_class.should_receive(:shuffle_array) do |array| + array.sort + end.once + subject.generate.should == '000000AAaa' + end + + end + + describe "pattern '[As]{20}', :remove_lookalikes => true" do + + let(:pattern) { '[As]{20}' } + let(:options) { { :remove_lookalikes => true } } + + its(:pattern) { should == '[As]{20}' } + its(:permute) { should be_true } + its(:char_sets) { should have(20).items } + + it "should generate a 20-character password" do + test_set = (char_set('A', 's') - Set.new(%w(O 0 l 1 I |))).to_a.sort + i = 0 + random_class.should_receive(:sample_array) do |array| + array.sort.should == test_set + result = array.sort[i] + i += 1 + result + end.exactly(20).times + random_class.should_receive(:shuffle_array) do |array| + array.sort + end.once + subject.generate.should == '!"#$%&\'()*+,-./23456' + end + + end + + describe "pattern '[\\I\\|]{3}', :remove_lookalikes => true" do + + let(:pattern) { '[\I\|]{3}' } + let(:options) { { :remove_lookalikes => true } } + + it "should raise an error" do + expect { subject }.to raise_error(KeePass::Password::InvalidPatternError) + end + + end + +end diff --git a/spec/random_spec.rb b/spec/random_spec.rb new file mode 100644 index 0000000..362d693 --- /dev/null +++ b/spec/random_spec.rb @@ -0,0 +1,49 @@ +require 'spec_helper' + +describe KeePass::Random do + + describe "#random_number" do + + it "should use ActiveSupport::SecureRandom" do + ActiveSupport::SecureRandom.should_receive(:random_number).once.with(12) + described_class.random_number(12) + end + + it "should accept default argument" do + ActiveSupport::SecureRandom.should_receive(:random_number).with(0) + described_class.random_number + end + + end + + describe "#sample_array" do + + it "should call random_number with the array size" do + described_class.should_receive(:random_number).with(6).and_return(3) + described_class.sample_array(%w(a b c d e f)).should == 'd' + end + + it "should return expected values for deterministic random number" do + described_class.stub(:random_number) { |arg| 0 } + described_class.sample_array(%w(a b c)).should == 'a' + described_class.sample_array(%w(b a c)).should == 'b' + described_class.sample_array(%w(c b a)).should == 'c' + end + + end + + describe "#shuffle_array" do + + it "should call random_number with no parameters" do + described_class.should_receive(:random_number).with().at_least(5).times.and_return(0.5) + described_class.shuffle_array(%w(a b c d e)) + end + + it "should return the same elements" do + described_class.stub(:random_number) { 0.5 } + described_class.shuffle_array(%w(a b c d e)).sort.should == %w(a b c d e) + end + + end + +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..ec4d49b --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,32 @@ +$:.unshift File.expand_path('../../lib', __FILE__) + +require 'rspec' +require 'keepass/password/generator' + +# module DeterministicRandomness +# +# def deterministic_random_number(&block) +# ActiveSupport::SecureRandom.stub(:random_number) do |arg| +# if block +# block.call(arg) +# else +# 0 +# end +# end +# end +# +# # def deterministic_shuffle +# # Array.any_instance.stub(:shuffle!) +# # end +# +# end +# +# RSpec.configure do |config| +# config.include DeterministicRandomness +# end + +# RSpec::Matchers.define :have_char_set_length_of do |expected| +# match do |actual| +# actual.char_sets.size == expected +# end +# end