smaak 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: b213ebcf6f507b5c7ecd8b92bc4ea8c5d38a9f1f
4
+ data.tar.gz: 951d7a6c199366cabbc88d386f7e05adcfbb3fe7
5
+ SHA512:
6
+ metadata.gz: 2022526d04a845e57d9d8837ed3105ae31c17d7e5fbab4ac2a5cc154c6c733f82c174ad958fae1beed1b4e38f36a5bac16f889f687b391cbd000301f1e99ad2f
7
+ data.tar.gz: 43307e566b241328de13db9a69d07cdd3ae1ce697d25ab18c4cc884941772d82d1d4cdb44a25c7f6930e838cf8741ecd2d61fbe9fff099891446004acfe27130
data/.gitignore ADDED
@@ -0,0 +1,3 @@
1
+ coverage
2
+ Gemfile.lock
3
+ *swp
data/.ruby-gemset ADDED
@@ -0,0 +1 @@
1
+ smaak
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ ruby-2.0.0-p451
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in smaak.gemspec
4
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2015 Ernst van Graan
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,29 @@
1
+ # Smaak
2
+
3
+ TODO: Write a gem description
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ gem 'smaak'
10
+
11
+ And then execute:
12
+
13
+ $ bundle
14
+
15
+ Or install it yourself as:
16
+
17
+ $ gem install smaak
18
+
19
+ ## Usage
20
+
21
+ TODO: Write usage instructions here
22
+
23
+ ## Contributing
24
+
25
+ 1. Fork it
26
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
27
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
28
+ 4. Push to the branch (`git push origin my-new-feature`)
29
+ 5. Create new Pull Request
data/Rakefile ADDED
@@ -0,0 +1 @@
1
+ require "bundler/gem_tasks"
@@ -0,0 +1,53 @@
1
+ require 'persistent-cache'
2
+
3
+ module Smaak
4
+ class Associate
5
+ attr_reader :association_store
6
+ attr_reader :token_life
7
+ attr_reader :key
8
+
9
+ def initialize
10
+ @association_store = Persistent::Cache.new("association_store", nil, Persistent::Cache::STORAGE_RAM)
11
+ @token_life = Smaak::DEFAULT_TOKEN_LIFE
12
+ end
13
+
14
+ def add_association(identity, key, psk)
15
+ the_key = key.is_a?(String) ? OpenSSL::PKey::RSA.new(key) : key
16
+ raise ArgumentError.new("Key needs to be valid") if not validate_key(the_key)
17
+ @association_store[identity] = { 'public_key' => the_key, 'psk' => psk }
18
+
19
+ rescue OpenSSL::PKey::RSAError
20
+ raise ArgumentError.new("Key needs to be valid")
21
+ end
22
+
23
+ def set_token_life(token_life)
24
+ raise ArgumentError.new("Token life has to be a positive number of seconds") if not validate_token_life(token_life)
25
+ @token_life = token_life
26
+ end
27
+
28
+ def set_key(key)
29
+ the_key = key.is_a?(String) ? OpenSSL::PKey::RSA.new(key) : key
30
+ raise ArgumentError.new("Key needs to be valid") if not validate_key(the_key)
31
+ @key = the_key
32
+
33
+ rescue OpenSSL::PKey::RSAError
34
+ raise ArgumentError.new("Key needs to be valid")
35
+ end
36
+
37
+ private
38
+
39
+ def validate_key(key)
40
+ return false if key.nil?
41
+ return false if key.is_a? String and key.empty?
42
+ return false if not key.is_a? OpenSSL::PKey::RSA
43
+ true
44
+ end
45
+
46
+ def validate_token_life(token_life)
47
+ return false if token_life.nil?
48
+ return false if not token_life.is_a? Integer
49
+ return false if not token_life > 0
50
+ true
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,74 @@
1
+ module Smaak
2
+ class AuthMessage
3
+ attr_reader :message
4
+ attr_reader :message_data
5
+ attr_reader :identity
6
+ attr_reader :nonce
7
+
8
+ def initialize(message)
9
+ raise ArgumentError.new("Message not specified") if message.nil?
10
+ @message = message.dup
11
+ @message.freeze
12
+ begin
13
+ @message_data = JSON.parse(Base64.decode64(message))
14
+ @message_data.freeze
15
+ rescue => ex
16
+ raise ArgumentError.new("Message must have valid message data")
17
+ end
18
+ raise ArgumentError.new("Message must have a valid expiry set") if not validate_expiry
19
+ @identity = @message_data['identity']
20
+ @identity.freeze
21
+ raise ArgumentError.new("Message must have a valid identity set") if @identity.nil? or @identity.empty?
22
+ @nonce = @message_data['nonce']
23
+ @nonce.freeze
24
+ raise ArgumentError.new("Message must have a valid nonce") if not validate_nonce(@nonce)
25
+ end
26
+
27
+ def expired?
28
+ @message_data['expires'].to_i < Time.now.to_i
29
+ end
30
+
31
+ def signature_ok?(signature, pubkey)
32
+ return false if signature.nil?
33
+ return false if pubkey.nil?
34
+ digest = OpenSSL::Digest::SHA256.new
35
+ pubkey.verify(digest, signature, @message)
36
+ end
37
+
38
+ def psk_match?(psk)
39
+ return false if psk.nil?
40
+ return false if @message_data['psk'].nil?
41
+ @message_data['psk'] == psk
42
+ end
43
+
44
+ def intended_for_recipient?(pubkey)
45
+ return false if pubkey.nil?
46
+ return false if @message_data['recipient'].nil?
47
+ @message_data['recipient'] == pubkey
48
+ end
49
+
50
+ def verify(signature, pubkey, psk)
51
+ return false if expired?
52
+ return false if not signature_ok?(signature, pubkey)
53
+ return false if not psk_match?(Smaak::obfuscate_psk(psk))
54
+ return false if not intended_for_recipient?(pubkey.export)
55
+ identity
56
+ end
57
+
58
+ private
59
+
60
+ def validate_nonce(nonce)
61
+ return false if nonce.nil?
62
+ return false if nonce.to_i == 0
63
+ true
64
+ end
65
+
66
+ def validate_expiry
67
+ return false if @message_data.nil?
68
+ return false if not @message_data.is_a? Hash
69
+ return false if @message_data['expires'].nil?
70
+ return false if not (@message_data['expires'].to_i > 0)
71
+ true
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,33 @@
1
+ module Smaak
2
+ class Client < Associate
3
+ attr_accessor :identity
4
+ attr_accessor :private_key
5
+
6
+ def set_private_key(key)
7
+ set_key(key)
8
+ end
9
+
10
+ def set_identity(identity)
11
+ @identity = identity
12
+ end
13
+
14
+ def build_auth_header(associate_identity)
15
+ raise ArgumentError.new("Associate invalid") if not validate_associate(associate_identity)
16
+ associate = @association_store[associate_identity]
17
+ message_data = Smaak::compile_auth_message_data(associate['public_key'], associate['psk'], @token_life, @identity)
18
+ signature = Smaak::sign_message_data(message_data, @key)
19
+ message = Smaak::build_message(message_data)
20
+ auth_body = { 'message' => message,
21
+ 'signature' => Base64.encode64(signature) }
22
+ auth = auth_body.to_json
23
+ end
24
+
25
+ private
26
+
27
+ def validate_associate(associate_identity)
28
+ return false if associate_identity.nil?
29
+ return false if @association_store[associate_identity].nil?
30
+ true
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,33 @@
1
+ require 'smaak/associate.rb'
2
+
3
+ module Smaak
4
+ class Server < Associate
5
+ attr_accessor :nonce_store
6
+ attr_accessor :public_key
7
+
8
+ def initialize
9
+ super
10
+ @nonce_store = Persistent::Cache.new("nonce_store", @token_life, Persistent::Cache::STORAGE_RAM)
11
+ end
12
+
13
+ def set_public_key(key)
14
+ set_key(key)
15
+ end
16
+
17
+ def auth_message_unique?(auth_message)
18
+ if nonce_store[auth_message.nonce].nil?
19
+ nonce_store[auth_message.nonce] = 1
20
+ return true
21
+ end
22
+ false
23
+ end
24
+
25
+ def verify_auth_message(auth_message, signature)
26
+ return false if not auth_message_unique?(auth_message)
27
+ identity = auth_message.identity
28
+ pubkey = @association_store[identity]['public_key']
29
+ psk = @association_store[identity]['psk']
30
+ auth_message.verify(signature, pubkey, psk)
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,3 @@
1
+ module Smaak
2
+ VERSION = "0.0.1"
3
+ end
data/lib/smaak.rb ADDED
@@ -0,0 +1,33 @@
1
+ require "smaak/version"
2
+
3
+ require 'openssl'
4
+
5
+ module Smaak
6
+ DEFAULT_TOKEN_LIFE = 2 unless defined? DEFAULT_TOKEN_LIFE; DEFAULT_TOKEN_LIFE.freeze
7
+
8
+ def self.obfuscate_psk(psk)
9
+ Digest::MD5.hexdigest(psk.reverse)
10
+ end
11
+
12
+ def self.build_message(message_data)
13
+ Base64.encode64(message_data.to_json)
14
+ end
15
+
16
+ def self.sign_message_data(message_data, private_key)
17
+ digest = OpenSSL::Digest::SHA256.new
18
+ private_key.sign(digest, Smaak::build_message(message_data))
19
+ end
20
+
21
+ def self.generate_nonce
22
+ SecureRandom::random_number(10000000000)
23
+ end
24
+
25
+ def self.compile_auth_message_data(recipient_public_key, recipient_psk, token_life, identity)
26
+ { 'recipient' => recipient_public_key.export,
27
+ 'identity' => identity,
28
+ 'psk' => Smaak::obfuscate_psk(recipient_psk),
29
+ 'expires' => Time.now.to_i + token_life,
30
+ 'nonce' => Smaak::generate_nonce }
31
+ end
32
+ end
33
+
data/smaak.gemspec ADDED
@@ -0,0 +1,30 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'smaak/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "smaak"
8
+ spec.version = Smaak::VERSION
9
+ spec.authors = ["Ernst van Graan"]
10
+ spec.email = ["ernst.van.graan@hetzner.co.za"]
11
+ spec.description = %q{Signed Message Authentication and Authorization with Key validation}
12
+ spec.summary = %q{This gems caters for both client and server side of a signed message interaction over HTTP or HTTPS implementing the RFC2617 Digest Access Authentication. The following compromises are protected against as specified: Man in the middle / snooping (HTTPS turned on), Replay (nonce + expires), Forgery (signature), Masquerading (recipient pub key check), Clear-text password compromise (MD5 pre-shared key)}
13
+ spec.homepage = ""
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files`.split($/)
17
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
+ spec.require_paths = ["lib"]
20
+ spec.required_ruby_version = '>= 2.0'
21
+
22
+ spec.add_dependency "persistent-cache", ">= 0.3.9"
23
+ spec.add_dependency "unirest"
24
+ spec.add_development_dependency "bundler", "~> 1.3"
25
+ spec.add_development_dependency "rake"
26
+ spec.add_development_dependency "byebug"
27
+ spec.add_development_dependency 'simplecov'
28
+ spec.add_development_dependency 'simplecov-rcov'
29
+ spec.add_development_dependency 'rspec'
30
+ end
@@ -0,0 +1,98 @@
1
+ require './spec/spec_helper.rb'
2
+ require 'smaak'
3
+
4
+ describe Smaak::Associate do
5
+ before :all do
6
+ @test_private_key = OpenSSL::PKey::RSA.new(4096)
7
+ @test_psk = "testpresharedkey"
8
+ @test_public_key = @test_private_key.public_key
9
+ end
10
+
11
+ before :each do
12
+ @iut = Smaak::Associate.new
13
+ end
14
+
15
+ context "when initialized" do
16
+ it "should have an association store" do
17
+ expect(@iut.association_store.class).to eq(Persistent::Cache)
18
+ end
19
+ it "should default to DEFAULT_TOKEN_LIFE if a token life is not provided" do
20
+ expect(@iut.token_life).to eq(Smaak::DEFAULT_TOKEN_LIFE)
21
+ end
22
+ end
23
+
24
+ context "when given its own key" do
25
+ it "should remember its own key" do
26
+ @iut.set_key(@test_public_key)
27
+ expect(@iut.key).to eq(@test_public_key)
28
+ end
29
+
30
+ it "should convert its own key from string to RSA if given a string" do
31
+ @iut.set_key(@test_public_key.export)
32
+ expect(@iut.key.export).to eq(@test_public_key.export)
33
+ end
34
+
35
+ it "should raise an ArgumentError if the key is not a valid key" do
36
+ error = "Key needs to be valid"
37
+ expect {
38
+ @iut.set_key(nil)
39
+ }.to raise_error ArgumentError, error
40
+ expect {
41
+ @iut.set_key(1)
42
+ }.to raise_error ArgumentError, error
43
+ expect {
44
+ @iut.set_key("")
45
+ }.to raise_error ArgumentError, error
46
+ end
47
+ end
48
+
49
+ context "when given a token life" do
50
+ it "should remember a token life provided" do
51
+ @iut.set_token_life(6)
52
+ expect(@iut.token_life).to eq(6)
53
+ end
54
+
55
+ it "should raise an ArgumentError if the token life provided is not a valid number > 0" do
56
+ error = "Token life has to be a positive number of seconds"
57
+ expect {
58
+ @iut.set_token_life(nil)
59
+ }.to raise_error ArgumentError, error
60
+ expect {
61
+ @iut.set_token_life(0)
62
+ }.to raise_error ArgumentError, error
63
+ expect {
64
+ @iut.set_token_life(-1)
65
+ }.to raise_error ArgumentError, error
66
+ expect {
67
+ @iut.set_token_life("1")
68
+ }.to raise_error ArgumentError, error
69
+ end
70
+ end
71
+
72
+ context "when told about an association" do
73
+ error = "Key needs to be valid"
74
+ it "should raise an ArgumentError if the association does not have a valid key" do
75
+ expect {
76
+ @iut.add_association("test", nil, "psk")
77
+ }.to raise_error ArgumentError, error
78
+ expect {
79
+ @iut.add_association("test", 1, "psk")
80
+ }.to raise_error ArgumentError, error
81
+ expect {
82
+ @iut.add_association("test", "", "psk")
83
+ }.to raise_error ArgumentError, error
84
+ end
85
+
86
+ it "should remember the association's name, public key and pre-shared key" do
87
+ @iut.add_association("test", @test_public_key, @test_psk)
88
+ expect(@iut.association_store["test"].nil?).to eq(false)
89
+ expect(@iut.association_store["test"]["public_key"]).to eq(@test_public_key)
90
+ expect(@iut.association_store["test"]["psk"]).to eq(@test_psk)
91
+ end
92
+
93
+ it "should convert string keys into RSA keys" do
94
+ @iut.add_association("test", @test_public_key.export, @test_psk)
95
+ expect(@iut.association_store["test"]["public_key"].export).to eq(@test_public_key.export)
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,238 @@
1
+ require './spec/spec_helper.rb'
2
+ require 'smaak'
3
+
4
+ describe Smaak::AuthMessage do
5
+ before :all do
6
+ @test_nonce = 1234567890
7
+ @test_server_private_key = OpenSSL::PKey::RSA.new(4096)
8
+ @test_psk = "testpresharedkey"
9
+ @test_server_public_key = @test_server_private_key.public_key
10
+ @test_identity = 'test-service'
11
+ end
12
+
13
+ before :each do
14
+ @test_message_data = { 'recipient' => @test_server_public_key.export,
15
+ 'identity' => @test_identity,
16
+ 'psk' => Smaak::obfuscate_psk(@test_psk),
17
+ 'expires' => Time.now.to_i + 10,
18
+ 'nonce' => @test_nonce }
19
+ @test_message = Smaak::build_message(@test_message_data)
20
+
21
+ @iut = Smaak::AuthMessage.new(@test_message)
22
+ end
23
+
24
+ context "when initialized" do
25
+ it "should raise an ArgumentError if no message is provided" do
26
+ expect {
27
+ Smaak::AuthMessage.new(nil)
28
+ }.to raise_error ArgumentError, "Message not specified"
29
+ end
30
+
31
+ it "should remember the message provided" do
32
+ expect(@iut.message).to eq(@test_message)
33
+ end
34
+
35
+ it "should unpack the message data using Base64::decode and JSON.parse" do
36
+ expect(@iut.message_data).to eq(@test_message_data)
37
+ end
38
+
39
+ it "should raise an ArgumentError if the message does not have a valid expiry set" do
40
+ error = "Message must have a valid expiry set"
41
+
42
+ expired_data = @test_message_data.dup
43
+
44
+ expect {
45
+ expired_data['expires'] = ""
46
+ Smaak::AuthMessage.new(Smaak::build_message(expired_data))
47
+ }.to raise_error ArgumentError, error
48
+ expect {
49
+ expired_data['expires'] = nil
50
+ Smaak::AuthMessage.new(Smaak::build_message(expired_data))
51
+ }.to raise_error ArgumentError, error
52
+ expect {
53
+ expired_data['expires'] = 'sometimes'
54
+ Smaak::AuthMessage.new(Smaak::build_message(expired_data))
55
+ }.to raise_error ArgumentError, error
56
+ end
57
+
58
+ it "should prevent its message from being changed" do
59
+ expect{
60
+ @iut.message = ""
61
+ }.to raise_error NoMethodError
62
+ end
63
+
64
+ it "should prevent its message data from being changed" do
65
+ expect{
66
+ @iut.message_data = ""
67
+ }.to raise_error NoMethodError
68
+ end
69
+
70
+ it "should extract the identity from the message data and remember it" do
71
+ expect(@iut.identity).to eq("test-service")
72
+ end
73
+
74
+ it "should prevent its identity from being changed" do
75
+ expect{
76
+ @iut.identity = ""
77
+ }.to raise_error NoMethodError
78
+ end
79
+
80
+ it "should raise an ArgumentError if the message does not have a valid nonce set" do
81
+ error = "Message must have a valid nonce"
82
+
83
+ noncense_data = @test_message_data.dup
84
+
85
+ expect {
86
+ noncense_data['nonce'] = "broken"
87
+ Smaak::AuthMessage.new(Smaak::build_message(noncense_data))
88
+ }.to raise_error ArgumentError, error
89
+ expect {
90
+ noncense_data['nonce'] = nil
91
+ Smaak::AuthMessage.new(Smaak::build_message(noncense_data))
92
+ }.to raise_error ArgumentError, error
93
+ expect {
94
+ noncense_data['nonce'] = 0
95
+ Smaak::AuthMessage.new(Smaak::build_message(noncense_data))
96
+ }.to raise_error ArgumentError, error
97
+ end
98
+
99
+ it "should extract the nonce from the message data and remember it" do
100
+ expect(@iut.nonce).to eq(@test_nonce)
101
+ end
102
+
103
+ it "should prevent its nonce from being changed" do
104
+ expect{
105
+ @iut.nonce = ""
106
+ }.to raise_error NoMethodError
107
+ end
108
+
109
+ it "should raise an ArgumentError if the message data does not contain an identity" do
110
+ error = "Message must have a valid identity set"
111
+ anonymous_data = @test_message_data.dup
112
+ anonymous_data['identity'] = nil
113
+ expect {
114
+ Smaak::AuthMessage.new(Smaak::build_message(anonymous_data))
115
+ }.to raise_error ArgumentError, error
116
+ anonymous_data['identity'] = ""
117
+ expect {
118
+ Smaak::AuthMessage.new(Smaak::build_message(anonymous_data))
119
+ }.to raise_error ArgumentError, error
120
+ end
121
+ end
122
+
123
+ context "when asked if it has expired" do
124
+ it "should return true if the current timestamp exceeds that of the message expiry" do
125
+ expired_data = @test_message_data.dup
126
+ expired_data['expires'] = Time.now - 1
127
+ iut = Smaak::AuthMessage.new(Smaak::build_message(expired_data))
128
+ expect(iut.expired?).to eq(true)
129
+ end
130
+
131
+ it "should return false if the current timestamp does not exceed that of the message expiry" do
132
+ expect(@iut.expired?).to eq(false)
133
+ end
134
+ end
135
+
136
+ context "when asked if the signature is ok given a signature and public key" do
137
+ before :each do
138
+ @test_signature = Smaak::sign_message_data(@test_message_data, @test_server_private_key)
139
+ end
140
+
141
+ it "should return false if the public key provided is not the correct key for the signature" do
142
+ mismatched = OpenSSL::PKey::RSA.new(4096).public_key
143
+ expect(@iut.signature_ok?(@test_signature, mismatched)).to eq(false)
144
+ end
145
+
146
+ it "should return false if the signature is nil" do
147
+ expect(@iut.signature_ok?(nil, @test_server_public_key)).to eq(false)
148
+ end
149
+
150
+ it "should return false if the public key is nil" do
151
+ expect(@iut.signature_ok?(@test_signature, nil)).to eq(false)
152
+ end
153
+
154
+ it "should return false if OpenSSL cannot verify the message against the signature using the public key" do
155
+ broken = @test_message_data
156
+ broken['identity'] = 'suspect-identity'
157
+ iut = Smaak::AuthMessage.new(Smaak::build_message(broken))
158
+ expect(iut.signature_ok?(@test_signature, @test_server_public_key)).to eq(false)
159
+ end
160
+
161
+ it "should return true if OpenSSL succesfully verifies message against the signature using the public key" do
162
+ expect(@iut.signature_ok?(@test_signature, @test_server_public_key)).to eq(true)
163
+ end
164
+ end
165
+
166
+ context "when asked whether a message's psk matched" do
167
+ it "should return false if no psk was provided" do
168
+ expect(@iut.psk_match?(nil)).to eq(false)
169
+ end
170
+
171
+ it "should return false if the message data does not include a psk" do
172
+ broken = @test_message_data
173
+ broken['psk'] = nil
174
+ iut = Smaak::AuthMessage.new(Smaak::build_message(broken))
175
+ expect(iut.psk_match?(@test_psk)).to eq(false)
176
+ end
177
+
178
+ it "should return false if the PSKs do not match" do
179
+ expect(@iut.psk_match?("doesnotmatch")).to eq(false)
180
+ end
181
+
182
+ it "should return true if the PSKs do match" do
183
+ expect(@iut.psk_match?(@test_psk)).to eq(false)
184
+ end
185
+ end
186
+
187
+ context "when asked whether this message is intended for a recipient, identified by public key" do
188
+ it "should return false if the recipient does not match the public key specified" do
189
+ mismatched = OpenSSL::PKey::RSA.new(4096).public_key
190
+ expect(@iut.intended_for_recipient?(mismatched.export)).to eq (false)
191
+ end
192
+
193
+ it "should return false if the message_data does not include a recipient" do
194
+ broken = @test_message_data
195
+ broken['recipient'] = nil
196
+ iut = Smaak::AuthMessage.new(Smaak::build_message(broken))
197
+ expect(iut.intended_for_recipient?(@test_server_public_key.export)).to eq (false)
198
+ end
199
+
200
+ it "should return false if the public key is not specified" do
201
+ expect(@iut.intended_for_recipient?(nil)).to eq (false)
202
+ end
203
+
204
+ it "should return true if the recipient matches the public key specified" do
205
+ expect(@iut.intended_for_recipient?(@test_server_public_key.export)).to eq (true)
206
+ end
207
+ end
208
+
209
+ context "when asked to verify the message using a signature" do
210
+ before :each do
211
+ @test_signature = Smaak::sign_message_data(@test_message_data, @test_server_private_key)
212
+ end
213
+
214
+ it "should check message expiry return false if the message has expired" do
215
+ expect(@iut).to(receive(:expired?)).and_return(true)
216
+ expect(@iut.verify(@test_signature, @test_server_public_key, @test_psk)).to eq(false)
217
+ end
218
+
219
+ it "should check the signature and return false if not OK" do
220
+ expect(@iut).to(receive(:signature_ok?)).and_return(false)
221
+ expect(@iut.verify(@test_signature, @test_server_public_key, @test_psk)).to eq(false)
222
+ end
223
+
224
+ it "should try and match the PSK and return false if it cannot" do
225
+ expect(@iut).to(receive(:psk_match?)).and_return(false)
226
+ expect(@iut.verify(@test_signature, @test_server_public_key, @test_psk)).to eq(false)
227
+ end
228
+
229
+ it "should ensure the message is intended for this service and return false if it is not" do
230
+ expect(@iut).to(receive(:intended_for_recipient?)).and_return(false)
231
+ expect(@iut.verify(@test_signature, @test_server_public_key, @test_psk)).to eq(false)
232
+ end
233
+
234
+ it "should return the identity specified in the message if the message was successfully verified" do
235
+ expect(@iut.verify(@test_signature, @test_server_public_key, @test_psk)).to eq(@test_identity)
236
+ end
237
+ end
238
+ end
@@ -0,0 +1,71 @@
1
+ require './spec/spec_helper.rb'
2
+ require 'smaak'
3
+
4
+ describe Smaak::Client do
5
+ before :all do
6
+ @test_service_identity = 'service-to-talk-to'
7
+ @test_service_psk = 'testsharedsecret'
8
+ @test_client_private_key = OpenSSL::PKey::RSA.new(4096)
9
+ @test_service_private_key = OpenSSL::PKey::RSA.new(4096)
10
+ @test_service_public_key = @test_service_private_key.public_key
11
+ @iut = Smaak::Client.new
12
+ @test_identity = 'test-client'
13
+ @test_token_life = 5
14
+ @iut.set_identity(@test_identity)
15
+ @iut.set_private_key(@test_client_private_key)
16
+ @iut.set_token_life(@test_token_life)
17
+ @iut.add_association(@test_service_identity, @test_service_public_key, @test_service_psk)
18
+ end
19
+
20
+ context "when given an identity" do
21
+ it "should remember an identity provided" do
22
+ iut = Smaak::Client.new
23
+ expect(iut.identity).to eq(nil)
24
+ iut.set_identity('test-client')
25
+ expect(iut.identity).to eq('test-client')
26
+ end
27
+ end
28
+
29
+ context "when asked to build an auth header destined for an associate" do
30
+ it "should raise an ArgumentError if the associate is unknown" do
31
+ expect{
32
+ @iut.build_auth_header("unknown")
33
+ }.to raise_error ArgumentError, "Associate invalid"
34
+ expect{
35
+ @iut.build_auth_header(nil)
36
+ }.to raise_error ArgumentError, "Associate invalid"
37
+ end
38
+
39
+ it "should compile the auth message data using the associate details" do
40
+ expect(Smaak).to receive(:compile_auth_message_data).with(@test_service_public_key, @test_service_psk, @test_token_life, @test_identity)
41
+ @iut.build_auth_header(@test_service_identity)
42
+ end
43
+
44
+ it "should build the message" do
45
+ expect(Smaak).to receive(:generate_nonce).twice().and_return(12345)
46
+ test_message_data = Smaak::compile_auth_message_data(@test_service_public_key, @test_service_psk, @test_token_life, @test_identity)
47
+ expect(Smaak).to receive(:build_message).with(test_message_data)
48
+ expect{
49
+ @iut.build_auth_header(@test_service_identity)
50
+ }.to raise_error
51
+ end
52
+
53
+ it "should sign the message" do
54
+ expect(Smaak).to receive(:generate_nonce).twice().and_return(12345)
55
+ test_message_data = Smaak::compile_auth_message_data(@test_service_public_key, @test_service_psk, @test_token_life, @test_identity)
56
+ expect(Smaak).to receive(:sign_message_data).with(test_message_data, @test_client_private_key)
57
+ expect{
58
+ @iut.build_auth_header(@test_service_identity)
59
+ }.to raise_error
60
+ end
61
+
62
+ it "should place the message and its signature (encoded base64) in a JSON dictionary" do
63
+ expect(Smaak).to receive(:generate_nonce).twice().and_return(12345)
64
+ test_message_data = Smaak::compile_auth_message_data(@test_service_public_key, @test_service_psk, @test_token_life, @test_identity)
65
+ test_signature = Smaak::sign_message_data(test_message_data, @test_client_private_key)
66
+ test_message = Smaak::build_message(test_message_data)
67
+ test_envelope = { 'message' => test_message, 'signature' => Base64.encode64(test_signature) }.to_json
68
+ expect(@iut.build_auth_header(@test_service_identity)).to eq(test_envelope)
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,91 @@
1
+ require './spec/spec_helper.rb'
2
+ require 'smaak'
3
+
4
+ describe Smaak::Server do
5
+ before :all do
6
+ @iut = Smaak::Server.new
7
+ @iut.set_token_life(2)
8
+ @test_nonce = 1234567890
9
+ @test_server_private_key = OpenSSL::PKey::RSA.new(4096)
10
+ @test_psk = "testpresharedkey"
11
+ @test_server_public_key = @test_server_private_key.public_key
12
+ @test_identity = 'test-service'
13
+ end
14
+
15
+ before :each do
16
+ @test_message_data = { 'recipient' => @test_server_public_key.export,
17
+ 'identity' => @test_identity,
18
+ 'psk' => Smaak::obfuscate_psk(@test_psk),
19
+ 'expires' => Time.now.to_i + 10,
20
+ 'nonce' => @test_nonce }
21
+ @message = Smaak::AuthMessage.new(Smaak::build_message(@test_message_data))
22
+ @iut.nonce_store[@test_nonce] = nil
23
+ expect(@iut.nonce_store[@test_nonce]).to eq(nil)
24
+ end
25
+
26
+ context "when initialized" do
27
+ it "should have a nonce store" do
28
+ expect(@iut.nonce_store.class).to eq(Persistent::Cache)
29
+ end
30
+
31
+ it "should not know its own public key" do
32
+ iut = Smaak::Server.new
33
+ expect(iut.public_key).to eq(nil)
34
+ end
35
+ end
36
+
37
+ context "when preventing replay attacks" do
38
+ it "should forget about nonces older than token_life" do
39
+ nonces = @iut.nonce_store
40
+ nonces[@test_nonce] = 1
41
+ expect(nonces[@test_nonce]).to eq(1)
42
+ sleep @iut.token_life
43
+ expect(nonces[@test_nonce]).to eq(nil)
44
+ end
45
+
46
+ it "should remember nonces younger than token_life" do
47
+ nonces = @iut.nonce_store
48
+ nonces[@test_nonce] = 1
49
+ expect(nonces[@test_nonce]).to eq(1)
50
+ sleep @iut.token_life - 1
51
+ expect(nonces[@test_nonce]).to eq(1)
52
+ end
53
+ end
54
+
55
+ context "when asked if a message is unique" do
56
+ it "should store a nonce once it has seen it" do
57
+ @iut.auth_message_unique?(@message)
58
+ expect(@iut.nonce_store[@test_nonce]).to eq(1)
59
+ end
60
+
61
+ it "should return true if the message nonce was not seen in the last token_life period" do
62
+ expect(@iut.auth_message_unique?(@message)).to eq(true)
63
+ end
64
+
65
+ it "should return false if the message was seen in the last token_life period" do
66
+ expect(@iut.auth_message_unique?(@message)).to eq(true)
67
+ expect(@iut.auth_message_unique?(@message)).to eq(false)
68
+ end
69
+ end
70
+
71
+ context "when asked to verify a message given a signature" do
72
+ before :each do
73
+ @signature = Smaak::sign_message_data(@test_message_data, @test_server_private_key)
74
+ @iut.set_public_key(@test_server_public_key.export)
75
+ @iut.add_association(@test_identity, @test_server_public_key.export, @test_psk)
76
+ end
77
+
78
+ it "should look up the identity specified in the message and retrieve its public key from the associations store" do
79
+ expect(@iut.verify_auth_message(@message, @signature)).to eq(@test_identity)
80
+ end
81
+
82
+ it "should look up the identity specified in the message and retrieve its PSK from the associations store" do
83
+ expect(@iut.verify_auth_message(@message, @signature)).to eq(@test_identity)
84
+ end
85
+
86
+ it "should check nonce uniqueness and return false if not unique" do
87
+ expect(@iut.auth_message_unique?(@message)).to eq(true)
88
+ expect(@iut.verify_auth_message(@message, @signature)).to eq(false)
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,87 @@
1
+ require './spec/spec_helper.rb'
2
+
3
+ describe Smaak do
4
+ context "when loaded" do
5
+ it "should specify a default token life" do
6
+ expect(Smaak::DEFAULT_TOKEN_LIFE).to eq(2)
7
+ end
8
+ end
9
+
10
+ context "when asked to obfuscate a clear-text psk" do
11
+ it "should reverse the psk and appluy an MD5 hexadecimal digest to the result" do
12
+ expect(Smaak::obfuscate_psk('sharedsecret')).to eq(Digest::MD5.hexdigest('sharedsecret'.reverse))
13
+ end
14
+ end
15
+
16
+ context "when asked to build an auth message" do
17
+ it "should base 64 encode a json representation of the message" do
18
+ testm = {'a' => 'A'}
19
+ expect(Smaak::build_message(testm)).to eq(Base64::encode64(testm.to_json))
20
+ end
21
+ end
22
+
23
+ context "when asked to sign a message given a private key" do
24
+ it "should sign the message with the key using a 256 bit digest" do
25
+ private_key = OpenSSL::PKey::RSA.new(4096)
26
+ message_data = {'a' => 'B'}
27
+ message = Smaak::build_message(message_data)
28
+ digest = OpenSSL::Digest::SHA256.new
29
+ expect(Smaak::sign_message_data(message_data, private_key)).to eq(private_key.sign(digest, message))
30
+ end
31
+ end
32
+
33
+ context "when asked to generate a nonce" do
34
+ it "should generate a random nonce with at most 1 in a ten billion probability of a consecutive clash" do
35
+ expect(SecureRandom).to receive(:random_number).with(10000000000)
36
+ Smaak::generate_nonce
37
+ end
38
+
39
+ it "should generate a different nonce every time with high probability (less than 1 in 10000) given a history of 1000000 nonces" do
40
+ repeat = {}
41
+ failed = 0
42
+ threshold = 1000000
43
+ for i in 1..threshold do
44
+ value = Smaak::generate_nonce
45
+ failed = failed + 1 if repeat[value] == 1
46
+ repeat[value] = 1
47
+ end
48
+ failed_p = (failed.to_f / threshold) * 100
49
+ puts "I've seen #{failed_p} % of nonces before in #{threshold} generations"
50
+ expect(failed_p < 0.01).to eq(true)
51
+ end
52
+ end
53
+
54
+ context "when asked to compile an auth message data dictionary with addressed to an associate" do
55
+ before :all do
56
+ @associate_public_key = OpenSSL::PKey::RSA.new(4096).public_key
57
+ @associate_psk = 'sharedsecret'
58
+ @token_life = 3
59
+ @identity = 'test-service'
60
+ end
61
+
62
+ before :each do
63
+ @iut = Smaak::compile_auth_message_data(@associate_public_key, @associate_psk, @token_life, @identity)
64
+ @timestamp = Time.now.to_i
65
+ end
66
+
67
+ it "should set the recipient to the recipient specified" do
68
+ expect(@iut['recipient']).to eq(@associate_public_key.export)
69
+ end
70
+
71
+ it "should set the identity to the sender's identity" do
72
+ expect(@iut['identity']).to eq(@identity)
73
+ end
74
+
75
+ it "should set the psk to the psk specified, obfuscated" do
76
+ expect(@iut['psk']).to eq(Smaak::obfuscate_psk(@associate_psk))
77
+ end
78
+
79
+ it "should set the expiry to now + the token life specified" do
80
+ expect(@iut['expires']).to eq(@timestamp + @token_life)
81
+ end
82
+
83
+ it "should generate and assign a nonce" do
84
+ expect(@iut['nonce'] > 0).to eq(true)
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,40 @@
1
+ require 'rspec'
2
+ require 'rspec/mocks'
3
+ require 'tempfile'
4
+ require 'simplecov'
5
+ require 'simplecov-rcov'
6
+ require 'byebug'
7
+
8
+ # This file was generated by the `rspec --init` command. Conventionally, all
9
+ # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`.
10
+ # Require this file using `require "spec_helper"` to ensure that it is only
11
+ # loaded once.
12
+ #
13
+ # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
14
+
15
+ $:.unshift(File.join(File.dirname(__FILE__), '..', 'smaak'))
16
+ $:.unshift(File.join(File.dirname(__FILE__), '..'))
17
+
18
+ require 'lib/smaak.rb'
19
+ require 'lib/smaak/associate.rb'
20
+ require 'lib/smaak/server.rb'
21
+ require 'lib/smaak/client.rb'
22
+ require 'lib/smaak/auth_message.rb'
23
+
24
+ RSpec.configure do |config|
25
+ config.run_all_when_everything_filtered = true
26
+ config.filter_run :focus
27
+ #config.expect_with(:rspec) { |c| c.syntax = :should }
28
+
29
+ # Run specs in random order to surface order dependencies. If you find an
30
+ # order dependency and want to debug it, you can fix the order by providing
31
+ # the seed, which is printed after each run.
32
+ # --seed 1234
33
+ config.order = 'random'
34
+ end
35
+
36
+ SimpleCov.formatter = SimpleCov::Formatter::RcovFormatter
37
+ SimpleCov.start do
38
+ add_filter "/spec/"
39
+ end
40
+
metadata ADDED
@@ -0,0 +1,186 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: smaak
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Ernst van Graan
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2015-06-15 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: persistent-cache
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - '>='
18
+ - !ruby/object:Gem::Version
19
+ version: 0.3.9
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - '>='
25
+ - !ruby/object:Gem::Version
26
+ version: 0.3.9
27
+ - !ruby/object:Gem::Dependency
28
+ name: unirest
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - '>='
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - '>='
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: bundler
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ~>
46
+ - !ruby/object:Gem::Version
47
+ version: '1.3'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ~>
53
+ - !ruby/object:Gem::Version
54
+ version: '1.3'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rake
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - '>='
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - '>='
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: byebug
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - '>='
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - '>='
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: simplecov
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - '>='
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - '>='
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: simplecov-rcov
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - '>='
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - '>='
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: rspec
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - '>='
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - '>='
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
125
+ description: Signed Message Authentication and Authorization with Key validation
126
+ email:
127
+ - ernst.van.graan@hetzner.co.za
128
+ executables: []
129
+ extensions: []
130
+ extra_rdoc_files: []
131
+ files:
132
+ - .gitignore
133
+ - .ruby-gemset
134
+ - .ruby-version
135
+ - Gemfile
136
+ - LICENSE.txt
137
+ - README.md
138
+ - Rakefile
139
+ - lib/smaak.rb
140
+ - lib/smaak/associate.rb
141
+ - lib/smaak/auth_message.rb
142
+ - lib/smaak/client.rb
143
+ - lib/smaak/server.rb
144
+ - lib/smaak/version.rb
145
+ - smaak.gemspec
146
+ - spec/lib/smaak/associate_spec.rb
147
+ - spec/lib/smaak/auth_message_spec.rb
148
+ - spec/lib/smaak/client_spec.rb
149
+ - spec/lib/smaak/server_spec.rb
150
+ - spec/lib/smaak_spec.rb
151
+ - spec/spec_helper.rb
152
+ homepage: ''
153
+ licenses:
154
+ - MIT
155
+ metadata: {}
156
+ post_install_message:
157
+ rdoc_options: []
158
+ require_paths:
159
+ - lib
160
+ required_ruby_version: !ruby/object:Gem::Requirement
161
+ requirements:
162
+ - - '>='
163
+ - !ruby/object:Gem::Version
164
+ version: '2.0'
165
+ required_rubygems_version: !ruby/object:Gem::Requirement
166
+ requirements:
167
+ - - '>='
168
+ - !ruby/object:Gem::Version
169
+ version: '0'
170
+ requirements: []
171
+ rubyforge_project:
172
+ rubygems_version: 2.0.14
173
+ signing_key:
174
+ specification_version: 4
175
+ summary: 'This gems caters for both client and server side of a signed message interaction
176
+ over HTTP or HTTPS implementing the RFC2617 Digest Access Authentication. The following
177
+ compromises are protected against as specified: Man in the middle / snooping (HTTPS
178
+ turned on), Replay (nonce + expires), Forgery (signature), Masquerading (recipient
179
+ pub key check), Clear-text password compromise (MD5 pre-shared key)'
180
+ test_files:
181
+ - spec/lib/smaak/associate_spec.rb
182
+ - spec/lib/smaak/auth_message_spec.rb
183
+ - spec/lib/smaak/client_spec.rb
184
+ - spec/lib/smaak/server_spec.rb
185
+ - spec/lib/smaak_spec.rb
186
+ - spec/spec_helper.rb