clarion 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
data/docs/api.md
ADDED
@@ -0,0 +1,113 @@
|
|
1
|
+
# Clarion API
|
2
|
+
|
3
|
+
- Registration flow:
|
4
|
+
- _app_ redirects or submits `<form>` post to `/register`
|
5
|
+
- _Clarion_ does key registration work with user
|
6
|
+
- _Clarion_ redirects user back to a specified _callback_
|
7
|
+
- _app_ stores the key information
|
8
|
+
- Authentication flow:
|
9
|
+
- _app_ requests _Clarion_ for authentication (POST `/api/authn`)
|
10
|
+
- _app_ navigates user to authentication URL presented by _Clarion_
|
11
|
+
- _Clarion_ does U2F authentication work
|
12
|
+
- _app_ polls `/api/authn/:id` for authentication result
|
13
|
+
|
14
|
+
Pro Tips: _app_ could be anything (browser, CLI, etc) and _app_ for registration, and _app_ for authentication may be different.
|
15
|
+
|
16
|
+
## POST `/api/authn` (Request authentication)
|
17
|
+
|
18
|
+
### Request
|
19
|
+
|
20
|
+
application/json
|
21
|
+
|
22
|
+
``` json
|
23
|
+
{
|
24
|
+
"name": "USERNAME",
|
25
|
+
"comment": "COMMENT",
|
26
|
+
"keys": [
|
27
|
+
{
|
28
|
+
"handle": "KEYHANDLE",
|
29
|
+
"public_key": "PUBLICKEY",
|
30
|
+
"counter": COUNTER
|
31
|
+
}
|
32
|
+
]
|
33
|
+
}
|
34
|
+
```
|
35
|
+
|
36
|
+
### Response
|
37
|
+
|
38
|
+
Same with /api/authn/:id
|
39
|
+
|
40
|
+
## GET `/api/authn/:id` (Check authentication result)
|
41
|
+
|
42
|
+
### Request
|
43
|
+
|
44
|
+
- `:id` authn ID
|
45
|
+
|
46
|
+
### Response
|
47
|
+
|
48
|
+
``` json
|
49
|
+
{
|
50
|
+
"authn": {
|
51
|
+
"id": "AUTHN_ID",
|
52
|
+
"expires_at": "EXPIRY",
|
53
|
+
"html_url": "HTML_URL",
|
54
|
+
"url": "API_URL",
|
55
|
+
"status": "STATUS",
|
56
|
+
"verified_at": "VERIFIED_AT",
|
57
|
+
"verified_key": {
|
58
|
+
"handle": "KEYHANDLE",
|
59
|
+
"public_key": "PUBLICKEY",
|
60
|
+
"counter": COUNTER
|
61
|
+
}
|
62
|
+
}
|
63
|
+
}
|
64
|
+
```
|
65
|
+
|
66
|
+
## GET/POST `/register` (Security Key Registration page)
|
67
|
+
|
68
|
+
Navigate user to this page for key registration. Clarion redirects back to _callback_ with registered key information.
|
69
|
+
|
70
|
+
### Request
|
71
|
+
|
72
|
+
form encoded body on POST, or query string on GET
|
73
|
+
|
74
|
+
```
|
75
|
+
name=NAME&comment=COMMENT&state=STATE&callback=CALLBACK&public_key=PUBKEY
|
76
|
+
```
|
77
|
+
|
78
|
+
CALLBACK may start with `js:`, when `js:$ORIGIN` (where `$ORIGIN` is a page origin) is specified, `/register` page will return a result using [window.opener.postMessage()](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage).
|
79
|
+
|
80
|
+
### Response
|
81
|
+
|
82
|
+
HTML for user
|
83
|
+
|
84
|
+
## POST (callback) (Security Key Registration callback)
|
85
|
+
|
86
|
+
callback (by redirection) that notifies key registration, with the registered key information to the _app_
|
87
|
+
|
88
|
+
### Request
|
89
|
+
|
90
|
+
form encoded
|
91
|
+
|
92
|
+
```
|
93
|
+
state=STATE&data=DATA
|
94
|
+
```
|
95
|
+
|
96
|
+
- `DATA` is a JSON string `{"data": "ENCRYPT_DATA_BASE64", "key": "ENCRYPTED_SHARED_KEY_BASE64"}`.
|
97
|
+
- `ENCRYPTED_SHARED_KEY_BASE64` contains base64 encoded binary, which is a encrypted JSON string using the given RSA public key to `/register`.
|
98
|
+
- it's decrypted like as `{"iv": "IV_BASE64", "tag": "TAG_BASE64", "key": "KEY_BASE64"}`.
|
99
|
+
- `IV_BASE64` is a base64 encoded IV.
|
100
|
+
- `TAG_BASE64` is a AES-GCM auth tag.
|
101
|
+
- `KEY_BASE64` is a base64 encoded shared key used for AES-256-GCM.
|
102
|
+
- `ENCRYPTED_DATA_BASE64` is a base64 encoded binary, which is a AES-256-GCM encrypted JSON string. Use `IV_BASE64`, `TAG_BASE64`, `KEY_BASE64` to decrypt.
|
103
|
+
|
104
|
+
`ENCRYPTED_DATA_BASE64` decrypted as like the following JSON string:
|
105
|
+
|
106
|
+
``` json
|
107
|
+
{
|
108
|
+
"name": "KEYNAME",
|
109
|
+
"handle": "KEYHANDLE",
|
110
|
+
"public_key": "PUBLICKEY"
|
111
|
+
}
|
112
|
+
```
|
113
|
+
|
data/docs/counters.md
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
# Counters
|
2
|
+
|
3
|
+
Clarion _counter_ is responsible to store U2F device counters. It's optional but highly recommended to set up.
|
4
|
+
|
5
|
+
## `memory`: Memory
|
6
|
+
|
7
|
+
Memory store for development purpose.
|
8
|
+
|
9
|
+
## `dynamodb`: AWS DynamoDB
|
10
|
+
|
11
|
+
- `table_name`
|
12
|
+
- `region`
|
13
|
+
|
14
|
+
Table should have partition key `handle` (String).
|
data/docs/stores.md
ADDED
data/lib/clarion.rb
ADDED
data/lib/clarion/app.rb
ADDED
@@ -0,0 +1,271 @@
|
|
1
|
+
require 'erubis'
|
2
|
+
require 'sinatra/base'
|
3
|
+
require 'u2f'
|
4
|
+
require 'securerandom'
|
5
|
+
|
6
|
+
require 'clarion/registrator'
|
7
|
+
require 'clarion/authenticator'
|
8
|
+
require 'clarion/authn'
|
9
|
+
|
10
|
+
module Clarion
|
11
|
+
def self.app(*args)
|
12
|
+
App.rack(*args)
|
13
|
+
end
|
14
|
+
|
15
|
+
class App < Sinatra::Base
|
16
|
+
CONTEXT_RACK_ENV_NAME = 'clarion.ctx'
|
17
|
+
|
18
|
+
def self.initialize_context(config)
|
19
|
+
{
|
20
|
+
config: config,
|
21
|
+
}
|
22
|
+
end
|
23
|
+
|
24
|
+
def self.rack(config={})
|
25
|
+
klass = App
|
26
|
+
|
27
|
+
context = initialize_context(config)
|
28
|
+
lambda { |env|
|
29
|
+
env[CONTEXT_RACK_ENV_NAME] = context
|
30
|
+
klass.call(env)
|
31
|
+
}
|
32
|
+
end
|
33
|
+
|
34
|
+
configure do
|
35
|
+
enable :logging
|
36
|
+
end
|
37
|
+
|
38
|
+
set :root, File.expand_path(File.join(__dir__, '..', '..', 'app'))
|
39
|
+
set :erb, :escape_html => true
|
40
|
+
|
41
|
+
helpers do
|
42
|
+
def data
|
43
|
+
begin
|
44
|
+
@data = JSON.parse(request.body.tap(&:rewind).read, symbolize_names: true)
|
45
|
+
rescue JSON::ParserError
|
46
|
+
content_type :json
|
47
|
+
halt 400, '{"error": "invalid_payload"}'
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def context
|
52
|
+
request.env[CONTEXT_RACK_ENV_NAME]
|
53
|
+
end
|
54
|
+
|
55
|
+
def conf
|
56
|
+
context[:config]
|
57
|
+
end
|
58
|
+
|
59
|
+
def u2f
|
60
|
+
@u2f ||= U2F::U2F.new(conf.app_id || request.base_url)
|
61
|
+
end
|
62
|
+
|
63
|
+
def counter
|
64
|
+
conf.counter
|
65
|
+
end
|
66
|
+
|
67
|
+
def store
|
68
|
+
conf.store
|
69
|
+
end
|
70
|
+
|
71
|
+
def render_authn_json(authn)
|
72
|
+
{
|
73
|
+
authn: authn.as_json.merge(
|
74
|
+
url: "#{request.base_url}/api/authn/#{authn.id}",
|
75
|
+
html_url: "#{request.base_url}/authn/#{authn.id}",
|
76
|
+
)
|
77
|
+
}.to_json
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
## UI
|
82
|
+
|
83
|
+
get '/' do
|
84
|
+
content_type :text
|
85
|
+
"Clarion\n"
|
86
|
+
end
|
87
|
+
|
88
|
+
get '/authn/:id' do
|
89
|
+
@authn = store.find_authn(params[:id])
|
90
|
+
unless @authn
|
91
|
+
halt 404, "authn not found"
|
92
|
+
end
|
93
|
+
if @authn.verified?
|
94
|
+
halt 410, "Authn already processed"
|
95
|
+
end
|
96
|
+
if @authn.expired?
|
97
|
+
halt 410, "Authn expired"
|
98
|
+
end
|
99
|
+
|
100
|
+
|
101
|
+
authenticator = Authenticator.new(@authn, u2f, counter, store)
|
102
|
+
@app_id, @requests, @challenge = authenticator.request
|
103
|
+
|
104
|
+
@req_id = SecureRandom.urlsafe_base64(12)
|
105
|
+
session[:reqs] ||= {}
|
106
|
+
session[:reqs][@req_id] = {challenge: @challenge}
|
107
|
+
|
108
|
+
erb :authn
|
109
|
+
end
|
110
|
+
|
111
|
+
## API (returns user-facing UI)
|
112
|
+
|
113
|
+
register = Proc.new do
|
114
|
+
unless params[:name] && params[:callback] && params[:public_key]
|
115
|
+
halt 400, 'missing params'
|
116
|
+
end
|
117
|
+
if params[:callback].start_with?('js:') && !(conf.registration_allowed_url === params[:callback])
|
118
|
+
halt 400, 'invalid callback'
|
119
|
+
end
|
120
|
+
|
121
|
+
public_key = begin
|
122
|
+
OpenSSL::PKey::RSA.new(params[:public_key].unpack('m*')[0], '')
|
123
|
+
rescue OpenSSL::PKey::RSAError
|
124
|
+
halt 400, 'invalid public key'
|
125
|
+
end
|
126
|
+
|
127
|
+
@reg_id = SecureRandom.urlsafe_base64(12)
|
128
|
+
registrator = Registrator.new(u2f, counter)
|
129
|
+
@app_id, @requests = registrator.request
|
130
|
+
session[:regs] ||= {}
|
131
|
+
session[:regs][@reg_id] = {
|
132
|
+
challenges: @requests.map(&:challenge),
|
133
|
+
key: public_key.to_der,
|
134
|
+
}
|
135
|
+
|
136
|
+
@callback = params[:callback]
|
137
|
+
@state = params[:state]
|
138
|
+
@name = params[:name]
|
139
|
+
@comment = params[:comment]
|
140
|
+
erb :register
|
141
|
+
end
|
142
|
+
get '/register', ®ister
|
143
|
+
post '/register', ®ister
|
144
|
+
|
145
|
+
## Internal APIs (used from UI)
|
146
|
+
|
147
|
+
post '/ui/register' do
|
148
|
+
content_type :json
|
149
|
+
unless data[:reg_id] && data[:response]
|
150
|
+
halt 400, '{"error": "Missing params"}'
|
151
|
+
end
|
152
|
+
|
153
|
+
session[:regs] ||= {}
|
154
|
+
reg = session[:regs][data[:reg_id]]
|
155
|
+
unless reg && reg[:challenges] && reg[:key]
|
156
|
+
halt 400, '{"error": "Invalid :reg"}'
|
157
|
+
end
|
158
|
+
|
159
|
+
public_key = begin
|
160
|
+
OpenSSL::PKey::RSA.new(reg[:key], '') # der
|
161
|
+
rescue OpenSSL::PKey::RSAError
|
162
|
+
halt 400, '{"error": "Invalid public key"}'
|
163
|
+
end
|
164
|
+
|
165
|
+
registrator = Registrator.new(u2f, counter)
|
166
|
+
key = registrator.register!(reg[:challenges], data[:response])
|
167
|
+
|
168
|
+
session[:regs].delete(data[:reg_id])
|
169
|
+
|
170
|
+
{ok: true, encrypted_key: key.to_encrypted_json(public_key, :all)}.to_json
|
171
|
+
end
|
172
|
+
|
173
|
+
post '/ui/verify/:id' do
|
174
|
+
content_type :json
|
175
|
+
unless data[:req_id] && data[:response]
|
176
|
+
halt 400, '{"error": "missing params"}'
|
177
|
+
end
|
178
|
+
session[:reqs] ||= {}
|
179
|
+
unless session[:reqs][data[:req_id]]
|
180
|
+
halt 400, '{"error": "invalid :req_id"}'
|
181
|
+
end
|
182
|
+
challenge = session[:reqs][data[:req_id]][:challenge]
|
183
|
+
unless challenge
|
184
|
+
halt 400, '{"error": "invalid :req_id"}'
|
185
|
+
end
|
186
|
+
|
187
|
+
@authn = store.find_authn(params[:id])
|
188
|
+
unless @authn
|
189
|
+
halt 404, '{"error": "authn not found"}'
|
190
|
+
end
|
191
|
+
if @authn.verified?
|
192
|
+
halt 410, '{"error": "authn already processed"}'
|
193
|
+
end
|
194
|
+
if @authn.expired?
|
195
|
+
halt 410, '{"error": "authn expired"}'
|
196
|
+
end
|
197
|
+
|
198
|
+
authenticator = Authenticator.new(@authn, u2f, counter, store)
|
199
|
+
|
200
|
+
begin
|
201
|
+
authenticator.verify!(
|
202
|
+
challenge,
|
203
|
+
data[:response]
|
204
|
+
)
|
205
|
+
rescue U2F::Error => e
|
206
|
+
halt 400, {error: "U2F Error: #{e.message}"}.to_json
|
207
|
+
rescue Authenticator::InvalidKey => e
|
208
|
+
halt 400, {error: "It is an unregistered key"}.to_json
|
209
|
+
end
|
210
|
+
|
211
|
+
session[:reqs].delete data[:req_id]
|
212
|
+
'{"ok": true}'
|
213
|
+
end
|
214
|
+
|
215
|
+
## API
|
216
|
+
|
217
|
+
post '/api/authn' do
|
218
|
+
content_type :json
|
219
|
+
@authn = begin
|
220
|
+
Authn.make(
|
221
|
+
name: data[:name],
|
222
|
+
comment: data[:comment],
|
223
|
+
keys: data[:keys],
|
224
|
+
expires_at: Time.now + conf.authn_default_expires_in,
|
225
|
+
status: :open,
|
226
|
+
)
|
227
|
+
rescue ArgumentError
|
228
|
+
halt 400, '{"error": "invalid params"}'
|
229
|
+
end
|
230
|
+
store.store_authn(@authn)
|
231
|
+
render_authn_json @authn
|
232
|
+
end
|
233
|
+
|
234
|
+
get '/api/authn/:id' do
|
235
|
+
content_type :json
|
236
|
+
@authn = store.find_authn(params[:id])
|
237
|
+
unless @authn
|
238
|
+
halt 404, '{"error": "authn not found"}'
|
239
|
+
end
|
240
|
+
render_authn_json @authn
|
241
|
+
end
|
242
|
+
|
243
|
+
## Testing purpose
|
244
|
+
|
245
|
+
get '/test' do
|
246
|
+
key = conf.options[:register_test_key] ||= OpenSSL::PKey::RSA.generate(2048)
|
247
|
+
@name = 'testuser'
|
248
|
+
@comment = 'test comment'
|
249
|
+
@register_url = "#{request.base_url}/register"
|
250
|
+
@callback = "#{request.base_url}/test/callback"
|
251
|
+
@public_key = [key.public_key.to_der].pack('m*').gsub(/\r?\n/, '')
|
252
|
+
@state = SecureRandom.urlsafe_base64(12)
|
253
|
+
erb :test
|
254
|
+
end
|
255
|
+
|
256
|
+
post '/test/callback' do
|
257
|
+
key = conf.options[:register_test_key] ||= OpenSSL::PKey::RSA.generate(2048)
|
258
|
+
if params[:data]
|
259
|
+
@state = params[:state]
|
260
|
+
json = params[:data]
|
261
|
+
@key = Key.from_encrypted_json(key, json)
|
262
|
+
elsif params[:key]
|
263
|
+
@state = 'dummy'
|
264
|
+
@key = Key.new(**JSON.parse(params[:key], symbolize_names))
|
265
|
+
else
|
266
|
+
halt 400, "what?"
|
267
|
+
end
|
268
|
+
erb :test_callback
|
269
|
+
end
|
270
|
+
end
|
271
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
require 'base64'
|
2
|
+
require 'u2f'
|
3
|
+
|
4
|
+
module Clarion
|
5
|
+
class Authenticator
|
6
|
+
class Error < StandardError; end
|
7
|
+
class InvalidKey < Error; end
|
8
|
+
|
9
|
+
def initialize(authn, u2f, counter, store)
|
10
|
+
@authn = authn
|
11
|
+
@u2f = u2f
|
12
|
+
@counter = counter
|
13
|
+
@store = store
|
14
|
+
end
|
15
|
+
|
16
|
+
attr_reader :authn, :u2f, :counter, :store
|
17
|
+
|
18
|
+
def request
|
19
|
+
[u2f.app_id, u2f.authentication_requests(authn.keys.map(&:handle)), u2f.challenge]
|
20
|
+
end
|
21
|
+
|
22
|
+
def verify!(challenge, response_json)
|
23
|
+
response = U2F::SignResponse.load_from_json(response_json)
|
24
|
+
key = authn.key_for_handle(response.key_handle)
|
25
|
+
unless key
|
26
|
+
raise InvalidKey, "#{response.key_handle.inspect} is invalid token for authn #{authn.id}"
|
27
|
+
end
|
28
|
+
count = counter ? counter.get(key) : 0
|
29
|
+
|
30
|
+
u2f.authenticate!(
|
31
|
+
challenge,
|
32
|
+
response,
|
33
|
+
Base64.decode64(key.public_key),
|
34
|
+
count,
|
35
|
+
)
|
36
|
+
|
37
|
+
unless authn.verify(key)
|
38
|
+
raise Authenticator::InvalidKey
|
39
|
+
end
|
40
|
+
|
41
|
+
key.counter = response.counter
|
42
|
+
if counter
|
43
|
+
counter.store(key)
|
44
|
+
end
|
45
|
+
|
46
|
+
store.store_authn(authn)
|
47
|
+
|
48
|
+
true
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|