keepassx 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/.rvmrc ADDED
@@ -0,0 +1 @@
1
+ rvm 1.8.7-p249@keepassx --create
@@ -0,0 +1,5 @@
1
+ language: ruby
2
+ rvm:
3
+ - 1.8.7
4
+ - 1.9.1
5
+ - 1.9.3
data/Gemfile ADDED
@@ -0,0 +1,5 @@
1
+ source "http://rubygems.org"
2
+
3
+ gemspec
4
+
5
+ gem 'rake', '0.8.7'
@@ -0,0 +1,28 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ keepassx (0.1.0)
5
+ fast-aes (~> 0.1)
6
+
7
+ GEM
8
+ remote: http://rubygems.org/
9
+ specs:
10
+ diff-lcs (1.1.3)
11
+ fast-aes (0.1.1)
12
+ rake (0.8.7)
13
+ rspec (2.11.0)
14
+ rspec-core (~> 2.11.0)
15
+ rspec-expectations (~> 2.11.0)
16
+ rspec-mocks (~> 2.11.0)
17
+ rspec-core (2.11.1)
18
+ rspec-expectations (2.11.3)
19
+ diff-lcs (~> 1.1.3)
20
+ rspec-mocks (2.11.3)
21
+
22
+ PLATFORMS
23
+ ruby
24
+
25
+ DEPENDENCIES
26
+ keepassx!
27
+ rake (= 0.8.7)
28
+ rspec (= 2.11.0)
File without changes
@@ -0,0 +1,5 @@
1
+ require 'rspec/core/rake_task'
2
+
3
+ task :default => :spec
4
+
5
+ RSpec::Core::RakeTask.new(:spec)
@@ -0,0 +1,14 @@
1
+ Gem::Specification.new do |s|
2
+ s.name = "keepassx"
3
+ s.summary = "Ruby API access for KeePassX databases"
4
+ s.description = "See http://github.com/pitluga/keepassx"
5
+ s.version = "0.1.0"
6
+ s.authors = ["Tony Pitluga", "Paul Hinze"]
7
+ s.email = ["tony.pitluga@gmail.com", "paul.t.hinze@gmail.com"]
8
+ s.homepage = "http://github.com/pitluga/keepassx"
9
+ s.files = `git ls-files`.split("\n")
10
+
11
+ s.add_dependency "fast-aes", "~> 0.1"
12
+
13
+ s.add_development_dependency "rspec", "2.11.0"
14
+ end
@@ -0,0 +1,13 @@
1
+ require 'base64'
2
+ require 'stringio'
3
+ require 'openssl'
4
+ require 'digest/sha2'
5
+ require 'fast-aes'
6
+
7
+ require 'keepassx/database'
8
+ require 'keepassx/entry'
9
+ require 'keepassx/entry_field'
10
+ require 'keepassx/group'
11
+ require 'keepassx/group_field'
12
+ require 'keepassx/header'
13
+ require 'keepassx/aes_crypt'
@@ -0,0 +1,19 @@
1
+ module Keepassx
2
+ module AESCrypt
3
+ def self.decrypt(encrypted_data, key, iv, cipher_type)
4
+ aes = OpenSSL::Cipher::Cipher.new(cipher_type)
5
+ aes.decrypt
6
+ aes.key = key
7
+ aes.iv = iv unless iv.nil?
8
+ aes.update(encrypted_data) + aes.final
9
+ end
10
+
11
+ def self.encrypt(data, key, iv, cipher_type)
12
+ aes = OpenSSL::Cipher::Cipher.new(cipher_type)
13
+ aes.encrypt
14
+ aes.key = key
15
+ aes.iv = iv unless iv.nil?
16
+ aes.update(data) + aes.final
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,45 @@
1
+ module Keepassx
2
+ class Database
3
+
4
+ attr_reader :header, :groups, :entries
5
+
6
+ def self.open(path)
7
+ content = File.respond_to?(:binread) ? File.binread(path) : File.read(path)
8
+ self.new(content)
9
+ end
10
+
11
+ def initialize(raw_db)
12
+ @header = Header.new(raw_db[0..124])
13
+ @encrypted_payload = raw_db[124..-1]
14
+ end
15
+
16
+ def entry(title)
17
+ @entries.detect { |e| e.title == title }
18
+ end
19
+
20
+ def unlock(master_password)
21
+ @final_key = header.final_key(master_password)
22
+ decrypt_payload
23
+ payload_io = StringIO.new(@payload)
24
+ @groups = Group.extract_from_payload(header, payload_io)
25
+ @entries = Entry.extract_from_payload(header, payload_io)
26
+ true
27
+ rescue OpenSSL::Cipher::CipherError
28
+ false
29
+ end
30
+
31
+ def search(pattern)
32
+ backup = groups.detect { |g| g.name == "Backup" }
33
+ backup_group_id = backup && backup.group_id
34
+ entries.select { |e| e.group_id != backup_group_id && e.title =~ /#{pattern}/i }
35
+ end
36
+
37
+ def valid?
38
+ @header.valid?
39
+ end
40
+
41
+ def decrypt_payload
42
+ @payload = AESCrypt.decrypt(@encrypted_payload, @final_key, header.encryption_iv, 'AES-256-CBC')
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,81 @@
1
+ # One entry: [FIELDTYPE(FT)][FIELDSIZE(FS)][FIELDDATA(FD)]
2
+ # [FT+FS+(FD)][FT+FS+(FD)][FT+FS+(FD)][FT+FS+(FD)][FT+FS+(FD)]...
3
+
4
+ # [ 2 bytes] FIELDTYPE
5
+ # [ 4 bytes] FIELDSIZE, size of FIELDDATA in bytes
6
+ # [ n bytes] FIELDDATA, n = FIELDSIZE
7
+
8
+ # Notes:
9
+ # - Strings are stored in UTF-8 encoded form and are null-terminated.
10
+ # - FIELDTYPE can be one of the following identifiers:
11
+ # * 0000: Invalid or comment block, block is ignored
12
+ # * 0001: UUID, uniquely identifying an entry, FIELDSIZE must be 16
13
+ # * 0002: Group ID, identifying the group of the entry, FIELDSIZE = 4
14
+ # It can be any 32-bit value except 0 and 0xFFFFFFFF
15
+ # * 0003: Image ID, identifying the image/icon of the entry, FIELDSIZE = 4
16
+ # * 0004: Title of the entry, FIELDDATA is an UTF-8 encoded string
17
+ # * 0005: URL string, FIELDDATA is an UTF-8 encoded string
18
+ # * 0006: UserName string, FIELDDATA is an UTF-8 encoded string
19
+ # * 0007: Password string, FIELDDATA is an UTF-8 encoded string
20
+ # * 0008: Notes string, FIELDDATA is an UTF-8 encoded string
21
+ # * 0009: Creation time, FIELDSIZE = 5, FIELDDATA = packed date/time
22
+ # * 000A: Last modification time, FIELDSIZE = 5, FIELDDATA = packed date/time
23
+ # * 000B: Last access time, FIELDSIZE = 5, FIELDDATA = packed date/time
24
+ # * 000C: Expiration time, FIELDSIZE = 5, FIELDDATA = packed date/time
25
+ # * 000D: Binary description UTF-8 encoded string
26
+ # * 000E: Binary data
27
+ # * FFFF: Entry terminator, FIELDSIZE must be 0
28
+ # '''
29
+
30
+ module Keepassx
31
+ class Entry
32
+ def self.extract_from_payload(header, payload_io)
33
+ groups = []
34
+ header.nentries.times do
35
+ group = Entry.new(payload_io)
36
+ groups << group
37
+ end
38
+ groups
39
+ end
40
+
41
+ attr_reader :fields
42
+
43
+ def initialize(payload_io)
44
+ fields = []
45
+ begin
46
+ field = EntryField.new(payload_io)
47
+ fields << field
48
+ end while not field.terminator?
49
+
50
+ @fields = fields
51
+ end
52
+
53
+ def length
54
+ @fields.map(&:length).reduce(&:+)
55
+ end
56
+
57
+ def notes
58
+ @fields.detect { |field| field.name == 'notes' }.data.chomp("\000")
59
+ end
60
+
61
+ def password
62
+ @fields.detect { |field| field.name == 'password' }.data.chomp("\000")
63
+ end
64
+
65
+ def title
66
+ @fields.detect { |field| field.name == 'title' }.data.chomp("\000")
67
+ end
68
+
69
+ def username
70
+ @fields.detect { |field| field.name == 'username' }.data.chomp("\000")
71
+ end
72
+
73
+ def group_id
74
+ @fields.detect { |field| field.name == 'groupid' }.data
75
+ end
76
+
77
+ def inspect
78
+ "Entry<title=#{title.inspect}, username=[FILTERED], password=[FILTERED], notes=#{notes.inspect}>"
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,49 @@
1
+ module Keepassx
2
+ class EntryField
3
+ FIELD_TYPES = [
4
+ [0x0, 'ignored', :null],
5
+ [0x1, 'uuid', :ascii],
6
+ [0x2, 'groupid', :int],
7
+ [0x3, 'imageid', :int],
8
+ [0x4, 'title', :string],
9
+ [0x5, 'url', :string],
10
+ [0x6, 'username', :string],
11
+ [0x7, 'password', :string],
12
+ [0x8, 'notes', :string],
13
+ [0x9, 'creation_time', :date],
14
+ [0xa, 'last_mod_time', :date],
15
+ [0xb, 'last_acc_time', :date],
16
+ [0xc, 'expiration_time', :date],
17
+ [0xd, 'binary_desc', :string],
18
+ [0xe, 'binary_data', :shunt],
19
+ [0xFFFF, 'terminator', :nil]
20
+ ]
21
+ FIELD_TERMINATOR = 0xFFFF
22
+ TYPE_CODE_FIELD_SIZE = 2 # unsigned short integer
23
+ DATA_LENGTH_FIELD_SIZE = 4 # unsigned integer
24
+
25
+
26
+ attr_reader :name, :data_type, :data
27
+
28
+ def initialize(payload)
29
+ type_code, @data_length = payload.read(TYPE_CODE_FIELD_SIZE + DATA_LENGTH_FIELD_SIZE).unpack('SI')
30
+ @name, @data_type = _parse_type_code(type_code)
31
+ @data = payload.read(@data_length)
32
+ end
33
+
34
+ def terminator?
35
+ name == 'terminator'
36
+ end
37
+
38
+ def length
39
+ TYPE_CODE_FIELD_SIZE + DATA_LENGTH_FIELD_SIZE + @data_length
40
+ end
41
+
42
+ def _parse_type_code(type_code)
43
+ (_, name, data_type) = FIELD_TYPES.detect do |(code, *rest)|
44
+ code == type_code
45
+ end
46
+ [name, data_type]
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,56 @@
1
+ # One group: [FIELDTYPE(FT)][FIELDSIZE(FS)][FIELDDATA(FD)]
2
+ # [FT+FS+(FD)][FT+FS+(FD)][FT+FS+(FD)][FT+FS+(FD)][FT+FS+(FD)]...
3
+ #
4
+ # [ 2 bytes] FIELDTYPE
5
+ # [ 4 bytes] FIELDSIZE, size of FIELDDATA in bytes
6
+ # [ n bytes] FIELDDATA, n = FIELDSIZE
7
+ #
8
+ # Notes:
9
+ # - Strings are stored in UTF-8 encoded form and are null-terminated.
10
+ # - FIELDTYPE can be one of the following identifiers:
11
+ # * 0000: Invalid or comment block, block is ignored
12
+ # * 0001: Group ID, FIELDSIZE must be 4 bytes
13
+ # It can be any 32-bit value except 0 and 0xFFFFFFFF
14
+ # * 0002: Group name, FIELDDATA is an UTF-8 encoded string
15
+ # * 0003: Creation time, FIELDSIZE = 5, FIELDDATA = packed date/time
16
+ # * 0004: Last modification time, FIELDSIZE = 5, FIELDDATA = packed date/time
17
+ # * 0005: Last access time, FIELDSIZE = 5, FIELDDATA = packed date/time
18
+ # * 0006: Expiration time, FIELDSIZE = 5, FIELDDATA = packed date/time
19
+ # * 0007: Image ID, FIELDSIZE must be 4 bytes
20
+ # * 0008: Level, FIELDSIZE = 2
21
+ # * 0009: Flags, 32-bit value, FIELDSIZE = 4
22
+ # * FFFF: Group entry terminator, FIELDSIZE must be 0
23
+ module Keepassx
24
+ class Group
25
+ def self.extract_from_payload(header, payload_io)
26
+ groups = []
27
+ header.ngroups.times do
28
+ group = Group.new(payload_io)
29
+ groups << group
30
+ end
31
+ groups
32
+ end
33
+
34
+ def initialize(payload_io)
35
+ fields = []
36
+ begin
37
+ field = GroupField.new(payload_io)
38
+ fields << field
39
+ end while not field.terminator?
40
+
41
+ @fields = fields
42
+ end
43
+
44
+ def length
45
+ @fields.map(&:length).reduce(&:+)
46
+ end
47
+
48
+ def group_id
49
+ @fields.detect { |field| field.name == 'groupid' }.data
50
+ end
51
+
52
+ def name
53
+ @fields.detect { |field| field.name == 'group_name' }.data.chomp("\000")
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,44 @@
1
+ module Keepassx
2
+ class GroupField
3
+ FIELD_TYPES = [
4
+ [0x0, 'ignored', :null],
5
+ [0x1, 'groupid', :int],
6
+ [0x2, 'group_name', :string],
7
+ [0x3, 'creation_time', :date],
8
+ [0x4, 'lastmod_time', :date],
9
+ [0x5, 'lastacc_time', :date],
10
+ [0x6, 'expire_time', :date],
11
+ [0x7, 'imageid', :int],
12
+ [0x8, 'level', :short],
13
+ [0x9, 'flags', :int],
14
+ [0xFFFF, 'terminator', :null]
15
+ ]
16
+ FIELD_TERMINATOR = 0xFFFF
17
+ TYPE_CODE_FIELD_SIZE = 2 # unsigned short integer
18
+ DATA_LENGTH_FIELD_SIZE = 4 # unsigned integer
19
+
20
+
21
+ attr_reader :name, :data_type, :data
22
+
23
+ def initialize(payload)
24
+ type_code, @data_length = payload.read(TYPE_CODE_FIELD_SIZE + DATA_LENGTH_FIELD_SIZE).unpack('SI')
25
+ @name, @data_type = _parse_type_code(type_code)
26
+ @data = payload.read(@data_length)
27
+ end
28
+
29
+ def terminator?
30
+ name == 'terminator'
31
+ end
32
+
33
+ def length
34
+ TYPE_CODE_FIELD_SIZE + DATA_LENGTH_FIELD_SIZE + @data_length
35
+ end
36
+
37
+ def _parse_type_code(type_code)
38
+ (_, name, data_type) = FIELD_TYPES.detect do |(code, *rest)|
39
+ code == type_code
40
+ end
41
+ [name, data_type]
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,87 @@
1
+ # The keepass file header.
2
+ #
3
+ # From the KeePass doc:
4
+ #
5
+ # Database header: [DBHDR]
6
+ #
7
+ # [ 4 bytes] DWORD dwSignature1 = 0x9AA2D903
8
+ # [ 4 bytes] DWORD dwSignature2 = 0xB54BFB65
9
+ # [ 4 bytes] DWORD dwFlags
10
+ # [ 4 bytes] DWORD dwVersion { Ve.Ve.Mj.Mj:Mn.Mn.Bl.Bl }
11
+ # [16 bytes] BYTE{16} aMasterSeed
12
+ # [16 bytes] BYTE{16} aEncryptionIV
13
+ # [ 4 bytes] DWORD dwGroups Number of groups in database
14
+ # [ 4 bytes] DWORD dwEntries Number of entries in database
15
+ # [32 bytes] BYTE{32} aContentsHash SHA-256 hash value of the plain contents
16
+ # [32 bytes] BYTE{32} aMasterSeed2 Used for the dwKeyEncRounds AES
17
+ # master key transformations
18
+ # [ 4 bytes] DWORD dwKeyEncRounds See above; number of transformations
19
+ #
20
+ # Notes:
21
+ #
22
+ # - dwFlags is a bitmap, which can include:
23
+ # * PWM_FLAG_SHA2 (1) for SHA-2.
24
+ # * PWM_FLAG_RIJNDAEL (2) for AES (Rijndael).
25
+ # * PWM_FLAG_ARCFOUR (4) for ARC4.
26
+ # * PWM_FLAG_TWOFISH (8) for Twofish.
27
+ # - aMasterSeed is a salt that gets hashed with the transformed user master key
28
+ # to form the final database data encryption/decryption key.
29
+ # * FinalKey = SHA-256(aMasterSeed, TransformedUserMasterKey)
30
+ # - aEncryptionIV is the initialization vector used by AES/Twofish for
31
+ # encrypting/decrypting the database data.
32
+ # - aContentsHash: "plain contents" refers to the database file, minus the
33
+ # database header, decrypted by FinalKey.
34
+ # * PlainContents = Decrypt_with_FinalKey(DatabaseFile - DatabaseHeader)
35
+ module Keepassx
36
+ class Header
37
+
38
+ ENCRYPTION_FLAGS = [
39
+ [1 , 'SHA2' ],
40
+ [2 , 'Rijndael'],
41
+ [2 , 'AES' ],
42
+ [4 , 'ArcFour' ],
43
+ [8 , 'TwoFish' ]
44
+ ]
45
+
46
+ attr_reader :encryption_iv
47
+ attr_reader :ngroups, :nentries
48
+
49
+ def initialize(header_bytes)
50
+ @signature1 = header_bytes[0..4].unpack('L*').first
51
+ @signature2 = header_bytes[4..8].unpack('L*').first
52
+ @flags = header_bytes[8..12].unpack('L*').first
53
+ @version = header_bytes[12..16].unpack('L*').first
54
+ @master_seed = header_bytes[16...32]
55
+ @encryption_iv = header_bytes[32...48]
56
+ @ngroups = header_bytes[48..52].unpack('L*').first
57
+ @nentries = header_bytes[52..56].unpack('L*').first
58
+ @contents_hash = header_bytes[56..88]
59
+ @master_seed2 = header_bytes[88...120]
60
+ @rounds = header_bytes[120..-1].unpack('L*').first
61
+ end
62
+
63
+ def valid?
64
+ @signature1 == 0x9AA2D903 && @signature2 == 0xB54BFB65
65
+ end
66
+
67
+ def encryption_type
68
+ ENCRYPTION_FLAGS.each do |(flag_mask, encryption_type)|
69
+ return encryption_type if @flags & flag_mask
70
+ end
71
+ 'Unknown'
72
+ end
73
+
74
+ def final_key(master_key)
75
+ key = Digest::SHA2.new.update(master_key).digest
76
+ aes = FastAES.new(@master_seed2)
77
+
78
+ @rounds.times do |i|
79
+ key = aes.encrypt(key)
80
+ end
81
+
82
+ key = Digest::SHA2.new.update(key).digest
83
+ key = Digest::SHA2.new.update(@master_seed + key).digest
84
+ key
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,56 @@
1
+ require 'spec_helper'
2
+
3
+ describe Keepassx::Database do
4
+ describe 'self.open' do
5
+ it "creates a new instance of the databse with the file" do
6
+ db = Keepassx::Database.open(TEST_DATABASE_PATH)
7
+ db.should_not be_nil
8
+ end
9
+ end
10
+
11
+ describe "unlock" do
12
+ before :each do
13
+ @db = Keepassx::Database.open(TEST_DATABASE_PATH)
14
+ @db.should be_valid
15
+ end
16
+
17
+ it "returns true when the master password is correct" do
18
+ @db.unlock('testmasterpassword').should be_true
19
+ end
20
+
21
+ it "returns false when the master password is incorrect" do
22
+ @db.unlock('bad password').should be_false
23
+ end
24
+ end
25
+
26
+ describe "an unlocked database" do
27
+ before :each do
28
+ @db = Keepassx::Database.open(TEST_DATABASE_PATH)
29
+ @db.unlock('testmasterpassword')
30
+ end
31
+
32
+ it "can find entries by their title" do
33
+ @db.entry("test entry").password.should == "testpassword"
34
+ end
35
+
36
+ it "can find groups" do
37
+ @db.groups.map(&:name).sort.should == ["Backup", "Internet", "eMail"]
38
+ end
39
+
40
+ it "can search for entries" do
41
+ entries = @db.search "test"
42
+ entries.first.title.should == "test entry"
43
+ end
44
+
45
+ it "can search for entries case-insensitively" do
46
+ entries = @db.search "TEST"
47
+ entries.first.title.should == "test entry"
48
+ end
49
+
50
+ it "will find the current values of entries with history" do
51
+ entries = @db.search "entry2"
52
+ entries.size.should == 1
53
+ entries.first.title.should == "entry2"
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,5 @@
1
+ $:.unshift File.expand_path('../../lib', __FILE__)
2
+ require 'keepassx'
3
+ require 'rspec'
4
+
5
+ TEST_DATABASE_PATH = File.expand_path('../test_database.kdb', __FILE__)
Binary file
metadata ADDED
@@ -0,0 +1,108 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: keepassx
3
+ version: !ruby/object:Gem::Version
4
+ prerelease: false
5
+ segments:
6
+ - 0
7
+ - 1
8
+ - 0
9
+ version: 0.1.0
10
+ platform: ruby
11
+ authors:
12
+ - Tony Pitluga
13
+ - Paul Hinze
14
+ autorequire:
15
+ bindir: bin
16
+ cert_chain: []
17
+
18
+ date: 2012-10-14 00:00:00 -05:00
19
+ default_executable:
20
+ dependencies:
21
+ - !ruby/object:Gem::Dependency
22
+ name: fast-aes
23
+ prerelease: false
24
+ requirement: &id001 !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ~>
27
+ - !ruby/object:Gem::Version
28
+ segments:
29
+ - 0
30
+ - 1
31
+ version: "0.1"
32
+ type: :runtime
33
+ version_requirements: *id001
34
+ - !ruby/object:Gem::Dependency
35
+ name: rspec
36
+ prerelease: false
37
+ requirement: &id002 !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - "="
40
+ - !ruby/object:Gem::Version
41
+ segments:
42
+ - 2
43
+ - 11
44
+ - 0
45
+ version: 2.11.0
46
+ type: :development
47
+ version_requirements: *id002
48
+ description: See http://github.com/pitluga/keepassx
49
+ email:
50
+ - tony.pitluga@gmail.com
51
+ - paul.t.hinze@gmail.com
52
+ executables: []
53
+
54
+ extensions: []
55
+
56
+ extra_rdoc_files: []
57
+
58
+ files:
59
+ - .rvmrc
60
+ - .travis.yml
61
+ - Gemfile
62
+ - Gemfile.lock
63
+ - README.md
64
+ - Rakefile
65
+ - keepassx.gemspec
66
+ - lib/keepassx.rb
67
+ - lib/keepassx/aes_crypt.rb
68
+ - lib/keepassx/database.rb
69
+ - lib/keepassx/entry.rb
70
+ - lib/keepassx/entry_field.rb
71
+ - lib/keepassx/group.rb
72
+ - lib/keepassx/group_field.rb
73
+ - lib/keepassx/header.rb
74
+ - spec/keepassx/database_spec.rb
75
+ - spec/spec_helper.rb
76
+ - spec/test_database.kdb
77
+ has_rdoc: true
78
+ homepage: http://github.com/pitluga/keepassx
79
+ licenses: []
80
+
81
+ post_install_message:
82
+ rdoc_options: []
83
+
84
+ require_paths:
85
+ - lib
86
+ required_ruby_version: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ segments:
91
+ - 0
92
+ version: "0"
93
+ required_rubygems_version: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - ">="
96
+ - !ruby/object:Gem::Version
97
+ segments:
98
+ - 0
99
+ version: "0"
100
+ requirements: []
101
+
102
+ rubyforge_project:
103
+ rubygems_version: 1.3.6
104
+ signing_key:
105
+ specification_version: 3
106
+ summary: Ruby API access for KeePassX databases
107
+ test_files: []
108
+