clarion 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
+
@@ -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).
@@ -0,0 +1,13 @@
1
+ # Stores
2
+
3
+ Clarion _store_ is responsible to store in-progress authentication data.
4
+
5
+ ## `memory`: Memory
6
+
7
+ Memory store for development purpose.
8
+
9
+ ## `s3`: Amazon S3
10
+
11
+ - `region`
12
+ - `bucket`
13
+ - `prefix` (recommended to end with `/`)
@@ -0,0 +1,3 @@
1
+ require 'clarion/config'
2
+ require 'clarion/app'
3
+ require "clarion/version"
@@ -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', &register
143
+ post '/register', &register
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