clarion 0.1.0
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 +7 -0
- data/.gitignore +11 -0
- data/.rspec +3 -0
- data/.travis.yml +5 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +69 -0
- data/LICENSE.txt +21 -0
- data/README.md +66 -0
- data/Rakefile +6 -0
- data/app/public/register.js +82 -0
- data/app/public/sign.js +67 -0
- data/app/public/test.js +81 -0
- data/app/public/u2f-api.js +748 -0
- data/app/views/authn.erb +51 -0
- data/app/views/layout.erb +128 -0
- data/app/views/register.erb +55 -0
- data/app/views/test.erb +35 -0
- data/app/views/test_callback.erb +10 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/bin/test-authn +11 -0
- data/clarion.gemspec +32 -0
- data/config.ru +63 -0
- data/dev.rb +30 -0
- data/docs/api.md +113 -0
- data/docs/counters.md +14 -0
- data/docs/stores.md +13 -0
- data/lib/clarion.rb +3 -0
- data/lib/clarion/app.rb +271 -0
- data/lib/clarion/authenticator.rb +51 -0
- data/lib/clarion/authn.rb +106 -0
- data/lib/clarion/config.rb +58 -0
- data/lib/clarion/const_finder.rb +24 -0
- data/lib/clarion/counters.rb +9 -0
- data/lib/clarion/counters/base.rb +17 -0
- data/lib/clarion/counters/dynamodb.rb +45 -0
- data/lib/clarion/counters/memory.rb +29 -0
- data/lib/clarion/key.rb +76 -0
- data/lib/clarion/registrator.rb +26 -0
- data/lib/clarion/stores.rb +9 -0
- data/lib/clarion/stores/base.rb +17 -0
- data/lib/clarion/stores/memory.rb +30 -0
- data/lib/clarion/stores/s3.rb +54 -0
- data/lib/clarion/version.rb +3 -0
- metadata +199 -0
@@ -0,0 +1,106 @@
|
|
1
|
+
require 'time'
|
2
|
+
require 'securerandom'
|
3
|
+
require 'clarion/key'
|
4
|
+
|
5
|
+
module Clarion
|
6
|
+
class Authn
|
7
|
+
STATUSES = %i(open verified)
|
8
|
+
|
9
|
+
class << self
|
10
|
+
def make(**kwargs)
|
11
|
+
kwargs.delete(:id)
|
12
|
+
new(
|
13
|
+
id: random_id,
|
14
|
+
created_at: Time.now,
|
15
|
+
**kwargs,
|
16
|
+
)
|
17
|
+
end
|
18
|
+
|
19
|
+
def random_id
|
20
|
+
SecureRandom.urlsafe_base64(64)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def initialize(id:, name: nil, comment: nil, keys: [], created_at:, expires_at:, status:, verified_at: nil, verified_key: nil)
|
25
|
+
@id = id
|
26
|
+
@name = name
|
27
|
+
@comment = comment
|
28
|
+
@keys = keys.map{ |_| _.is_a?(Hash) ? Key.new(**_) : _}
|
29
|
+
@created_at = created_at
|
30
|
+
@expires_at = expires_at
|
31
|
+
@status = status.to_sym
|
32
|
+
@verified_at = verified_at
|
33
|
+
@verified_key = verified_key.is_a?(Hash) ? Key.new(**verified_key) : verified_key
|
34
|
+
|
35
|
+
@created_at = Time.xmlschema(@created_at) if @created_at && @created_at.is_a?(String)
|
36
|
+
@expires_at = Time.xmlschema(@expires_at) if @expires_at && @expires_at.is_a?(String)
|
37
|
+
@verified_at = Time.xmlschema(@verified_at) if @verified_at && @verified_at.is_a?(String)
|
38
|
+
|
39
|
+
raise ArgumentError, ":status not valid" unless STATUSES.include?(@status)
|
40
|
+
end
|
41
|
+
|
42
|
+
attr_reader :id, :name, :comment
|
43
|
+
attr_reader :keys
|
44
|
+
attr_reader :created_at
|
45
|
+
attr_reader :expires_at
|
46
|
+
attr_reader :status
|
47
|
+
attr_reader :verified_at, :verified_key
|
48
|
+
|
49
|
+
def expired?
|
50
|
+
Time.now > expires_at
|
51
|
+
end
|
52
|
+
|
53
|
+
def open?
|
54
|
+
status == :open
|
55
|
+
end
|
56
|
+
|
57
|
+
def verified?
|
58
|
+
status == :verified
|
59
|
+
end
|
60
|
+
|
61
|
+
def key_for_handle(handle)
|
62
|
+
keys.find { |_| _.handle == handle }
|
63
|
+
end
|
64
|
+
|
65
|
+
def verify(key, verified_at: Time.now)
|
66
|
+
unless key_for_handle(key.handle)
|
67
|
+
return false
|
68
|
+
end
|
69
|
+
@verified_at = verified_at
|
70
|
+
@verified_key = key
|
71
|
+
@status = :verified
|
72
|
+
true
|
73
|
+
end
|
74
|
+
|
75
|
+
def to_h(all=false)
|
76
|
+
{
|
77
|
+
id: id,
|
78
|
+
status: status,
|
79
|
+
name: name,
|
80
|
+
comment: comment,
|
81
|
+
created_at: created_at,
|
82
|
+
expires_at: expires_at,
|
83
|
+
}.tap do |h|
|
84
|
+
if verified_key
|
85
|
+
h[:verified_at] = verified_at
|
86
|
+
h[:verified_key] = verified_key.to_h(all)
|
87
|
+
end
|
88
|
+
if all
|
89
|
+
h[:keys] = keys.map{ |_| _.to_h(all) }
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
def as_json(*args)
|
95
|
+
to_h(*args).tap { |_|
|
96
|
+
_[:created_at] = _[:created_at].xmlschema if _[:created_at]
|
97
|
+
_[:verified_at] = _[:verified_at].xmlschema if _[:verified_at]
|
98
|
+
_[:expires_at] = _[:expires_at].xmlschema if _[:expires_at]
|
99
|
+
}
|
100
|
+
end
|
101
|
+
|
102
|
+
def to_json(*args)
|
103
|
+
as_json(*args).to_json
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
require 'clarion/stores'
|
2
|
+
require 'clarion/counters'
|
3
|
+
|
4
|
+
module Clarion
|
5
|
+
class Config
|
6
|
+
class << self
|
7
|
+
def option(meth)
|
8
|
+
options << meth
|
9
|
+
end
|
10
|
+
|
11
|
+
def options
|
12
|
+
@options ||= []
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def initialize(options={})
|
17
|
+
@options = options
|
18
|
+
|
19
|
+
# Validation
|
20
|
+
self.class.options.each do |m|
|
21
|
+
send(m)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
attr_reader :options
|
26
|
+
|
27
|
+
option def registration_allowed_url
|
28
|
+
@options.fetch(:registration_allowed_url)
|
29
|
+
end
|
30
|
+
|
31
|
+
option def authn_default_expires_in
|
32
|
+
@options.fetch(:authn_default_expires_in, 300).to_i
|
33
|
+
end
|
34
|
+
|
35
|
+
option def app_id
|
36
|
+
@options[:app_id]
|
37
|
+
end
|
38
|
+
|
39
|
+
option def store
|
40
|
+
@store ||= Clarion::Stores.find(@options.fetch(:store).fetch(:kind)).new(store_options)
|
41
|
+
end
|
42
|
+
|
43
|
+
def store_options
|
44
|
+
@store_options ||= @options.fetch(:store).dup.tap { |_| _.delete(:kind) }
|
45
|
+
end
|
46
|
+
|
47
|
+
option def counter
|
48
|
+
if @options[:counter]
|
49
|
+
@counter ||= Clarion::Counters.find(@options.fetch(:counter).fetch(:kind)).new(counter_options)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def counter_options
|
54
|
+
@counter_options ||= @options.fetch(:counter).dup.tap { |_| _.delete(:kind) }
|
55
|
+
end
|
56
|
+
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
module Clarion
|
2
|
+
module ConstFinder
|
3
|
+
def self.find(const, prefix, name)
|
4
|
+
retried = false
|
5
|
+
constant_name = name.to_s.gsub(/\A.|_./) { |s| s[-1].upcase }
|
6
|
+
|
7
|
+
begin
|
8
|
+
const.const_get constant_name, false
|
9
|
+
rescue NameError
|
10
|
+
unless retried
|
11
|
+
begin
|
12
|
+
require "#{prefix}/#{name}"
|
13
|
+
rescue LoadError
|
14
|
+
end
|
15
|
+
|
16
|
+
retried = true
|
17
|
+
retry
|
18
|
+
end
|
19
|
+
|
20
|
+
nil
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
require 'aws-sdk-dynamodb'
|
2
|
+
require 'clarion/counters/base'
|
3
|
+
|
4
|
+
module Clarion
|
5
|
+
module Counters
|
6
|
+
class Dynamodb < Base
|
7
|
+
def initialize(table_name:, region:)
|
8
|
+
@table_name = table_name
|
9
|
+
@region = region
|
10
|
+
end
|
11
|
+
|
12
|
+
def get(key)
|
13
|
+
item = table.query(
|
14
|
+
limit: 1,
|
15
|
+
select: 'ALL_ATTRIBUTES',
|
16
|
+
key_condition_expression: 'handle = :handle',
|
17
|
+
expression_attribute_values: {":handle" => key.handle},
|
18
|
+
).items.first
|
19
|
+
|
20
|
+
item && item['key_counter']
|
21
|
+
end
|
22
|
+
|
23
|
+
def store(key)
|
24
|
+
table.update_item(
|
25
|
+
key: {
|
26
|
+
'handle' => key.handle,
|
27
|
+
},
|
28
|
+
update_expression: 'SET key_counter = :new',
|
29
|
+
condition_expression: 'attribute_not_exists(key_counter) OR key_counter < :new',
|
30
|
+
expression_attribute_values: {':new' => key.counter},
|
31
|
+
)
|
32
|
+
rescue Aws::DynamoDB::Errors::ConditionalCheckFailedException
|
33
|
+
end
|
34
|
+
|
35
|
+
def table
|
36
|
+
@table ||= dynamodb.table(@table_name)
|
37
|
+
end
|
38
|
+
|
39
|
+
def dynamodb
|
40
|
+
@dynamodb ||= Aws::DynamoDB::Resource.new(region: @region)
|
41
|
+
end
|
42
|
+
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
require 'thread'
|
2
|
+
require 'clarion/counters/base'
|
3
|
+
|
4
|
+
module Clarion
|
5
|
+
module Counters
|
6
|
+
class Memory < Base
|
7
|
+
def initialize(*)
|
8
|
+
super
|
9
|
+
@lock = Mutex.new
|
10
|
+
@counters = {}
|
11
|
+
end
|
12
|
+
|
13
|
+
def get(key)
|
14
|
+
@lock.synchronize do
|
15
|
+
@counters[key.handle]
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def store(key)
|
20
|
+
@lock.synchronize do
|
21
|
+
counter = @counters[key.handle]
|
22
|
+
if !counter || key.counter > counter
|
23
|
+
@counters[key.handle] = key.counter
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
data/lib/clarion/key.rb
ADDED
@@ -0,0 +1,76 @@
|
|
1
|
+
module Clarion
|
2
|
+
class Key
|
3
|
+
CIPHER_ALGO = 'aes-256-gcm'
|
4
|
+
def self.from_encrypted_json(private_key, json)
|
5
|
+
payload = JSON.parse(json, symbolize_names: true)
|
6
|
+
encrypted_data = payload.fetch(:data).unpack('m*')[0]
|
7
|
+
encrypted_shared_key = payload.fetch(:key).unpack('m*')[0]
|
8
|
+
|
9
|
+
shared_key_json = private_key.private_decrypt(encrypted_shared_key, OpenSSL::PKey::RSA::PKCS1_OAEP_PADDING)
|
10
|
+
shared_key_info = JSON.parse(shared_key_json, symbolize_names: true)
|
11
|
+
iv = shared_key_info.fetch(:iv).unpack('m*')[0]
|
12
|
+
shared_key = shared_key_info.fetch(:key).unpack('m*')[0]
|
13
|
+
tag = shared_key_info.fetch(:tag).unpack('m*')[0]
|
14
|
+
|
15
|
+
cipher = OpenSSL::Cipher.new(CIPHER_ALGO).tap do |c|
|
16
|
+
c.decrypt
|
17
|
+
c.key = shared_key
|
18
|
+
c.iv = iv
|
19
|
+
c.auth_data = ''
|
20
|
+
c.auth_tag = tag
|
21
|
+
end
|
22
|
+
|
23
|
+
key_json = cipher.update(encrypted_data)
|
24
|
+
key_json << cipher.final
|
25
|
+
key = JSON.parse(key_json, symbolize_names: true)
|
26
|
+
new(**key)
|
27
|
+
end
|
28
|
+
|
29
|
+
def initialize(handle:, name: nil, public_key: nil, counter: nil)
|
30
|
+
@handle = handle
|
31
|
+
@name = name
|
32
|
+
@public_key = public_key
|
33
|
+
@counter = counter
|
34
|
+
end
|
35
|
+
|
36
|
+
attr_reader :handle, :name, :public_key
|
37
|
+
attr_accessor :counter
|
38
|
+
|
39
|
+
def to_h(all=false)
|
40
|
+
{
|
41
|
+
handle: handle,
|
42
|
+
}.tap do |h|
|
43
|
+
h[:name] = name if name
|
44
|
+
h[:counter] = counter if counter
|
45
|
+
if all
|
46
|
+
h[:public_key] = public_key if public_key
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def to_json(*args)
|
52
|
+
to_h(*args).to_json
|
53
|
+
end
|
54
|
+
|
55
|
+
def to_encrypted_json(public_key, *args)
|
56
|
+
cipher = OpenSSL::Cipher.new(CIPHER_ALGO)
|
57
|
+
shared_key = OpenSSL::Random.random_bytes(cipher.key_len)
|
58
|
+
cipher.encrypt
|
59
|
+
cipher.key = shared_key
|
60
|
+
cipher.iv = iv = cipher.random_iv
|
61
|
+
cipher.auth_data = ''
|
62
|
+
|
63
|
+
json = to_json(*args)
|
64
|
+
|
65
|
+
ciphertext = cipher.update(json)
|
66
|
+
ciphertext << cipher.final
|
67
|
+
|
68
|
+
encrypted_key = public_key.public_encrypt({
|
69
|
+
iv: [iv].pack('m*').gsub(/\r?\n/,''),
|
70
|
+
tag: [cipher.auth_tag].pack('m*').gsub(/\r?\n/,''),
|
71
|
+
key: [shared_key].pack('m*').gsub(/\r?\n/,''),
|
72
|
+
}.to_json, OpenSSL::PKey::RSA::PKCS1_OAEP_PADDING)
|
73
|
+
{data: [ciphertext].pack('m*'), key: [encrypted_key].pack('m*')}.to_json
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
require 'clarion/key'
|
2
|
+
|
3
|
+
module Clarion
|
4
|
+
class Registrator
|
5
|
+
def initialize(u2f, counter)
|
6
|
+
@u2f = u2f
|
7
|
+
@counter = counter
|
8
|
+
end
|
9
|
+
|
10
|
+
attr_reader :u2f, :counter
|
11
|
+
|
12
|
+
def request
|
13
|
+
[u2f.app_id, u2f.registration_requests]
|
14
|
+
end
|
15
|
+
|
16
|
+
def register!(challenges, response_json)
|
17
|
+
response = U2F::RegisterResponse.load_from_json(response_json)
|
18
|
+
reg = u2f.register!(challenges, response)
|
19
|
+
key = Key.new(handle: reg.key_handle, public_key: reg.public_key, counter: reg.counter)
|
20
|
+
if counter
|
21
|
+
counter.store(key)
|
22
|
+
end
|
23
|
+
key
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
require 'thread'
|
2
|
+
require 'clarion/stores/base'
|
3
|
+
require 'clarion/authn'
|
4
|
+
|
5
|
+
module Clarion
|
6
|
+
module Stores
|
7
|
+
class Memory < Base
|
8
|
+
def initialize(*)
|
9
|
+
super
|
10
|
+
@lock = Mutex.new
|
11
|
+
@store = {}
|
12
|
+
end
|
13
|
+
|
14
|
+
def store_authn(authn)
|
15
|
+
@lock.synchronize do
|
16
|
+
@store[authn.id] = authn.to_h(:all)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def find_authn(id)
|
21
|
+
@lock.synchronize do
|
22
|
+
unless @store.key?(id)
|
23
|
+
return nil
|
24
|
+
end
|
25
|
+
Authn.new(**@store[id])
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|