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