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
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
|