Merge lp:~jderose/dmedia/all-in into lp:dmedia

Proposed by Jason Gerard DeRose
Status: Merged
Merged at revision: 469
Proposed branch: lp:~jderose/dmedia/all-in
Merge into: lp:dmedia
Diff against target: 5039 lines (+2468/-1581)
23 files modified
dmedia-cli (+2/-2)
dmedia-gtk (+4/-3)
dmedia-service (+111/-20)
dmedia/core.py (+13/-26)
dmedia/gtk/peering.py (+66/-9)
dmedia/gtk/ui/client.html (+10/-12)
dmedia/httpd.py (+1/-1)
dmedia/parallel.py (+42/-0)
dmedia/peering.py (+298/-439)
dmedia/server.py (+301/-205)
dmedia/service/__init__.py (+24/-5)
dmedia/service/avahi.py (+74/-81)
dmedia/service/peers.py (+324/-24)
dmedia/service/tests/test_avahi.py (+54/-20)
dmedia/startup.py (+45/-45)
dmedia/tests/couch.py (+3/-1)
dmedia/tests/test_core.py (+20/-59)
dmedia/tests/test_peering.py (+419/-324)
dmedia/tests/test_server.py (+559/-190)
dmedia/tests/test_startup.py (+92/-111)
run-browse.py (+2/-1)
run-publish.py (+3/-3)
setup.py (+1/-0)
To merge this branch: bzr merge lp:~jderose/dmedia/all-in
Reviewer Review Type Date Requested Status
Jason Gerard DeRose Approve
Review via email: mp+129853@code.launchpad.net

Description of the change

This diff isn't as big as it looks because there was a lot of cosmetic changes in dmedia/peering.py, reordering things and so on so that it's easier to review this security sensitive code. So messy diff, but much nicer code.

Major changes include:

* dmedia-service wont start CouchDB when there isn't a user SSL identity

* dmedia-gtk uses the new init_if_needed() function that will walk the user through the first-run dialog if needed. The same will be added to novacut-gtk after this lands.

* After the first run init, dmedia-service will listen for _dmedia-offer._tcp as long as it has the private user key (without the private key, it can't issue new machine certificates anyway, so it would be pointless)

* Moved the peering related WSGI apps from dmedia/peering.py to dmedia/server.py to consolidate all the WSGI apps in one module

* Ported the file serving app to the new Dmedia HTTPD centric design, cleaned things up, added a lot of missing tests

* Ported the Avahi class to the brave new SSL world... it now does a test GET on the peer so it doesn't needlessly send the CouchDB replicator out to try and replicate to peers it can't authenticate to anyway

* POST /csr now includes a MAC proving the creator of the CSR knows the secret, and the response includes a similar MAC to prove the cert issuer knows the secret

* The response to POST /csr now includes the user private key. It's protected by the SSL channel, but I'd like to do something better here in case somehow this was reachable without going through SSL. I'd like to encrypt it to the machine CA, but I haven't figured out how to do that with openssl yet

* The PKI.verify_foo() methods are now, by my account, exhaustive in their checks. They check the subject and issuer for the correct intrinsic common names, and they do an SSL verification to check that CAs are correctly self-signed, and that certs are signed by the correct CA.

To post a comment you must log in.
lp:~jderose/dmedia/all-in updated
526. By Jason Gerard DeRose

Fixed sending Hub 'spin_orb' signal on ClientUI.on_Response()

Revision history for this message
Jason Gerard DeRose (jderose) wrote :

Bad form and all, I'm going to self-approve this. This feature is going into 12.10, and so I want to get as much testing as possible before the release. Time to ram this through the daily build.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'dmedia-cli'
2--- dmedia-cli 2012-08-16 10:38:13 +0000
3+++ dmedia-cli 2012-10-16 12:35:25 +0000
4@@ -121,8 +121,8 @@
5 'Get the _local/dmedia document (shows file-stores)'
6
7
8-class LocalPeers(_Method):
9- 'Get the _local/peers document'
10+class Peers(_Method):
11+ 'Show local peers'
12
13
14 class CreateFileStore(_Method):
15
16=== modified file 'dmedia-gtk'
17--- dmedia-gtk 2012-08-16 12:48:30 +0000
18+++ dmedia-gtk 2012-10-16 12:35:25 +0000
19@@ -35,6 +35,7 @@
20 from dmedia import views
21 from dmedia import schema
22 from dmedia.gtk.util import Timer
23+from dmedia.service import init_if_needed
24 from dmedia.service.udisks import Ejector, Formatter
25 from dmedia.importer import ImportManager, has_magic_lantern
26 from gi.repository import GObject, Gtk, Gio
27@@ -45,7 +46,8 @@
28 UnityImportUX = None
29
30
31-log = logging.getLogger()
32+log = dmedia.configure_logging()
33+init_if_needed()
34
35
36 class Importer:
37@@ -261,8 +263,7 @@
38 project.save(doc)
39 self.hub.send('project_created', doc['_id'], doc['title'])
40
41-
42-dmedia.configure_logging()
43+
44 app = App()
45 try:
46 app.run()
47
48=== modified file 'dmedia-service'
49--- dmedia-service 2012-10-06 07:56:57 +0000
50+++ dmedia-service 2012-10-16 12:35:25 +0000
51@@ -37,13 +37,14 @@
52 from dbus.mainloop.glib import DBusGMainLoop
53 from gi.repository import GObject
54 from microfiber import NotFound, dumps
55-from filestore import _start_thread
56
57 import dmedia
58+from dmedia.parallel import start_thread
59 from dmedia.startup import DmediaCouch
60 from dmedia.core import Core, start_httpd
61 from dmedia.service.udisks import UDisks
62-from dmedia.service.avahi import FileServer
63+from dmedia.service.avahi import Avahi
64+from dmedia.service.peers import Browser, Publisher
65
66
67 BUS = dmedia.BUS
68@@ -66,6 +67,11 @@
69 couch = None
70 httpd = None
71 avahi = None
72+ peer = None
73+ ui = None
74+ thread = None
75+ publisher = None
76+ env_s = '{}'
77
78 def __init__(self, bus, couch):
79 self.bus = bus
80@@ -80,6 +86,13 @@
81 self.udisks.connect('card_added', self.on_card_added)
82 self.udisks.connect('card_removed', self.on_card_removed)
83
84+ def run(self):
85+ if self.couch.isfirstrun():
86+ log.info('First run, not starting CouchDB.')
87+ else:
88+ self.start_core()
89+ mainloop.run()
90+
91 def start_core(self):
92 start = time.time()
93 env = self.couch.auto_bootstrap()
94@@ -91,36 +104,33 @@
95 if self.core.local.get('default_store') is None:
96 self.core.set_default_store('shared')
97 self.env_s = dumps(self.core.env, pretty=True)
98- self.ssl_config = self.couch.get_ssl_config()
99+ log.info('Finished core startup in %.3f', time.time() - start)
100+ GObject.timeout_add(1000, self.on_idle1)
101
102 def start_httpd(self):
103- if self.ssl_config is None:
104- return
105- (self.httpd, env) = start_httpd(self.core.env, self.ssl_config)
106- self.avahi = FileServer(self.core.env, env['port'])
107+ ssl_config = self.couch.get_ssl_config()
108+ (self.httpd, env) = start_httpd(self.core.env, ssl_config)
109+ port = env['port']
110+ self.avahi = Avahi(self.core.env, port, ssl_config)
111 self.avahi.run()
112-
113- def run(self):
114- self.start_core()
115- log.info('Finished core startup in %.3f', time.time() - start_time)
116- GObject.timeout_add(2000, self.on_idle1)
117- mainloop.run()
118+ if self.couch.pki.user.key_file is not None:
119+ self.peer = Browser(self.couch)
120
121 def on_idle1(self):
122 log.info('[idle1]')
123 self.udisks.monitor()
124- GObject.timeout_add(4000, self.on_idle2)
125+ GObject.timeout_add(1000, self.on_idle2)
126 return False # Don't repeat idle call
127
128 def on_idle2(self):
129 log.info('[idle2]')
130 self.start_httpd()
131- GObject.timeout_add(6000, self.on_idle3)
132+ GObject.timeout_add(1000, self.on_idle3)
133 return False # Don't repeat idle call
134
135 def on_idle3(self):
136 log.info('[idle3]')
137- _start_thread(self.core.init_project_views)
138+ start_thread(self.core.init_project_views)
139 self.core.start_background_tasks()
140 return False # Don't repeat idle call
141
142@@ -130,6 +140,10 @@
143
144 def shutdown(self):
145 log.info('Service.shutdown()')
146+ if self.peer is not None:
147+ log.info('* freeing peer')
148+ self.peer.free()
149+ self.peer = None
150 if self.avahi is not None:
151 log.info('* freeing avahi')
152 self.avahi.free()
153@@ -170,6 +184,22 @@
154 def CardRemoved(self, obj, mount):
155 pass
156
157+ @dbus.service.signal(IFACE, signature='s')
158+ def Message(self, message):
159+ log.info('@Dmedia.Message(%r)', message)
160+
161+ @dbus.service.signal(IFACE, signature='')
162+ def Accept(self):
163+ log.info('@Dmedia.Accept()')
164+
165+ @dbus.service.signal(IFACE, signature='b')
166+ def Response(self, success):
167+ log.info('@Dmedia.Response(%r)', success)
168+
169+ @dbus.service.signal(IFACE, signature='')
170+ def InitDone(self):
171+ log.info('@Dmedia.InitDone()')
172+
173 @dbus.service.method(IFACE, in_signature='', out_signature='s')
174 def Version(self):
175 """
176@@ -177,6 +207,66 @@
177 """
178 return dmedia.__version__
179
180+ @dbus.service.method(IFACE, in_signature='', out_signature='b')
181+ def NeedsInit(self):
182+ """
183+ Return True if we need to do the firstrun init.
184+ """
185+ return self.couch.user is None
186+
187+ @dbus.service.method(IFACE, in_signature='', out_signature='b')
188+ def CreateUser(self):
189+ log.info('Dmedia.CreateUser()')
190+ if self.couch.user is not None:
191+ return False
192+ if self.thread is not None:
193+ return False
194+ self.Message('Creating user certificate...')
195+ self.thread = start_thread(self.create_user)
196+ return True
197+
198+ def create_user(self):
199+ log.info('create_user()')
200+ self.couch.create_user()
201+ GObject.idle_add(self.Message, 'Starting CouchDB...')
202+ self.start_core()
203+ GObject.idle_add(self.on_init_done)
204+
205+ def on_init_done(self):
206+ log.info('on_init_done()')
207+ self.thread.join()
208+ self.thread = None
209+ self.Message('Done!')
210+ GObject.timeout_add(500, self.InitDone)
211+
212+ @dbus.service.method(IFACE, in_signature='', out_signature='b')
213+ def PeerWithExisting(self):
214+ log.info('Dmedia.PeerWithExisting()')
215+ if self.couch.user is not None:
216+ return False
217+ if self.publisher is not None:
218+ return False
219+ self.publisher = Publisher(self, self.couch)
220+ GObject.idle_add(self.publisher.run)
221+ return True
222+
223+ @dbus.service.method(IFACE, in_signature='s', out_signature='b')
224+ def SetSecret(self, secret):
225+ log.info('Dmedia.SetSecret()')
226+ return self.publisher.set_secret(secret)
227+
228+ def set_user(self, user_id):
229+ log.info('set_user(%r)', user_id)
230+ assert self.couch.user is None
231+ assert self.thread is None
232+ self.Message('Starting CouchDB...')
233+ self.thread = start_thread(self._set_user, user_id)
234+
235+ def _set_user(self, user_id):
236+ self.couch.set_user(user_id)
237+ self.start_core()
238+ GObject.idle_add(self.on_init_done)
239+
240 @dbus.service.method(IFACE, in_signature='', out_signature='i')
241 def Kill(self):
242 """
243@@ -209,12 +299,13 @@
244 return dumps(self.core.db.get('_local/dmedia'))
245
246 @dbus.service.method(IFACE, in_signature='', out_signature='s')
247- def LocalPeers(self):
248+ def Peers(self):
249 """
250 Return the _local/peers doc.
251 """
252 try:
253- return dumps(self.core.db.get('_local/peers'))
254+ doc = self.core.db.get('_local/peers')
255+ return dumps(doc['peers'], pretty=True)
256 except NotFound:
257 return '{}'
258
259@@ -273,8 +364,8 @@
260 # Now go for it:
261 dmedia.configure_logging()
262 try:
263- if couch.isfirstrun():
264- couch.firstrun_init(create_user=True)
265+ if couch.machine is None:
266+ couch.create_machine()
267 service = Service(options.bus, couch)
268 try:
269 service.run()
270
271=== modified file 'dmedia/core.py'
272--- dmedia/core.py 2012-10-13 12:48:38 +0000
273+++ dmedia/core.py 2012-10-16 12:35:25 +0000
274@@ -41,9 +41,10 @@
275 from copy import deepcopy
276
277 from microfiber import Server, Database, NotFound, Conflict, BulkConflict
278-from filestore import FileStore, check_root_hash, check_id, _start_thread
279+from filestore import FileStore, check_root_hash, check_id
280
281 import dmedia
282+from dmedia.parallel import start_thread, start_process
283 from dmedia.server import run_server
284 from dmedia import util, schema
285 from dmedia.metastore import MetaStore
286@@ -56,13 +57,6 @@
287 PRIVATE = path.abspath(os.environ['HOME'])
288
289
290-def start_process(target, *args):
291- process = multiprocessing.Process(target=target, args=args)
292- process.daemon = True
293- process.start()
294- return process
295-
296-
297 def start_httpd(couch_env, ssl_config):
298 queue = multiprocessing.Queue()
299 process = start_process(run_server, queue, couch_env, '0.0.0.0', ssl_config)
300@@ -138,22 +132,17 @@
301 self.db.save(self.local)
302 self.__local = deepcopy(self.local)
303
304- def load_identity(self, machine, user=None):
305+ def load_identity(self, machine, user):
306 try:
307- self.db.save(machine)
308- except Conflict:
309+ self.db.save_many([machine, user])
310+ except BulkConflict:
311 pass
312+ log.info('machine_id = %s', machine['_id'])
313+ log.info('user_id = %s', user['_id'])
314+ self.env['machine_id'] = machine['_id']
315+ self.env['user_id'] = user['_id']
316 self.local['machine_id'] = machine['_id']
317- self.env['machine_id'] = machine['_id']
318- log.info('machine_id = %s', machine['_id'])
319- if user is not None:
320- try:
321- self.db.save(user)
322- except Conflict:
323- pass
324- self.local['user_id'] = user['_id']
325- self.env['user_id'] = user['_id']
326- log.info('user_id = %s', user['_id'])
327+ self.local['user_id'] = user['_id']
328 self.save_local()
329
330 def init_default_store(self):
331@@ -175,10 +164,8 @@
332 self._add_filestore(fs, doc)
333
334 def _sync_stores(self):
335- stores = self.stores.local_stores()
336- if self.local.get('stores') != stores:
337- self.local['stores'] = stores
338- self.db.save(self.local)
339+ self.local['stores'] = self.stores.local_stores()
340+ self.save_local()
341
342 def _add_filestore(self, fs, doc):
343 self.stores.add(fs)
344@@ -211,7 +198,7 @@
345
346 def start_background_tasks(self):
347 assert self.thread is None
348- self.thread = _start_thread(self._background_worker)
349+ self.thread = start_thread(self._background_worker)
350
351 def init_project_views(self):
352 try:
353
354=== modified file 'dmedia/gtk/peering.py'
355--- dmedia/gtk/peering.py 2012-10-09 01:48:26 +0000
356+++ dmedia/gtk/peering.py 2012-10-16 12:35:25 +0000
357@@ -1,9 +1,11 @@
358 from os import path
359 import json
360+import logging
361
362 from gi.repository import GObject, Gtk, WebKit
363
364
365+log = logging.getLogger()
366 ui = path.join(path.dirname(path.abspath(__file__)), 'ui')
367 assert path.isdir(ui)
368
369@@ -61,20 +63,13 @@
370 self.hub = hub_factory(self.signals)(self.view)
371 self.connect_hub_signals(self.hub)
372
373- def show(self):
374- self.window.show_all()
375+ def connect_hub_signals(self, hub):
376+ pass
377
378 def run(self):
379- self.window.connect('destroy', self.quit)
380 self.window.show_all()
381 Gtk.main()
382
383- def quit(self, *args):
384- Gtk.main_quit()
385-
386- def connect_hub_signals(self, hub):
387- pass
388-
389 def build_window(self):
390 self.window = Gtk.Window()
391 self.window.set_position(Gtk.WindowPosition.CENTER)
392@@ -98,3 +93,65 @@
393 self.inspector.show_all()
394 return self.inspector
395
396+
397+class ClientUI(BaseUI):
398+ page = 'client.html'
399+ title = 'Welcome to Novacut!'
400+
401+ signals = {
402+ 'create_user': [],
403+ 'peer_with_existing': [],
404+ 'have_secret': ['secret'],
405+
406+ 'response': ['success'],
407+ 'message': ['message'],
408+
409+ 'show_screen2a': [],
410+ 'show_screen2b': [],
411+ 'show_screen3b': [],
412+ 'spin_orb': [],
413+ }
414+
415+ def __init__(self, Dmedia):
416+ super().__init__()
417+ self.Dmedia = Dmedia
418+ self.quit = False
419+ Dmedia.connect_to_signal('Message', self.on_Message)
420+ Dmedia.connect_to_signal('Accept', self.on_Accept)
421+ Dmedia.connect_to_signal('Response', self.on_Response)
422+ Dmedia.connect_to_signal('InitDone', self.on_InitDone)
423+ self.window.connect('destroy', Gtk.main_quit)
424+ self.window.connect('delete-event', self.on_delete_event)
425+
426+ def on_delete_event(self, *args):
427+ self.quit = True
428+
429+ def connect_hub_signals(self, hub):
430+ hub.connect('create_user', self.on_create_user)
431+ hub.connect('peer_with_existing', self.on_peer_with_existing)
432+ hub.connect('have_secret', self.on_have_secret)
433+
434+ def on_Message(self, message):
435+ self.hub.send('message', message)
436+
437+ def on_Accept(self):
438+ self.hub.send('show_screen3b')
439+
440+ def on_Response(self, success):
441+ self.hub.send('response', success)
442+ if success:
443+ GObject.timeout_add(200, self.hub.send, 'spin_orb')
444+
445+ def on_InitDone(self):
446+ self.window.destroy()
447+
448+ def on_create_user(self, hub):
449+ self.Dmedia.CreateUser()
450+ hub.send('show_screen2a')
451+
452+ def on_peer_with_existing(self, hub):
453+ self.Dmedia.PeerWithExisting()
454+ hub.send('show_screen2b')
455+
456+ def on_have_secret(self, hub, secret):
457+ self.Dmedia.SetSecret(secret)
458
459=== modified file 'dmedia/gtk/ui/client.html'
460--- dmedia/gtk/ui/client.html 2012-10-09 05:37:35 +0000
461+++ dmedia/gtk/ui/client.html 2012-10-16 12:35:25 +0000
462@@ -89,7 +89,7 @@
463 }
464 );
465
466-Hub.connect('set_message',
467+Hub.connect('message',
468 function(message) {
469 $('message').textContent = message;
470 }
471@@ -97,16 +97,16 @@
472
473 Hub.connect('response',
474 function(success) {
475- if (!success) {
476+ if (success) {
477+ $hide('finish');
478+ $show('logo2');
479+ }
480+ else {
481 UI.input.value = '';
482 UI.input.disabled = false;
483 UI.input.focus();
484 $('finish').classList.add('hidden');
485 }
486- else {
487- $hide('finish');
488- $show('logo2');
489- }
490 }
491 );
492
493@@ -117,7 +117,7 @@
494 <img src="novacut.svg" id="logo">
495
496 <div id="screen1" class="hide">
497- <div id="first" onclick="Hub.send('first_time')">
498+ <div id="first" onclick="Hub.send('create_user')">
499 <p class="top">
500 This is my first time using Novacut
501 </p>
502@@ -126,7 +126,7 @@
503 </p>
504 </div>
505
506- <div id="sync" onclick="Hub.send('already_using')">
507+ <div id="sync" onclick="Hub.send('peer_with_existing')">
508 <p class="top">
509 I'm already using Novacut
510 </p>
511@@ -137,9 +137,6 @@
512 </div>
513
514 <div id="screen2a" class="hide">
515- <p class="gen">
516- Pretty words here
517- </p>
518 </div>
519
520 <div id="screen2b" class="hide">
521@@ -157,10 +154,11 @@
522 <input id="input" type="text" maxlength="8" size="8" autofocus="1"></input>
523 </form>
524 </div>
525- <p id="message"></p>
526 <img src="sync.svg" id="finish" class="hidden" onclick="UI.have_secret()">
527 <img src="novacut.svg" id="logo2" class="hide">
528 </div>
529
530+<p id="message"></p>
531+
532 </body>
533 </html>
534
535=== modified file 'dmedia/httpd.py'
536--- dmedia/httpd.py 2012-10-08 18:41:48 +0000
537+++ dmedia/httpd.py 2012-10-16 12:35:25 +0000
538@@ -125,7 +125,7 @@
539 def build_server_ssl_context(config):
540 ctx = ssl.SSLContext(ssl.PROTOCOL_TLSv1)
541 ctx.options |= ssl.OP_NO_COMPRESSION # Protect against CRIME-like attacks
542- ctx.set_ciphers('RC4')
543+ # ctx.set_ciphers('RC4') # Example of how to change ciphers
544 ctx.load_cert_chain(config['cert_file'], config['key_file'])
545 if 'ca_file' in config or 'ca_path' in config:
546 ctx.verify_mode = ssl.CERT_REQUIRED
547
548=== added file 'dmedia/parallel.py'
549--- dmedia/parallel.py 1970-01-01 00:00:00 +0000
550+++ dmedia/parallel.py 2012-10-16 12:35:25 +0000
551@@ -0,0 +1,42 @@
552+# dmedia: distributed media library
553+# Copyright (C) 2012 Novacut Inc
554+#
555+# This file is part of `dmedia`.
556+#
557+# `dmedia` is free software: you can redistribute it and/or modify it under
558+# the terms of the GNU Affero General Public License as published by the Free
559+# Software Foundation, either version 3 of the License, or (at your option) any
560+# later version.
561+#
562+# `dmedia` is distributed in the hope that it will be useful, but WITHOUT ANY
563+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
564+# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
565+# details.
566+#
567+# You should have received a copy of the GNU Affero General Public License along
568+# with `dmedia`. If not, see <http://www.gnu.org/licenses/>.
569+#
570+# Authors:
571+# Jason Gerard DeRose <jderose@novacut.com>
572+
573+"""
574+Small helpers for starting threads and processes.
575+"""
576+
577+import threading
578+import multiprocessing
579+
580+
581+def start_thread(target, *args, **kw):
582+ thread = threading.Thread(target=target, args=args, kwargs=kw)
583+ thread.daemon = True
584+ thread.start()
585+ return thread
586+
587+
588+def start_process(target, *args, **kw):
589+ process = multiprocessing.Process(target=target, args=args, kwargs=kw)
590+ process.daemon = True
591+ process.start()
592+ return process
593+
594
595=== modified file 'dmedia/peering.py'
596--- dmedia/peering.py 2012-10-09 03:49:01 +0000
597+++ dmedia/peering.py 2012-10-16 12:35:25 +0000
598@@ -20,94 +20,10 @@
599 # Jason Gerard DeRose <jderose@novacut.com>
600
601 """
602-Securely peer one device with another device on the same local network.
603-
604-We want it to be easy for a user to associate multiple devices with the same
605-user CA. This is the local-network "user account" that works without any cloud
606-infrastructure, without a Novacut account or any other 3rd-party account.
607-
608-To do the peering, the user needs both devices side-by-side. One device is
609-already associated with the user, and the other is the one to be associated.
610-
611-The existing device generates a random secret and displays it on the screen.
612-The user then enter the secret on the second device, and each device does a
613-challenge-response to make the other prove it has the same secret.
614-
615-In a nutshell:
616-
617- 1. A generates random ``nonce1`` to challenge B
618- 2. B must respond with ``hash(secret + nonce1)``
619- 3. B generates random ``nonce2`` to challenge A
620- 4. A must respond with ``hash(secret + nonce2)``
621-
622-We only give the responder one try, and if it's wrong, the process starts over
623-with a new secret. If the user makes a typo, we don't let them try again to
624-correctly type the initial secret... they must try and correctly type a new
625-secret.
626-
627-Limiting the responder to only one attempt lets us to use a fairly low-entropy
628-secret, something easy for the user to type. And importantly, the secret
629-is not susceptible to any "offline" attack, because there isn't an intrinsicly
630-correct answer.
631-
632-For example, this would not be the case if we used the secret to encrypt a
633-message and send it from one to another. An attacker could run through the
634-keyspace till (say) gpg stopped telling them the passphrase is wrong.
635-
636-
637-
638-Request 1:
639-
640- GET /challenge
641-
642-Response 1:
643-
644- {"challenge": ""}
645-
646-
647-Request 2:
648-
649- POST /response
650-
651- {"nonce": "", "response": "", "counter_challenge": ""}
652-
653-Response 2:
654-
655- {"nonce": "", "counter_response": ""}
656-
657-
658-
659-1. A starts server, publishes _dmedia-offer._tcp under cert_a_id
660-
661-2. B downloads cert_a from A, if hash doesn't match cert_a_id, ABORT
662-
663-3. B prompts user about offer, if user declines, ABORT
664-
665-4. B starts server, publishes _dmedia-accept._tcp under cert_b_id
666-
667-5. A downloads cert_b from B, if hash doesn't match cert_b_id, ABORT
668-
669-6. A generates secret, displays to user, waits for request from B
670-
671-7. User enters secret on B
672-
673-8. B does GET /challenge to get challenge from A
674-
675-9. B does POST /response to post response and counter-challenge to A
676-
677-10. If response is wrong, A assumes user typo, RESTART at (6) with new secret
678-
679-11. A returns counter-response to B
680-
681-12. If counter-response is wrong, B ABORTS with scary warning
682-
683-13. DONE!
684-
685-
686-
687+Secure peering protocol, SSL-based machine and user identity.
688 """
689
690-import base64
691+from base64 import b32encode, b32decode
692 import os
693 from os import path
694 import stat
695@@ -115,30 +31,31 @@
696 import shutil
697 from collections import namedtuple
698 from subprocess import check_call, check_output
699-import json
700-import socket
701 import logging
702
703 from skein import skein512
704-from microfiber import random_id, dumps
705-
706-from dmedia.httpd import WSGIError
707-
708-
709+from microfiber import random_id
710+
711+
712+# Skein personalization strings
713+PERS_PUBKEY = b'20120918 jderose@novacut.com dmedia/pubkey'
714+PERS_RESPONSE = b'20120918 jderose@novacut.com dmedia/response'
715+PERS_CSR = b'20120918 jderose@novacut.com dmedia/csr'
716+PERS_CERT = b'20120918 jderose@novacut.com dmedia/cert'
717+
718+MAC_BITS = 280
719 DAYS = 365 * 10
720 CA = namedtuple('CA', 'id ca_file')
721 Cert = namedtuple('Cert', 'id cert_file key_file')
722-
723-# Skein personalization strings
724-PERS_PUBKEY = b'20120918 jderose@novacut.com dmedia/pubkey'
725-PERS_RESPONSE = b'20120918 jderose@novacut.com dmedia/response'
726-
727-USER = os.environ.get('USER')
728-HOST = socket.gethostname()
729+Machine = namedtuple('Machine', 'id ca_file key_file cert_file')
730+User = namedtuple('User', 'id ca_file key_file')
731
732 log = logging.getLogger()
733
734
735+###################
736+# Custom exceptions
737+
738 class IdentityError(Exception):
739 def __init__(self, filename, expected, got):
740 self.filename = filename
741@@ -152,18 +69,258 @@
742 class PublicKeyError(IdentityError):
743 pass
744
745-
746 class SubjectError(IdentityError):
747 pass
748
749-
750 class IssuerError(IdentityError):
751 pass
752
753-
754 class VerificationError(IdentityError):
755 pass
756-
757+
758+
759+class WrongResponse(Exception):
760+ def __init__(self, expected, got):
761+ self.expected = expected
762+ self.got = got
763+ super().__init__('Incorrect response')
764+
765+
766+class WrongMAC(Exception):
767+ def __init__(self, expected, got):
768+ self.expected = expected
769+ self.got = got
770+ super().__init__('Incorrect MAC')
771+
772+
773+
774+###########################################
775+# Helper functions for base32 encode/decode
776+
777+def encode(value):
778+ """
779+ Base32-encode the bytes *value*.
780+
781+ For example:
782+
783+ >>> encode(b'skein')
784+ 'ONVWK2LO'
785+
786+ """
787+ assert isinstance(value, bytes)
788+ assert len(value) > 0 and len(value) % 5 == 0
789+ return b32encode(value).decode('utf-8')
790+
791+
792+def decode(value):
793+ """
794+ Base32-decode the str *value*.
795+
796+ For example:
797+
798+ >>> decode('ONVWK2LO')
799+ b'skein'
800+
801+ """
802+ assert isinstance(value, str)
803+ assert len(value) > 0 and len(value) % 8 == 0
804+ return b32decode(value.encode('utf-8'))
805+
806+
807+
808+###########################################################
809+# Skein-based hashing functions and ChallengeResponse class
810+
811+def hash_pubkey(pubkey_data):
812+ """
813+ Hash an RSA public key to compute the Dmedia identity ID.
814+
815+ For example:
816+
817+ >>> hash_pubkey(b'The PEM encoded public key')
818+ 'JTHPOXBZKRMAUGDKXDR74ZRVVSKYUMTHZNQUOSUNTQ2IO4UD'
819+
820+ """
821+ skein = skein512(pubkey_data,
822+ digest_bits=240,
823+ pers=PERS_PUBKEY,
824+ )
825+ return encode(skein.digest())
826+
827+
828+def compute_response(secret, challenge, nonce, challenger_hash, responder_hash):
829+ """
830+ Compute response hash used in challenge-response protocol.
831+
832+ :param secret: the shared secret
833+ :param challenge: a nonce generated by the challenger
834+ :param none: a nonce generated by the responder
835+ :param challenger_hash: hash of the challenger's public key
836+ :param responder_hash: hash of the responder's public key
837+ """
838+ assert len(secret) == 5
839+ assert len(challenge) == 20
840+ assert len(nonce) == 20
841+ assert len(challenger_hash) == 30
842+ assert len(responder_hash) == 30
843+ skein = skein512(
844+ digest_bits=MAC_BITS,
845+ pers=PERS_RESPONSE,
846+ key=secret,
847+ nonce=(challenge + nonce),
848+ )
849+ skein.update(challenger_hash)
850+ skein.update(responder_hash)
851+ return encode(skein.digest())
852+
853+
854+def compute_csr_mac(secret, csr_data, remote_hash, local_hash):
855+ """
856+ Compute MAC to prove machine that created CSR has the secret.
857+
858+ :param secret: the shared secret
859+ :param csr_data: PEM encoded certificate signing request
860+ :param remote_hash: hash of remote peer's public key
861+ :param local_hash: hash of local peer's public key
862+ """
863+ assert len(secret) == 5
864+ assert len(remote_hash) == 30
865+ assert len(local_hash) == 30
866+ skein = skein512(csr_data,
867+ digest_bits=MAC_BITS,
868+ pers=PERS_CSR,
869+ key=secret,
870+ key_id=(remote_hash + local_hash),
871+ )
872+ return encode(skein.digest())
873+
874+
875+def compute_cert_mac(secret, cert_data, remote_hash, local_hash):
876+ """
877+ Compute MAC to prove machine that issued certificate has the secret.
878+
879+ :param secret: the shared secret
880+ :param cert_data: PEM encoded certificate
881+ :param remote_hash: hash of remote peer's public key
882+ :param local_hash: hash of local peer's public key
883+ """
884+ assert len(secret) == 5
885+ assert len(remote_hash) == 30
886+ assert len(local_hash) == 30
887+ skein = skein512(cert_data,
888+ digest_bits=MAC_BITS,
889+ pers=PERS_CERT,
890+ key=secret,
891+ key_id=(remote_hash + local_hash),
892+ )
893+ return encode(skein.digest())
894+
895+
896+class ChallengeResponse:
897+ """
898+ Helper class to hold state for challenge-response protocol.
899+ """
900+
901+ def __init__(self, _id, peer_id):
902+ self.id = _id
903+ self.peer_id = peer_id
904+ self.local_hash = decode(_id)
905+ self.remote_hash = decode(peer_id)
906+ assert len(self.local_hash) == 30
907+ assert len(self.remote_hash) == 30
908+
909+ def get_secret(self):
910+ # 40-bit secret (8 characters when base32 encoded)
911+ self.secret = os.urandom(5)
912+ return encode(self.secret)
913+
914+ def set_secret(self, secret):
915+ assert len(secret) == 8
916+ self.secret = decode(secret)
917+ assert len(self.secret) == 5
918+
919+ def get_challenge(self):
920+ self.challenge = os.urandom(20)
921+ return encode(self.challenge)
922+
923+ def create_response(self, challenge):
924+ nonce = os.urandom(20)
925+ response = compute_response(
926+ self.secret,
927+ decode(challenge),
928+ nonce,
929+ self.remote_hash,
930+ self.local_hash
931+ )
932+ return (encode(nonce), response)
933+
934+ def check_response(self, nonce, response):
935+ expected = compute_response(
936+ self.secret,
937+ self.challenge,
938+ decode(nonce),
939+ self.local_hash,
940+ self.remote_hash
941+ )
942+ if response != expected:
943+ del self.secret
944+ del self.challenge
945+ raise WrongResponse(expected, response)
946+
947+ def csr_mac(self, csr_data):
948+ return compute_csr_mac(
949+ self.secret,
950+ csr_data,
951+ self.remote_hash,
952+ self.local_hash,
953+ )
954+
955+ def check_csr_mac(self, csr_data, mac):
956+ expected = compute_csr_mac(
957+ self.secret,
958+ csr_data,
959+ self.local_hash,
960+ self.remote_hash,
961+ )
962+ if mac != expected:
963+ del self.secret
964+ raise WrongMAC(expected, mac)
965+
966+ def cert_mac(self, cert_data):
967+ return compute_cert_mac(
968+ self.secret,
969+ cert_data,
970+ self.remote_hash,
971+ self.local_hash,
972+ )
973+
974+ def check_cert_mac(self, cert_data, mac):
975+ expected = compute_cert_mac(
976+ self.secret,
977+ cert_data,
978+ self.local_hash,
979+ self.remote_hash,
980+ )
981+ if mac != expected:
982+ del self.secret
983+ raise WrongMAC(expected, mac)
984+
985+
986+
987+##########################
988+# openssl helper functions
989+
990+def make_subject(cn):
991+ """
992+ Make an openssl certificate subject from the common name *cn*.
993+
994+ For example:
995+
996+ >>> make_subject('foo')
997+ '/CN=foo'
998+
999+ """
1000+ return '/CN={}'.format(cn)
1001
1002
1003 def create_key(dst_file, bits=2048):
1004@@ -171,6 +328,7 @@
1005 Create an RSA keypair and save it to *dst_file*.
1006 """
1007 assert bits % 1024 == 0
1008+ assert bits >= 1024
1009 check_call(['openssl', 'genrsa',
1010 '-out', dst_file,
1011 str(bits)
1012@@ -357,301 +515,9 @@
1013 return filename
1014
1015
1016-def encode(value):
1017- assert isinstance(value, bytes)
1018- assert len(value) > 0 and len(value) % 5 == 0
1019- return base64.b32encode(value).decode('utf-8')
1020-
1021-
1022-def decode(value):
1023- assert isinstance(value, str)
1024- assert len(value) > 0 and len(value) % 8 == 0
1025- return base64.b32decode(value.encode('utf-8'))
1026-
1027-
1028-def _hash_pubkey(data):
1029- return skein512(data,
1030- digest_bits=240,
1031- pers=PERS_PUBKEY,
1032- ).digest()
1033-
1034-
1035-def hash_pubkey(data):
1036- return encode(_hash_pubkey(data))
1037-
1038-
1039-def _hash_cert(cert_data):
1040- return skein512(cert_data,
1041- digest_bits=200,
1042- pers=PERS_CERT,
1043- ).digest()
1044-
1045-
1046-def hash_cert(cert_data):
1047- return encode(_hash_cert(cert_data))
1048-
1049-
1050-def compute_response(secret, challenge, nonce, challenger_hash, responder_hash):
1051- """
1052-
1053- :param secret: the shared secret
1054-
1055- :param challenge: a nonce generated by the challenger
1056-
1057- :param none: a nonce generated by the responder
1058-
1059- :param challenger_hash: hash of the challengers certificate
1060-
1061- :param responder_hash: hash of the responders certificate
1062- """
1063- assert len(secret) == 5
1064- assert len(challenge) == 20
1065- assert len(nonce) == 20
1066- assert len(challenger_hash) == 30
1067- assert len(responder_hash) == 30
1068- skein = skein512(
1069- digest_bits=280,
1070- pers=PERS_RESPONSE,
1071- key=secret,
1072- nonce=(challenge + nonce),
1073- )
1074- skein.update(challenger_hash)
1075- skein.update(responder_hash)
1076- return encode(skein.digest())
1077-
1078-
1079-class WrongResponse(Exception):
1080- def __init__(self, expected, got):
1081- self.expected = expected
1082- self.got = got
1083- super().__init__('Incorrect response')
1084-
1085-
1086-class ChallengeResponse:
1087- def __init__(self, _id, peer_id):
1088- self.id = _id
1089- self.peer_id = peer_id
1090- self.local_hash = decode(_id)
1091- self.remote_hash = decode(peer_id)
1092- assert len(self.local_hash) == 30
1093- assert len(self.remote_hash) == 30
1094-
1095- def get_secret(self):
1096- # 40-bit secret (8 characters when base32 encoded)
1097- self.secret = os.urandom(5)
1098- return encode(self.secret)
1099-
1100- def set_secret(self, secret):
1101- assert len(secret) == 8
1102- self.secret = decode(secret)
1103- assert len(self.secret) == 5
1104-
1105- def get_challenge(self):
1106- self.challenge = os.urandom(20)
1107- return encode(self.challenge)
1108-
1109- def create_response(self, challenge):
1110- nonce = os.urandom(20)
1111- response = compute_response(
1112- self.secret,
1113- decode(challenge),
1114- nonce,
1115- self.remote_hash,
1116- self.local_hash
1117- )
1118- return (encode(nonce), response)
1119-
1120- def check_response(self, nonce, response):
1121- expected = compute_response(
1122- self.secret,
1123- self.challenge,
1124- decode(nonce),
1125- self.local_hash,
1126- self.remote_hash
1127- )
1128- if response != expected:
1129- del self.secret
1130- del self.challenge
1131- raise WrongResponse(expected, response)
1132-
1133-
1134-class InfoApp:
1135- def __init__(self, _id):
1136- self.id = _id
1137- obj = {
1138- 'id': _id,
1139- 'user': USER,
1140- 'host': HOST,
1141- }
1142- self.info = dumps(obj).encode('utf-8')
1143- self.info_length = str(len(self.info))
1144-
1145- def __call__(self, environ, start_response):
1146- if environ['wsgi.multithread'] is not False:
1147- raise WSGIError('500 Internal Server Error')
1148- if environ['PATH_INFO'] != '/':
1149- raise WSGIError('410 Gone')
1150- if environ['REQUEST_METHOD'] != 'GET':
1151- raise WSGIError('405 Method Not Allowed')
1152- start_response('200 OK',
1153- [
1154- ('Content-Length', self.info_length),
1155- ('Content-Type', 'application/json'),
1156- ]
1157- )
1158- return [self.info]
1159-
1160-
1161-class ClientApp:
1162- allowed_states = (
1163- 'ready',
1164- 'gave_challenge',
1165- 'in_response',
1166- 'wrong_response',
1167- 'response_ok',
1168- )
1169-
1170- forwarded_states = (
1171- 'wrong_response',
1172- 'response_ok',
1173- )
1174-
1175- def __init__(self, cr, queue):
1176- assert isinstance(cr, ChallengeResponse)
1177- self.cr = cr
1178- self.queue = queue
1179- self.__state = None
1180- self.map = {
1181- '/challenge': self.get_challenge,
1182- '/response': self.put_response,
1183- }
1184-
1185- def get_state(self):
1186- return self.__state
1187-
1188- def set_state(self, state):
1189- if state not in self.__class__.allowed_states:
1190- self.__state = None
1191- log.error('invalid state: %r', state)
1192- raise Exception('invalid state: {!r}'.format(state))
1193- self.__state = state
1194- if state in self.__class__.forwarded_states:
1195- self.queue.put(state)
1196-
1197- state = property(get_state, set_state)
1198-
1199- def __call__(self, environ, start_response):
1200- if environ['wsgi.multithread'] is not False:
1201- raise WSGIError('500 Internal Server Error')
1202- if environ.get('SSL_CLIENT_VERIFY') != 'SUCCESS':
1203- raise WSGIError('403 Forbidden')
1204- if environ.get('SSL_CLIENT_S_DN_CN') != self.cr.peer_id:
1205- raise WSGIError('403 Forbidden')
1206- if environ.get('SSL_CLIENT_I_DN_CN') != self.cr.peer_id:
1207- raise WSGIError('403 Forbidden')
1208-
1209- path_info = environ['PATH_INFO']
1210- if path_info not in self.map:
1211- raise WSGIError('410 Gone')
1212- log.info('%s %s', environ['REQUEST_METHOD'], environ['PATH_INFO'])
1213- try:
1214- obj = self.map[path_info](environ)
1215- data = json.dumps(obj).encode('utf-8')
1216- start_response('200 OK',
1217- [
1218- ('Content-Length', str(len(data))),
1219- ('Content-Type', 'application/json'),
1220- ]
1221- )
1222- return [data]
1223- except WSGIError as e:
1224- raise e
1225- except Exception:
1226- log.exception('500 Internal Server Error')
1227- raise WSGIError('500 Internal Server Error')
1228-
1229- def get_challenge(self, environ):
1230- if self.state != 'ready':
1231- raise WSGIError('400 Bad Request Order')
1232- self.state = 'gave_challenge'
1233- if environ['REQUEST_METHOD'] != 'GET':
1234- raise WSGIError('405 Method Not Allowed')
1235- return {
1236- 'challenge': self.cr.get_challenge(),
1237- }
1238-
1239- def put_response(self, environ):
1240- if self.state != 'gave_challenge':
1241- raise WSGIError('400 Bad Request Order')
1242- self.state = 'in_response'
1243- if environ['REQUEST_METHOD'] != 'PUT':
1244- raise WSGIError('405 Method Not Allowed')
1245- data = environ['wsgi.input'].read()
1246- obj = json.loads(data.decode('utf-8'))
1247- nonce = obj['nonce']
1248- response = obj['response']
1249- try:
1250- self.cr.check_response(nonce, response)
1251- except WrongResponse:
1252- self.state = 'wrong_response'
1253- raise WSGIError('401 Unauthorized')
1254- self.state = 'response_ok'
1255- return {'ok': True}
1256-
1257-
1258-class ServerApp(ClientApp):
1259-
1260- allowed_states = (
1261- 'info',
1262- 'counter_response_ok',
1263- 'in_csr',
1264- 'bad_csr',
1265- 'cert_issued',
1266- ) + ClientApp.allowed_states
1267-
1268- forwarded_states = (
1269- 'bad_csr',
1270- 'cert_issued',
1271- ) + ClientApp.forwarded_states
1272-
1273- def __init__(self, cr, queue, pki):
1274- super().__init__(cr, queue)
1275- self.pki = pki
1276- self.map['/'] = self.get_info
1277- self.map['/csr'] = self.post_csr
1278-
1279- def get_info(self, environ):
1280- if self.state != 'info':
1281- raise WSGIError('400 Bad Request State')
1282- self.state = 'ready'
1283- if environ['REQUEST_METHOD'] != 'GET':
1284- raise WSGIError('405 Method Not Allowed')
1285- return {
1286- 'id': self.cr.id,
1287- 'user': USER,
1288- 'host': HOST,
1289- }
1290-
1291- def post_csr(self, environ):
1292- if self.state != 'counter_response_ok':
1293- raise WSGIError('400 Bad Request Order')
1294- self.state = 'in_csr'
1295- if environ['REQUEST_METHOD'] != 'POST':
1296- raise WSGIError('405 Method Not Allowed')
1297- data = environ['wsgi.input'].read()
1298- obj = json.loads(data.decode('utf-8'))
1299- csr_data = base64.b64decode(obj['csr'].encode('utf-8'))
1300- try:
1301- self.pki.write_csr(self.cr.peer_id, csr_data)
1302- self.pki.issue_cert(self.cr.peer_id, self.cr.id)
1303- cert_data = self.pki.read_cert2(self.cr.peer_id, self.cr.id)
1304- except Exception as e:
1305- log.exception('could not issue cert')
1306- self.state = 'bad_csr'
1307- raise WSGIError('401 Unauthorized')
1308- self.state = 'cert_issued'
1309- return {'cert': base64.b64encode(cert_data).decode('utf-8')}
1310-
1311+
1312+###########################
1313+# The PKI class and related
1314
1315 def ensuredir(d):
1316 try:
1317@@ -662,10 +528,6 @@
1318 raise ValueError('not a directory: {!r}'.format(d))
1319
1320
1321-def make_subject(cn):
1322- return '/CN={}'.format(cn)
1323-
1324-
1325 class PKI:
1326 def __init__(self, ssldir):
1327 self.ssldir = ssldir
1328@@ -772,7 +634,7 @@
1329 tmp_file = self.random_tmp()
1330 cert_file = self.path(_id, 'cert')
1331
1332- ca_file = self.verify_cert(subca_id)
1333+ ca_file = self.path(subca_id, 'cert')
1334 key_file = self.verify_key(subca_id)
1335 srl_file = self.path(subca_id, 'srl')
1336
1337@@ -780,32 +642,16 @@
1338 os.rename(tmp_file, cert_file)
1339 return cert_file
1340
1341- def verify_cert(self, _id):
1342- cert_file = self.path(_id, 'cert')
1343- return verify(cert_file, _id)
1344-
1345- def verify_cert2(self, cert_id, ca_id):
1346+ def verify_cert(self, cert_id, ca_id):
1347 cert_file = self.path(cert_id, 'cert')
1348 ca_file = self.verify_ca(ca_id)
1349 return verify_cert(cert_file, cert_id, ca_file, ca_id)
1350
1351- def read_cert(self, _id):
1352- cert_file = self.verify_cert(_id)
1353- return open(cert_file, 'rb').read()
1354-
1355- def read_cert2(self, cert_id, ca_id):
1356- cert_file = self.verify_cert2(cert_id, ca_id)
1357- return open(cert_file, 'rb').read()
1358-
1359- def write_cert(self, _id, data):
1360- tmp_file = self.random_tmp()
1361- open(tmp_file, 'wb').write(data)
1362- verify(tmp_file, _id)
1363- cert_file = self.path(_id, 'cert')
1364- os.rename(tmp_file, cert_file)
1365- return cert_file
1366-
1367- def write_cert2(self, cert_id, ca_id, cert_data):
1368+ def read_cert(self, cert_id, ca_id):
1369+ cert_file = self.verify_cert(cert_id, ca_id)
1370+ return open(cert_file, 'rb').read()
1371+
1372+ def write_cert(self, cert_id, ca_id, cert_data):
1373 ca_file = self.verify_ca(ca_id)
1374 tmp_file = self.random_tmp()
1375 open(tmp_file, 'wb').write(cert_data)
1376@@ -817,20 +663,33 @@
1377 def get_ca(self, _id):
1378 return CA(_id, self.verify_ca(_id))
1379
1380- def get_cert(self, _id):
1381- return Cert(_id, self.verify_cert(_id), self.verify_key(_id))
1382+ def get_cert(self, cert_id, ca_id):
1383+ return Cert(
1384+ cert_id,
1385+ self.verify_cert(cert_id, ca_id),
1386+ self.verify_key(cert_id)
1387+ )
1388+
1389+ def load_machine(self, machine_id, user_id=None):
1390+ ca_file = self.verify_ca(machine_id)
1391+ key_file = self.verify_key(machine_id)
1392+ if user_id is None:
1393+ cert_file = None
1394+ else:
1395+ cert_file = self.verify_cert(machine_id, user_id)
1396+ return Machine(machine_id, ca_file, key_file, cert_file)
1397+
1398+ def load_user(self, user_id):
1399+ ca_file = self.verify_ca(user_id)
1400+ if path.exists(self.path(user_id, 'key')):
1401+ key_file = self.verify_key(user_id)
1402+ else:
1403+ key_file = None
1404+ return User(user_id, ca_file, key_file)
1405
1406 def load_pki(self, machine_id, user_id=None):
1407- if user_id is None:
1408- self.machine = Cert(
1409- machine_id,
1410- self.verify_ca(machine_id),
1411- self.verify_key(machine_id)
1412- )
1413- self.user = None
1414- else:
1415- self.machine = self.get_cert(machine_id)
1416- self.user = self.get_ca(user_id)
1417+ self.machine = self.load_machine(machine_id, user_id)
1418+ self.user = (None if user_id is None else self.load_user(user_id))
1419
1420
1421 class TempPKI(PKI):
1422@@ -857,7 +716,7 @@
1423 self.create_csr(cert_id)
1424 self.issue_cert(cert_id, ca_id)
1425
1426- return (self.get_ca(ca_id), self.get_cert(cert_id))
1427+ return (self.get_ca(ca_id), self.get_cert(cert_id, ca_id))
1428
1429 def get_server_config(self):
1430 config = {
1431
1432=== modified file 'dmedia/server.py'
1433--- dmedia/server.py 2012-10-04 21:58:54 +0000
1434+++ dmedia/server.py 2012-10-16 12:35:25 +0000
1435@@ -20,96 +20,45 @@
1436 # Jason Gerard DeRose <jderose@novacut.com>
1437
1438 """
1439-dmedia HTTP server.
1440-
1441-== Security Note ==
1442-
1443-To help prevent cross-site scripting attacks, the `HTTPError` raised for any
1444-invalid request should not include any data supplied in the request.
1445-
1446-It is helpful to include a meaningful bit a text in the response body, plus it
1447-allows us to test that an `HTTPError` is being raised because of the condition
1448-we expected it to be raised for. But include only static messages.
1449-
1450-For example, this is okay:
1451-
1452->>> raise BadRequest('too many slashes in request path') #doctest: +SKIP
1453-
1454-But this is not okay:
1455-
1456->>> raise BadRequest('bad path: {}'.format(environ['PATH_INFO'])) #doctest: +SKIP
1457-
1458+Dmedia WSGI applications.
1459 """
1460
1461 import json
1462+import os
1463 import socket
1464+from base64 import b64encode, b64decode
1465 from wsgiref.util import shift_path_info
1466 import logging
1467
1468 from filestore import DIGEST_B32LEN, B32ALPHABET, LEAF_SIZE
1469-import microfiber
1470+from microfiber import dumps, basic_auth_header, CouchBase
1471
1472+import dmedia
1473 from dmedia import __version__
1474-from dmedia.httpd import WSGIError, make_server
1475-from dmedia import local
1476-
1477-
1478-HTTP_METHODS = ('PUT', 'POST', 'GET', 'DELETE', 'HEAD')
1479-WELCOME = json.dumps(
1480- {'Dmedia': 'welcome', 'version': __version__},
1481- sort_keys=True,
1482-).encode('utf-8')
1483+from dmedia.httpd import WSGIError, make_server
1484+from dmedia import local, peering
1485+
1486+
1487+USER = os.environ.get('USER')
1488+HOST = socket.gethostname()
1489 log = logging.getLogger()
1490
1491
1492-def server_welcome(environ, start_response):
1493- if environ['REQUEST_METHOD'] != 'GET':
1494- raise WSGIError('405 Method Not Allowed')
1495- start_response('200 OK',
1496- [
1497- ('Content-Length', str(len(WELCOME))),
1498- ('Content-Type', 'application/json'),
1499- ]
1500- )
1501- return [WELCOME]
1502-
1503-
1504-class HTTPError(Exception):
1505- def __init__(self, body=b'', headers=None):
1506- if isinstance(body, str):
1507- body = body.encode('utf-8')
1508- headers = [('Content-Type', 'text/plain; charset=utf-8')]
1509- self.body = body
1510- self.headers = ([] if headers is None else headers)
1511- super().__init__(self.status)
1512-
1513-
1514-class BadRequest(HTTPError):
1515- status = '400 Bad Request'
1516-
1517-
1518-class NotFound(HTTPError):
1519- status = '404 Not Found'
1520-
1521-
1522-class MethodNotAllowed(HTTPError):
1523- status = '405 Method Not Allowed'
1524-
1525-
1526-class Conflict(HTTPError):
1527- status = '409 Conflict'
1528-
1529-
1530-class LengthRequired(HTTPError):
1531- status = '411 Length Required'
1532-
1533-
1534-class PreconditionFailed(HTTPError):
1535- status = '412 Precondition Failed'
1536-
1537-
1538-class BadRangeRequest(HTTPError):
1539- status = '416 Requested Range Not Satisfiable'
1540+def iter_headers(environ):
1541+ for (key, value) in environ.items():
1542+ if key in ('CONTENT_LENGHT', 'CONTENT_TYPE'):
1543+ yield (key.replace('_', '-').lower(), value)
1544+ elif key.startswith('HTTP_'):
1545+ yield (key[5:].replace('_', '-').lower(), value)
1546+
1547+
1548+def request_args(environ):
1549+ headers = dict(iter_headers(environ))
1550+ if environ['wsgi.input']._avail:
1551+ body = environ['wsgi.input'].read()
1552+ else:
1553+ body = None
1554+ return (environ['REQUEST_METHOD'], environ['PATH_INFO'], body, headers)
1555
1556
1557 def get_slice(environ):
1558@@ -168,27 +117,27 @@
1559 """
1560 unit = 'bytes='
1561 if not value.startswith(unit):
1562- raise BadRangeRequest('bad range units')
1563+ raise WSGIError('400 Bad Range Units')
1564 value = value[len(unit):]
1565 if value.startswith('-'):
1566 try:
1567 return (int(value), None)
1568 except ValueError:
1569- raise BadRangeRequest('range -start is not an integer')
1570+ raise WSGIError('400 Bad Range Negative Start')
1571 parts = value.split('-')
1572 if not len(parts) == 2:
1573- raise BadRangeRequest('not formatted as bytes=start-end')
1574+ raise WSGIError('400 Bad Range Format')
1575 try:
1576 start = int(parts[0])
1577 except ValueError:
1578- raise BadRangeRequest('range start is not an integer')
1579+ raise WSGIError('400 Bad Range Start')
1580 try:
1581 end = parts[1]
1582 stop = (int(end) + 1 if end else None)
1583 except ValueError:
1584- raise BadRangeRequest('range end is not an integer')
1585+ raise WSGIError('400 Bad Range End')
1586 if not (stop is None or start < stop):
1587- raise BadRangeRequest('range end must be less than or equal to start')
1588+ raise WSGIError('400 Bad Range')
1589 return (start, stop)
1590
1591
1592@@ -207,35 +156,10 @@
1593 'bytes 500-999/1234'
1594
1595 """
1596- assert 0 <= start < length
1597- assert start < stop <= length
1598+ assert 0 <= start < stop <= length
1599 return 'bytes {}-{}/{}'.format(start, stop - 1, length)
1600
1601
1602-class BaseWSGIMeta(type):
1603- def __new__(meta, name, bases, dict):
1604- http_methods = []
1605- cls = type.__new__(meta, name, bases, dict)
1606- for name in filter(lambda n: n in HTTP_METHODS, dir(cls)):
1607- method = getattr(cls, name)
1608- if callable(method):
1609- http_methods.append(name)
1610- cls.http_methods = frozenset(http_methods)
1611- return cls
1612-
1613-
1614-class BaseWSGI(metaclass=BaseWSGIMeta):
1615- def __call__(self, environ, start_response):
1616- try:
1617- name = environ['REQUEST_METHOD']
1618- if name not in self.__class__.http_methods:
1619- raise MethodNotAllowed()
1620- return getattr(self, name)(environ, start_response)
1621- except HTTPError as e:
1622- start_response(e.status, e.headers)
1623- return [e.body]
1624-
1625-
1626 MiB = 1024 * 1024
1627
1628
1629@@ -259,92 +183,28 @@
1630 assert remaining == 0
1631
1632
1633-class ReadOnlyApp(BaseWSGI):
1634- def __init__(self, env):
1635- self.local = local.LocalSlave(env)
1636- info = {
1637- 'Dmedia': 'Welcome',
1638- 'version': __version__,
1639- 'machine_id': env.get('machine_id'),
1640- 'hostname': socket.gethostname(),
1641- }
1642- self._info = json.dumps(info, sort_keys=True).encode('utf-8')
1643-
1644- def server_info(self, environ, start_response):
1645- start_response('200 OK', [('Content-Type', 'application/json')])
1646- return [self._info]
1647-
1648- def GET(self, environ, start_response):
1649- path_info = environ['PATH_INFO']
1650- if path_info == '/':
1651- return self.server_info(environ, start_response)
1652-
1653- _id = path_info.lstrip('/')
1654- if not (len(_id) == DIGEST_B32LEN and set(_id).issubset(B32ALPHABET)):
1655- raise NotFound()
1656- try:
1657- doc = self.local.get_doc(_id)
1658- st = self.local.stat2(doc)
1659- fp = open(st.name, 'rb')
1660- except Exception:
1661- raise NotFound()
1662-
1663- if doc.get('content_type'):
1664- headers = [('Content-Type', doc['content_type'])]
1665- else:
1666- headers = []
1667-
1668- if 'HTTP_RANGE' in environ:
1669- (start, stop) = range_to_slice(environ['HTTP_RANGE'])
1670- status = '206 Partial Content'
1671- else:
1672- start = 0
1673- stop = None
1674- status = '200 OK'
1675-
1676- stop = (st.size if stop is None else min(st.size, stop))
1677- length = str(stop - start)
1678- headers.append(('Content-Length', length))
1679- if 'HTTP_RANGE' in environ:
1680- headers.append(
1681- ('Content-Range', slice_to_content_range(start, stop, st.size))
1682- )
1683-
1684- start_response(status, headers)
1685- return FileSlice(fp, start, stop)
1686-
1687-
1688-class ReadWriteApp(ReadOnlyApp):
1689- def PUT(self, environ, start_response):
1690- pass
1691-
1692- def POST(self, environ, start_response):
1693- pass
1694-
1695-
1696-def iter_headers(environ):
1697- for (key, value) in environ.items():
1698- if key in ('CONTENT_LENGHT', 'CONTENT_TYPE'):
1699- yield (key.replace('_', '-').lower(), value)
1700- elif key.startswith('HTTP_'):
1701- yield (key[5:].replace('_', '-').lower(), value)
1702-
1703-
1704-def request_args(environ):
1705- headers = dict(iter_headers(environ))
1706- if environ['wsgi.input']._avail:
1707- body = environ['wsgi.input'].read()
1708- else:
1709- body = None
1710- return (environ['REQUEST_METHOD'], environ['PATH_INFO'], body, headers)
1711-
1712-
1713 class RootApp:
1714+ """
1715+ Main Dmedia WSGI app.
1716+ """
1717+
1718 def __init__(self, env):
1719 self.user_id = env['user_id']
1720+ obj = {
1721+ 'user_id': env['user_id'],
1722+ 'machine_id': env['machine_id'],
1723+ 'version': dmedia.__version__,
1724+ 'user': USER,
1725+ 'host': HOST,
1726+ }
1727+ self.info = dumps(obj).encode('utf-8')
1728+ self.info_length = str(len(self.info))
1729+ self.proxy = ProxyApp(env)
1730+ self.files = FilesApp(env)
1731 self.map = {
1732- '': server_welcome,
1733- 'couch': ProxyApp(env),
1734+ '': self.get_info,
1735+ 'couch': self.proxy,
1736+ 'files': self.files,
1737 }
1738
1739 def __call__(self, environ, start_response):
1740@@ -357,35 +217,35 @@
1741 return self.map[key](environ, start_response)
1742 raise WSGIError('410 Gone')
1743
1744+ def get_info(self, environ, start_response):
1745+ if environ['REQUEST_METHOD'] != 'GET':
1746+ raise WSGIError('405 Method Not Allowed')
1747+ start_response('200 OK',
1748+ [
1749+ ('Content-Length', self.info_length),
1750+ ('Content-Type', 'application/json'),
1751+ ]
1752+ )
1753+ return [self.info]
1754+
1755
1756 class ProxyApp:
1757- def __init__(self, env, debug=False):
1758- self.debug = debug
1759- self.client = microfiber.CouchBase(env)
1760+ def __init__(self, env):
1761+ self.client = CouchBase(env)
1762 self.target_host = self.client.ctx.t.netloc
1763- self.basic_auth = microfiber.basic_auth_header(env['basic'])
1764+ self.basic_auth = basic_auth_header(env['basic'])
1765
1766 def __call__(self, environ, start_response):
1767 (method, path, body, headers) = request_args(environ)
1768 db = shift_path_info(environ)
1769 if db and db.startswith('_'):
1770 raise WSGIError('403 Forbidden')
1771- if self.debug:
1772- print('')
1773- print('{REQUEST_METHOD} {PATH_INFO}'.format(**environ))
1774- for key in sorted(headers):
1775- print('{}: {}'.format(key, headers[key]))
1776 headers['host'] = self.target_host
1777 headers['authorization'] = self.basic_auth
1778
1779 response = self.client.raw_request(method, path, body, headers)
1780 status = '{} {}'.format(response.status, response.reason)
1781 headers = response.getheaders()
1782- if self.debug:
1783- print('-' * 80)
1784- print(status)
1785- for (key, value) in headers:
1786- print('{}: {}'.format(key, value))
1787 start_response(status, headers)
1788 body = response.read()
1789 if body:
1790@@ -393,6 +253,242 @@
1791 return []
1792
1793
1794+class FilesApp:
1795+ def __init__(self, env):
1796+ self.local = local.LocalSlave(env)
1797+
1798+ def __call__(self, environ, start_response):
1799+ if environ['REQUEST_METHOD'] != 'GET':
1800+ raise WSGIError('405 Method Not Allowed')
1801+ _id = shift_path_info(environ)
1802+ if not (len(_id) == DIGEST_B32LEN and set(_id).issubset(B32ALPHABET)):
1803+ raise WSGIError('400 Bad Request ID')
1804+ try:
1805+ doc = self.local.get_doc(_id)
1806+ st = self.local.stat2(doc)
1807+ fp = open(st.name, 'rb')
1808+ except Exception:
1809+ raise WSGIError('404 Not Found')
1810+
1811+ if 'HTTP_RANGE' in environ:
1812+ (start, stop) = range_to_slice(environ['HTTP_RANGE'])
1813+ status = '206 Partial Content'
1814+ else:
1815+ start = 0
1816+ stop = None
1817+ status = '200 OK'
1818+
1819+ # '416 Requested Range Not Satisfiable'
1820+
1821+ stop = (st.size if stop is None else min(st.size, stop))
1822+ length = str(stop - start)
1823+ headers = [('Content-Length', length)]
1824+ if 'HTTP_RANGE' in environ:
1825+ headers.append(
1826+ ('Content-Range', slice_to_content_range(start, stop, st.size))
1827+ )
1828+ start_response(status, headers)
1829+ return FileSlice(fp, start, stop)
1830+
1831+
1832+class InfoApp:
1833+ """
1834+ WSGI app initially used by the client-end of the peering process.
1835+ """
1836+
1837+ def __init__(self, _id):
1838+ self.id = _id
1839+ obj = {
1840+ 'id': _id,
1841+ 'version': dmedia.__version__,
1842+ 'user': USER,
1843+ 'host': HOST,
1844+ }
1845+ self.info = dumps(obj).encode('utf-8')
1846+ self.info_length = str(len(self.info))
1847+
1848+ def __call__(self, environ, start_response):
1849+ if environ['wsgi.multithread'] is not False:
1850+ raise WSGIError('500 Internal Server Error')
1851+ if environ['PATH_INFO'] != '/':
1852+ raise WSGIError('410 Gone')
1853+ if environ['REQUEST_METHOD'] != 'GET':
1854+ raise WSGIError('405 Method Not Allowed')
1855+ start_response('200 OK',
1856+ [
1857+ ('Content-Length', self.info_length),
1858+ ('Content-Type', 'application/json'),
1859+ ]
1860+ )
1861+ return [self.info]
1862+
1863+
1864+class ClientApp:
1865+ """
1866+ WSGI app used by the client-end of the peering process.
1867+ """
1868+
1869+ allowed_states = (
1870+ 'ready',
1871+ 'gave_challenge',
1872+ 'in_response',
1873+ 'wrong_response',
1874+ 'response_ok',
1875+ )
1876+
1877+ forwarded_states = (
1878+ 'wrong_response',
1879+ 'response_ok',
1880+ )
1881+
1882+ def __init__(self, cr, queue):
1883+ self.cr = cr
1884+ self.queue = queue
1885+ self.__state = None
1886+ self.map = {
1887+ '/challenge': self.get_challenge,
1888+ '/response': self.post_response,
1889+ }
1890+
1891+ def get_state(self):
1892+ return self.__state
1893+
1894+ def set_state(self, state):
1895+ if state not in self.__class__.allowed_states:
1896+ self.__state = None
1897+ log.error('invalid state: %r', state)
1898+ raise Exception('invalid state: {!r}'.format(state))
1899+ self.__state = state
1900+ if state in self.__class__.forwarded_states:
1901+ self.queue.put(state)
1902+
1903+ state = property(get_state, set_state)
1904+
1905+ def __call__(self, environ, start_response):
1906+ if environ['wsgi.multithread'] is not False:
1907+ raise WSGIError('500 Internal Server Error')
1908+ if environ.get('SSL_CLIENT_VERIFY') != 'SUCCESS':
1909+ raise WSGIError('403 Forbidden SSL')
1910+ if environ.get('SSL_CLIENT_S_DN_CN') != self.cr.peer_id:
1911+ raise WSGIError('403 Forbidden Subject')
1912+ if environ.get('SSL_CLIENT_I_DN_CN') != self.cr.peer_id:
1913+ raise WSGIError('403 Forbidden Issuer')
1914+
1915+ path_info = environ['PATH_INFO']
1916+ if path_info not in self.map:
1917+ raise WSGIError('410 Gone')
1918+ log.info('%s %s',
1919+ environ.get('REQUEST_METHOD'),
1920+ environ.get('PATH_INFO')
1921+ )
1922+ try:
1923+ obj = self.map[path_info](environ)
1924+ data = dumps(obj).encode('utf-8')
1925+ start_response('200 OK',
1926+ [
1927+ ('Content-Length', str(len(data))),
1928+ ('Content-Type', 'application/json'),
1929+ ]
1930+ )
1931+ return [data]
1932+ except WSGIError as e:
1933+ raise e
1934+ except Exception:
1935+ log.exception('500 Internal Server Error')
1936+ raise WSGIError('500 Internal Server Error')
1937+
1938+ def get_challenge(self, environ):
1939+ if self.state != 'ready':
1940+ raise WSGIError('400 Bad Request Order')
1941+ self.state = 'gave_challenge'
1942+ if environ['REQUEST_METHOD'] != 'GET':
1943+ raise WSGIError('405 Method Not Allowed')
1944+ return {
1945+ 'challenge': self.cr.get_challenge(),
1946+ }
1947+
1948+ def post_response(self, environ):
1949+ if self.state != 'gave_challenge':
1950+ raise WSGIError('400 Bad Request Order')
1951+ self.state = 'in_response'
1952+ if environ['REQUEST_METHOD'] != 'POST':
1953+ raise WSGIError('405 Method Not Allowed')
1954+ data = environ['wsgi.input'].read()
1955+ obj = json.loads(data.decode('utf-8'))
1956+ nonce = obj['nonce']
1957+ response = obj['response']
1958+ try:
1959+ self.cr.check_response(nonce, response)
1960+ except peering.WrongResponse:
1961+ self.state = 'wrong_response'
1962+ raise WSGIError('401 Unauthorized')
1963+ self.state = 'response_ok'
1964+ return {'ok': True}
1965+
1966+
1967+class ServerApp(ClientApp):
1968+ """
1969+ WSGI app used by the server-end of the peering process.
1970+ """
1971+
1972+ allowed_states = (
1973+ 'info',
1974+ 'counter_response_ok',
1975+ 'in_csr',
1976+ 'bad_csr',
1977+ 'cert_issued',
1978+ ) + ClientApp.allowed_states
1979+
1980+ forwarded_states = (
1981+ 'bad_csr',
1982+ 'cert_issued',
1983+ ) + ClientApp.forwarded_states
1984+
1985+ def __init__(self, cr, queue, pki):
1986+ super().__init__(cr, queue)
1987+ self.pki = pki
1988+ self.map['/'] = self.get_info
1989+ self.map['/csr'] = self.post_csr
1990+
1991+ def get_info(self, environ):
1992+ if self.state != 'info':
1993+ raise WSGIError('400 Bad Request State')
1994+ self.state = 'ready'
1995+ if environ['REQUEST_METHOD'] != 'GET':
1996+ raise WSGIError('405 Method Not Allowed')
1997+ return {
1998+ 'id': self.cr.id,
1999+ 'user': USER,
2000+ 'host': HOST,
2001+ }
2002+
2003+ def post_csr(self, environ):
2004+ if self.state != 'counter_response_ok':
2005+ raise WSGIError('400 Bad Request Order')
2006+ self.state = 'in_csr'
2007+ if environ['REQUEST_METHOD'] != 'POST':
2008+ raise WSGIError('405 Method Not Allowed')
2009+ data = environ['wsgi.input'].read()
2010+ d = json.loads(data.decode('utf-8'))
2011+ csr_data = b64decode(d['csr'].encode('utf-8'))
2012+ try:
2013+ self.cr.check_csr_mac(csr_data, d['mac'])
2014+ self.pki.write_csr(self.cr.peer_id, csr_data)
2015+ self.pki.issue_cert(self.cr.peer_id, self.cr.id)
2016+ cert_data = self.pki.read_cert(self.cr.peer_id, self.cr.id)
2017+ key_data = self.pki.read_key(self.cr.id)
2018+ except Exception as e:
2019+ log.exception('could not issue cert')
2020+ self.state = 'bad_csr'
2021+ raise WSGIError('401 Unauthorized')
2022+ self.state = 'cert_issued'
2023+ return {
2024+ 'cert': b64encode(cert_data).decode('utf-8'),
2025+ 'mac': self.cr.cert_mac(cert_data),
2026+ 'key': b64encode(key_data).decode('utf-8'),
2027+ }
2028+
2029+
2030 def run_server(queue, couch_env, bind_address, ssl_config):
2031 try:
2032 app = RootApp(couch_env)
2033
2034=== modified file 'dmedia/service/__init__.py'
2035--- dmedia/service/__init__.py 2012-04-15 05:29:57 +0000
2036+++ dmedia/service/__init__.py 2012-10-16 12:35:25 +0000
2037@@ -25,15 +25,34 @@
2038 Code that is portable should go in dmedia/*.py (the dmedia core).
2039 """
2040
2041+import logging
2042+
2043 import dbus
2044 from dbus.mainloop.glib import DBusGMainLoop
2045 from gi.repository import GObject
2046
2047+
2048+DBusGMainLoop(set_as_default=True)
2049 GObject.threads_init()
2050-DBusGMainLoop(set_as_default=True)
2051-
2052-
2053-def get_proxy():
2054+
2055+BUS = 'org.freedesktop.Dmedia'
2056+
2057+
2058+def get_proxy(bus=BUS):
2059 session = dbus.SessionBus()
2060- return session.get_object('org.freedesktop.Dmedia', '/')
2061+ return session.get_object(bus, '/')
2062+
2063+
2064+def init_if_needed():
2065+ logging.info('Getting Dmedia proxy object...')
2066+ Dmedia = get_proxy()
2067+ logging.info('Checking Dmedia.NeedsInit()...')
2068+ logging.info(Dmedia.NeedsInit())
2069+ if Dmedia.NeedsInit():
2070+ logging.info('Needs firstrun init.')
2071+ from dmedia.gtk.peering import ClientUI
2072+ ui = ClientUI(Dmedia)
2073+ ui.run()
2074+ if ui.quit:
2075+ raise SystemExit(0)
2076
2077
2078=== modified file 'dmedia/service/avahi.py'
2079--- dmedia/service/avahi.py 2012-10-06 23:43:40 +0000
2080+++ dmedia/service/avahi.py 2012-10-16 12:35:25 +0000
2081@@ -28,30 +28,49 @@
2082 import time
2083 from collections import namedtuple
2084
2085-from filestore import _start_thread
2086-from microfiber import Context, Server, Database, NotFound
2087+from microfiber import dumps, NotFound, Context, Server, Database
2088 import dbus
2089 from gi.repository import GObject
2090
2091+from dmedia.parallel import start_thread
2092 from dmedia import util, views
2093
2094 log = logging.getLogger()
2095+Peer = namedtuple('Peer', 'env names')
2096 PROTO = 0 # Protocol -1 = both, 0 = IPv4, 1 = IPv6
2097-Peer = namedtuple('Peer', 'env names')
2098-PEERS = '_local/peers'
2099+PEERS_ID = '_local/peers'
2100+
2101+
2102+def make_url(ip, port):
2103+ if PROTO == 0:
2104+ return 'https://{}:{}/'.format(ip, port)
2105+ elif PROTO == 1:
2106+ return 'https://[{}]:{}/'.format(ip, port)
2107+ raise Exception('bad PROTO')
2108
2109
2110 class Avahi:
2111- """
2112- Base class to capture the messy Avahi DBus details.
2113- """
2114-
2115- service = '_example._tcp'
2116-
2117- def __init__(self, _id, port):
2118+
2119+ service = '_dmedia._tcp'
2120+
2121+ def __init__(self, env, port, ssl_config):
2122 self.group = None
2123- self.id = _id
2124+ self.machine_id = env['machine_id']
2125+ self.user_id = env['user_id']
2126 self.port = port
2127+ self.ssl_config = ssl_config
2128+ ctx = Context(env)
2129+ self.db = Database('dmedia-0', ctx=ctx)
2130+ self.server = Server(ctx=ctx)
2131+ self.replications = {}
2132+ try:
2133+ self.peers = self.db.get(PEERS_ID)
2134+ if self.peers.get('peers') != {}:
2135+ self.peers['peers'] = {}
2136+ self.db.save(self.peers)
2137+ except NotFound:
2138+ self.peers = {'_id': PEERS_ID, 'peers': {}}
2139+ self.db.save(self.peers)
2140
2141 def __del__(self):
2142 self.free()
2143@@ -65,14 +84,12 @@
2144 dbus_interface='org.freedesktop.Avahi.Server'
2145 )
2146 )
2147- log.info(
2148- 'Avahi(%s): advertising %s on port %s', self.service, self.id, self.port
2149- )
2150+ log.info('Avahi: advertising %s on port %s', self.machine_id, self.port)
2151 self.group.AddService(
2152 -1, # Interface
2153 PROTO, # Protocol -1 = both, 0 = ipv4, 1 = ipv6
2154 0, # Flags
2155- self.id,
2156+ self.machine_id,
2157 self.service,
2158 '', # Domain, default to .local
2159 '', # Host, default to localhost
2160@@ -90,22 +107,25 @@
2161 dbus_interface='org.freedesktop.Avahi.Server'
2162 )
2163 self.browser = system.get_object('org.freedesktop.Avahi', browser_path)
2164+ self.browser.connect_to_signal('ItemRemove', self.on_ItemRemove)
2165 self.browser.connect_to_signal('ItemNew', self.on_ItemNew)
2166- self.browser.connect_to_signal('ItemRemove', self.on_ItemRemove)
2167+ self.timeout_id = GObject.timeout_add(15000, self.on_timeout)
2168
2169 def free(self):
2170 if self.group is not None:
2171- log.info(
2172- 'Avahi(%s): freeing %s on port %s', self.service, self.id, self.port
2173- )
2174+ log.info('Avahi: freeing %s on port %s', self.machine_id, self.port)
2175 self.group.Reset(dbus_interface='org.freedesktop.Avahi.EntryGroup')
2176 self.group = None
2177 del self.browser
2178 del self.avahi
2179
2180+ def on_ItemRemove(self, interface, protocol, key, _type, domain, flags):
2181+ log.info('Avahi: peer removed: %s', key)
2182+ GObject.idle_add(self.remove_peer, key)
2183+
2184 def on_ItemNew(self, interface, protocol, key, _type, domain, flags):
2185 # Ignore what we publish ourselves:
2186- if key == self.id:
2187+ if key == self.machine_id:
2188 return
2189 self.avahi.ResolveService(
2190 # 2nd to last arg is Protocol, again for some reason
2191@@ -118,57 +138,30 @@
2192 def on_reply(self, *args):
2193 key = args[2]
2194 (ip, port) = args[7:9]
2195- url = 'https://{}:{}/'.format(ip, port)
2196- log.info('Avahi(%s): new peer %s at %s', self.service, key, url)
2197- self.add_peer(key, url)
2198+ url = make_url(ip, port)
2199+ log.info('Avahi: new peer %s at %s', key, url)
2200+ start_thread(self.info_thread, key, url)
2201
2202 def on_error(self, exception):
2203- log.error('%s: error calling ResolveService(): %r', self.service, exception)
2204-
2205- def on_ItemRemove(self, interface, protocol, key, _type, domain, flags):
2206- log.info('Avahi(%s): peer removed: %s', self.service, key)
2207- self.remove_peer(key)
2208-
2209- def add_peer(self, key, url):
2210- raise NotImplementedError(
2211- '{}.add_peer()'.format(self.__class__.__name__)
2212- )
2213-
2214- def remove_peer(self, key):
2215- raise NotImplementedError(
2216- '{}.remove_peer()'.format(self.__class__.__name__)
2217- )
2218-
2219-
2220-class FileServer(Avahi):
2221- """
2222- Advertise HTTP file server server over Avahi, discover other peers.
2223- """
2224- service = '_dmedia._tcp'
2225-
2226- def __init__(self, env, port):
2227- super().__init__(env['machine_id'], port)
2228- ctx = Context(env)
2229- self.db = Database('dmedia-0', ctx=ctx)
2230- self.server = Server(ctx=ctx)
2231- self.replications = {}
2232-
2233- def run(self):
2234+ log.error('Avahi: error calling ResolveService(): %r', exception)
2235+
2236+ def info_thread(self, key, url):
2237 try:
2238- self.peers = self.db.get(PEERS)
2239- if self.peers.get('peers') != {}:
2240- self.peers['peers'] = {}
2241- self.db.save(self.peers)
2242- except NotFound:
2243- self.peers = {'_id': PEERS, 'peers': {}}
2244- self.db.save(self.peers)
2245- super().run()
2246- self.timeout_id = GObject.timeout_add(15000, self.on_timeout)
2247+ env = {'url': url, 'ssl': self.ssl_config}
2248+ client = Server(env)
2249+ info = client.get()
2250+ assert info.pop('user_id') == self.user_id
2251+ assert info.pop('machine_id') == key
2252+ info['url'] = url
2253+ log.info('Avahi: got peer info: %s', dumps(info, pretty=True))
2254+ GObject.idle_add(self.add_peer, key, info)
2255+ except Exception:
2256+ log.exception('Avahi: could not get info for %s', url)
2257
2258- def add_peer(self, key, url):
2259- self.peers['peers'][key] = url
2260+ def add_peer(self, key, info):
2261+ self.peers['peers'][key] = info
2262 self.db.save(self.peers)
2263- self.add_replication_peer(key, url)
2264+ self.add_replication_peer(key, info['url'])
2265
2266 def remove_peer(self, key):
2267 try:
2268@@ -178,6 +171,18 @@
2269 pass
2270 self.remove_replication_peer(key)
2271
2272+ def add_replication_peer(self, key, url):
2273+ env = {'url': url + 'couch/'}
2274+ cancel = self.replications.pop(key, None)
2275+ start = Peer(env, list(self.get_names()))
2276+ self.replications[key] = start
2277+ start_thread(self.replication_worker, cancel, start)
2278+
2279+ def remove_replication_peer(self, key):
2280+ cancel = self.replications.pop(key, None)
2281+ if cancel:
2282+ start_thread(self.replication_worker, cancel, None)
2283+
2284 def on_timeout(self):
2285 if not self.replications:
2286 return True # Repeat timeout call
2287@@ -188,21 +193,9 @@
2288 log.info('New databases: %r', sorted(new))
2289 tmp = Peer(peer.env, tuple(new))
2290 peer.names.extend(new)
2291- _start_thread(self.replication_worker, None, tmp)
2292+ start_thread(self.replication_worker, None, tmp)
2293 return True # Repeat timeout call
2294
2295- def add_replication_peer(self, key, url):
2296- env = {'url': url + 'couch/'}
2297- cancel = self.replications.pop(key, None)
2298- start = Peer(env, list(self.get_names()))
2299- self.replications[key] = start
2300- _start_thread(self.replication_worker, cancel, start)
2301-
2302- def remove_replication_peer(self, key):
2303- cancel = self.replications.pop(key, None)
2304- if cancel:
2305- _start_thread(self.replication_worker, cancel, None)
2306-
2307 def get_names(self):
2308 for name in self.server.get('_all_dbs'):
2309 if name.startswith('dmedia-0') or name.startswith('novacut-0'):
2310@@ -248,4 +241,4 @@
2311 log.exception('Error canceling push of %s to %s', name, env['url'])
2312 else:
2313 log.exception('Error starting push of %s to %s', name, env['url'])
2314-
2315+
2316
2317=== modified file 'dmedia/service/peers.py'
2318--- dmedia/service/peers.py 2012-10-08 19:01:37 +0000
2319+++ dmedia/service/peers.py 2012-10-16 12:35:25 +0000
2320@@ -35,19 +35,28 @@
2321 import ssl
2322 import socket
2323 import threading
2324+from queue import Queue
2325+from base64 import b64encode, b64decode
2326+from gettext import gettext as _
2327
2328 import dbus
2329-from dbus.mainloop.glib import DBusGMainLoop
2330-from gi.repository import GObject
2331-from microfiber import _start_thread, random_id, CouchBase, dumps, build_ssl_context
2332+from gi.repository import GObject, Gtk, AppIndicator3
2333+from microfiber import Unauthorized, CouchBase
2334+from microfiber import random_id, dumps, build_ssl_context
2335+
2336+
2337+from dmedia.parallel import start_thread
2338+from dmedia.gtk.peering import BaseUI
2339+from dmedia.gtk.ubuntu import NotifyManager
2340+from dmedia.peering import ChallengeResponse
2341+from dmedia.server import ServerApp, InfoApp, ClientApp
2342+from dmedia.httpd import WSGIError, make_server
2343+
2344
2345 PROTO = 0 # Protocol -1 = both, 0 = IPv4, 1 = IPv6
2346-GObject.threads_init()
2347-DBusGMainLoop(set_as_default=True)
2348-log = logging.getLogger()
2349-
2350 Peer = namedtuple('Peer', 'id ip port')
2351 Info = namedtuple('Info', 'name host url id')
2352+log = logging.getLogger()
2353
2354
2355 def get_service(verb):
2356@@ -175,9 +184,10 @@
2357 self.group = None
2358 self.pki = pki
2359 self.client_mode = client_mode
2360- self.id = (pki.machine.id if client_mode else pki.user.id)
2361- self.cert_file = pki.verify_ca(self.id)
2362- self.key_file = pki.verify_key(self.id)
2363+ ca = (pki.machine if client_mode else pki.user)
2364+ self.id = ca.id
2365+ self.cert_file = ca.ca_file
2366+ self.key_file = ca.key_file
2367 self.state = State()
2368 self.peer = None
2369 self.info = None
2370@@ -192,7 +202,7 @@
2371 raise Exception(
2372 'Cannot activate {!r} from {!r}'.format(peer_id, self.state)
2373 )
2374- log.info('Activated session with %r', self.peer)
2375+ log.info('Peering: activated session with %r', self.peer)
2376 assert self.state.state == 'activated'
2377 assert self.state.peer_id == peer_id
2378 assert self.peer.id == peer_id
2379@@ -206,7 +216,7 @@
2380 raise Exception(
2381 'Cannot deactivate {!r} from {!r}'.format(peer_id, self.state)
2382 )
2383- log.info('Deactivated session with %r', self.peer)
2384+ log.info('Peering: deactivated session with %r', self.peer)
2385 assert self.state.state == 'deactivated'
2386 assert self.state.peer_id == peer_id
2387 assert self.peer.id == peer_id
2388@@ -222,21 +232,21 @@
2389 def unbind(self, peer_id):
2390 retract = (self.state.state == 'verified')
2391 if not self.state.unbind(peer_id):
2392- log.error('Cannot unbind %s from %r', peer_id, self.state)
2393+ log.error('Peering: cannot unbind %s from %r', peer_id, self.state)
2394 return
2395- log.info('Unbound from %s', peer_id)
2396+ log.info('Peering: unbound from %s', peer_id)
2397 assert self.state.peer_id == peer_id
2398 assert self.state.state == 'unbound'
2399 if retract:
2400- log.info("Firing 'retract' signal")
2401+ log.info("Peering: firing 'retract' signal")
2402 self.emit('retract')
2403 GObject.timeout_add(10 * 1000, self.on_timeout, peer_id)
2404
2405 def on_timeout(self, peer_id):
2406 if not self.state.free(peer_id):
2407- log.error('Cannot free %s from %r', peer_id, self.state)
2408+ log.error('Peering: cannot free %s from %r', peer_id, self.state)
2409 return
2410- log.info('Rate-limiting timeout reached, freeing from %s', peer_id)
2411+ log.info('Peering: rate-limiting timeout reached, freeing from %s', peer_id)
2412 assert self.state.state == 'free'
2413 assert self.state.peer_id is None
2414 self.info = None
2415@@ -277,7 +287,7 @@
2416 )
2417 )
2418 log.info(
2419- 'Publishing %s for %r on port %s', self.id, service, port
2420+ 'Peering: publishing %s for %r on port %s', self.id, service, port
2421 )
2422 self.group.AddService(
2423 -1, # Interface
2424@@ -295,14 +305,14 @@
2425
2426 def unpublish(self):
2427 if self.group is not None:
2428- log.info('Un-publishing %s', self.id)
2429+ log.info('Peering: unpublishing %s', self.id)
2430 self.group.Reset(dbus_interface='org.freedesktop.Avahi.EntryGroup')
2431 self.group = None
2432
2433 def browse(self):
2434 verb = ('accept' if self.client_mode else 'offer')
2435 service = get_service(verb)
2436- log.info('Browsing for %r', service)
2437+ log.info('Peering: browsing for %r', service)
2438 path = self.avahi.ServiceBrowserNew(
2439 -1, # Interface
2440 PROTO, # Protocol -1 = both, 0 = ipv4, 1 = ipv6
2441@@ -316,10 +326,10 @@
2442 self.browser.connect_to_signal('ItemRemove', self.on_ItemRemove)
2443
2444 def on_ItemNew(self, interface, protocol, peer_id, _type, domain, flags):
2445- log.info('Peer added: %s', peer_id)
2446+ log.info('Peering: peer added: %s', peer_id)
2447 if not self.state.bind(str(peer_id)):
2448- log.error('Cannot bind %s from %r', peer_id, self.state)
2449- log.warning('Possible attack from %s', peer_id)
2450+ log.error('Peering: cannot bind %s from %r', peer_id, self.state)
2451+ log.warning('Peering: possible attack from %s', peer_id)
2452 return
2453 assert self.state.state == 'bound'
2454 assert self.state.peer_id == peer_id
2455@@ -342,7 +352,7 @@
2456 (ip, port) = args[7:9]
2457 log.info('%s is at %s, port %s', peer_id, ip, port)
2458 self.peer = Peer(str(peer_id), str(ip), int(port))
2459- _start_thread(self.cert_thread, self.peer)
2460+ start_thread(self.cert_thread, self.peer)
2461
2462 def on_error(self, error):
2463 log.error(
2464@@ -417,3 +427,293 @@
2465 signal = ('accept' if self.client_mode else 'offer')
2466 log.info('Firing %r signal for %r', signal, info)
2467 self.emit(signal, info)
2468+
2469+
2470+class ServerUI(BaseUI):
2471+ page = 'server.html'
2472+
2473+ signals = {
2474+ 'get_secret': [],
2475+ 'display_secret': ['secret'],
2476+ 'set_message': ['message'],
2477+ 'done': [],
2478+ }
2479+
2480+ def __init__(self, cr):
2481+ super().__init__()
2482+ self.cr = cr
2483+
2484+ def connect_hub_signals(self, hub):
2485+ hub.connect('get_secret', self.on_get_secret)
2486+
2487+ def on_get_secret(self, hub):
2488+ secret = self.cr.get_secret()
2489+ hub.send('display_secret', secret)
2490+
2491+
2492+class ServerSession:
2493+ def __init__(self, pki, _id, peer, server_config, client_config):
2494+ self.pki = pki
2495+ self.peer_id = peer.id
2496+ self.peer = peer
2497+ self.cr = ChallengeResponse(_id, peer.id)
2498+ self.q = Queue()
2499+ start_thread(self.monitor_response)
2500+ self.app = ServerApp(self.cr, self.q, pki)
2501+ self.app.state = 'info'
2502+ self.httpd = make_server(self.app, '0.0.0.0', server_config)
2503+ env = {'url': peer.url, 'ssl': client_config}
2504+ self.client = CouchBase(env)
2505+ self.httpd.start()
2506+ self.ui = ServerUI(self.cr)
2507+
2508+ def monitor_response(self):
2509+ while True:
2510+ signal = self.q.get()
2511+ if signal == 'wrong_response':
2512+ GObject.idle_add(self.retry)
2513+ elif signal == 'response_ok':
2514+ GObject.timeout_add(500, self.on_response_ok)
2515+ break
2516+
2517+ def monitor_cert_request(self):
2518+ status = self.q.get()
2519+ if status != 'cert_issued':
2520+ log.error('Bad cert request from %r', self.peer)
2521+ log.warning('Possible malicious peer: %r', self.peer)
2522+ GObject.idle_add(self.on_cert_request, status)
2523+
2524+ def retry(self):
2525+ self.httpd.shutdown()
2526+ secret = self.cr.get_secret()
2527+ self.ui.hub.send('display_secret', secret)
2528+ self.ui.hub.send('set_message',
2529+ _('Typo? Please try again with new secret.')
2530+ )
2531+ self.app.state = 'ready'
2532+ self.httpd.start()
2533+
2534+ def on_response_ok(self):
2535+ assert self.app.state == 'response_ok'
2536+ self.ui.hub.send('set_message', _('Counter-Challenge...'))
2537+ start_thread(self.counter_challenge)
2538+
2539+ def counter_challenge(self):
2540+ log.info('Getting counter-challenge from %r', self.peer)
2541+ challenge = self.client.get('challenge')['challenge']
2542+ (nonce, response) = self.cr.create_response(challenge)
2543+ obj = {'nonce': nonce, 'response': response}
2544+ log.info('Posting counter-response to %r', self.peer)
2545+ try:
2546+ r = self.client.post(obj, 'response')
2547+ log.info('Counter-response accepted')
2548+ GObject.idle_add(self.on_counter_response_ok)
2549+ except Unauthorized:
2550+ log.error('Counter-response rejected!')
2551+ log.warning('Possible malicious peer: %r', self.peer)
2552+ GObject.idle_add(self.on_counter_response_fail)
2553+
2554+ def on_counter_response_ok(self):
2555+ assert self.app.state == 'response_ok'
2556+ self.app.state = 'counter_response_ok'
2557+ start_thread(self.monitor_cert_request)
2558+ self.ui.hub.send('set_message', _('Issuing Certificate...'))
2559+
2560+ def on_counter_response_fail(self):
2561+ self.ui.hub.send('set_message', _('Very Bad Things!'))
2562+
2563+ def on_cert_request(self, status):
2564+ if status == 'cert_issued':
2565+ self.ui.hub.send('set_message', _('Done!'))
2566+ GObject.timeout_add(250, self.ui.hub.send, 'done')
2567+ else:
2568+ self.ui.hub.send('set_message', _('Security Problems in CSR!'))
2569+
2570+
2571+
2572+class Browser:
2573+ def __init__(self, couch):
2574+ self.couch = couch
2575+ self.avahi = AvahiPeer(couch.pki)
2576+ self.avahi.connect('offer', self.on_offer)
2577+ self.avahi.connect('retract', self.on_retract)
2578+ self.avahi.browse()
2579+ self.notifymanager = NotifyManager()
2580+ self.indicator = None
2581+ self.session = None
2582+
2583+ def free(self):
2584+ self.avahi.unpublish()
2585+
2586+ def on_offer(self, avahi, info):
2587+ assert self.indicator is None
2588+ self.indicator = AppIndicator3.Indicator.new(
2589+ 'dmedia-peer',
2590+ 'indicator-novacut',
2591+ AppIndicator3.IndicatorCategory.APPLICATION_STATUS
2592+ )
2593+ menu = Gtk.Menu()
2594+ accept = Gtk.MenuItem()
2595+ accept.set_label(_('Accept {}@{}').format(info.name, info.host))
2596+ accept.connect('activate', self.on_accept, info)
2597+ menu.append(accept)
2598+ menu.show_all()
2599+ self.indicator.set_menu(menu)
2600+ self.indicator.set_status(AppIndicator3.IndicatorStatus.ATTENTION)
2601+ self.notifymanager.replace(
2602+ _('Novacut Peering Offer'),
2603+ '{}@{}'.format(info.name, info.host),
2604+ )
2605+
2606+ def on_retract(self, avahi):
2607+ if self.indicator is not None:
2608+ self.indicator = None
2609+ self.notifymanager.replace(_('Peering Offer Removed'))
2610+
2611+ def on_accept(self, menuitem, info):
2612+ assert self.session is None
2613+ self.avahi.activate(info.id)
2614+ self.indicator = None
2615+ self.session = ServerSession(self.couch.pki, self.avahi.id, info,
2616+ self.avahi.get_server_config(),
2617+ self.avahi.get_client_config()
2618+ )
2619+ self.session.ui.window.connect('delete-event', self.on_delete_event)
2620+ self.session.ui.hub.connect('done', self.on_delete_event)
2621+ self.session.ui.window.show_all()
2622+ self.avahi.publish(self.session.httpd.port)
2623+
2624+ def on_delete_event(self, *args):
2625+ self.session.httpd.shutdown()
2626+ self.session.ui.window.destroy()
2627+ self.avahi.unpublish()
2628+ self.avahi.deactivate(self.session.peer_id)
2629+ self.session = None
2630+
2631+
2632+class Publisher:
2633+ def __init__(self, service, couch):
2634+ self.service = service
2635+ self.couch = couch
2636+ self.thread = None
2637+ self.avahi = None
2638+
2639+ def __del__(self):
2640+ self.free()
2641+
2642+ def free(self):
2643+ if self.avahi is not None:
2644+ self.avahi.unpublish()
2645+ self.avahi = None
2646+ del self.service
2647+ del self.couch
2648+
2649+ def run(self):
2650+ self.couch.load_pki()
2651+ self.avahi = AvahiPeer(self.couch.pki, client_mode=True)
2652+ self.avahi.connect('accept', self.on_accept)
2653+ app = InfoApp(self.avahi.id)
2654+ self.httpd = make_server(app, '0.0.0.0',
2655+ self.avahi.get_server_config()
2656+ )
2657+ self.httpd.start()
2658+ self.avahi.browse()
2659+ self.avahi.publish(self.httpd.port)
2660+
2661+ def on_accept(self, avahi, peer):
2662+ log.info('Publisher.on_accept()')
2663+ self.avahi.activate(peer.id)
2664+ self.peer = peer
2665+ self.cr = ChallengeResponse(avahi.id, peer.id)
2666+ self.q = Queue()
2667+ self.app = ClientApp(self.cr, self.q)
2668+ # Reconfigure HTTPD to only accept connections from bound peer
2669+ self.httpd.reconfigure(self.app, avahi.get_server_config())
2670+ env = {'url': peer.url, 'ssl': avahi.get_client_config()}
2671+ self.client = CouchBase(env)
2672+ avahi.unpublish()
2673+ self.service.Accept()
2674+
2675+ def set_secret(self, secret):
2676+ if self.thread is not None:
2677+ return False
2678+ self.cr.set_secret(secret)
2679+ self.thread = start_thread(self.challenge)
2680+ self.service.Message(_('Challenge...'))
2681+ return True
2682+
2683+ def challenge(self):
2684+ log.info('Getting challenge from %r', self.peer)
2685+ challenge = self.client.get('challenge')['challenge']
2686+ (nonce, response) = self.cr.create_response(challenge)
2687+ obj = {'nonce': nonce, 'response': response}
2688+ log.info('Posting response to %r', self.peer)
2689+ try:
2690+ r = self.client.post(obj, 'response')
2691+ log.info('Response accepted')
2692+ success = True
2693+ except Unauthorized:
2694+ log.info('Response rejected')
2695+ success = False
2696+ GObject.idle_add(self.on_response, success)
2697+
2698+ def on_response(self, success):
2699+ self.thread.join()
2700+ self.thread = None
2701+ if success:
2702+ self.app.state = 'ready'
2703+ self.thread = start_thread(self.monitor_counter_response)
2704+ self.service.Message(_('Counter-Challenge...'))
2705+ else:
2706+ self.service.Message(_('Typo? Please try again with new secret.'))
2707+ self.service.Response(success)
2708+
2709+ def monitor_counter_response(self):
2710+ # FIXME: Should use a timeout with queue.get()
2711+ status = self.q.get()
2712+ log.info('Counter-response gave %r', status)
2713+ if status != 'response_ok':
2714+ log.error('Wrong counter-response!')
2715+ log.warning('Possible malicious peer: %r', self.peer)
2716+ GObject.timeout_add(500, self.on_counter_response, status)
2717+
2718+ def on_counter_response(self, status):
2719+ self.thread.join()
2720+ self.thread = None
2721+ assert self.app.state == status
2722+ if status == 'response_ok':
2723+ self.thread = start_thread(self.request_cert)
2724+ self.service.Message(_('Requesting Certificate...'))
2725+ else:
2726+ self.service.Message(_('Scary! Counter-Challenge Failed!'))
2727+
2728+ def request_cert(self):
2729+ log.info('Creating CSR')
2730+ success = False
2731+ try:
2732+ self.couch.pki.create_csr(self.cr.id)
2733+ csr_data = self.couch.pki.read_csr(self.cr.id)
2734+ obj = {
2735+ 'csr': b64encode(csr_data).decode('utf-8'),
2736+ 'mac': self.cr.csr_mac(csr_data),
2737+ }
2738+ d = self.client.post(obj, 'csr')
2739+ cert_data = b64decode(d['cert'].encode('utf-8'))
2740+ self.cr.check_cert_mac(cert_data, d['mac'])
2741+ self.couch.pki.write_cert(self.cr.id, self.cr.peer_id, cert_data)
2742+ self.couch.pki.verify_cert(self.cr.id, self.cr.peer_id)
2743+ key_data = b64decode(d['key'].encode('utf-8'))
2744+ self.couch.pki.write_key(self.cr.peer_id, key_data)
2745+ self.couch.pki.verify_key(self.cr.peer_id)
2746+ success = True
2747+ except Exception as e:
2748+ log.exception('Could not request cert')
2749+ GObject.idle_add(self.on_csr_response, success)
2750+
2751+ def on_csr_response(self, success):
2752+ self.thread.join()
2753+ self.thread = None
2754+ if success:
2755+ self.service.set_user(self.cr.peer_id)
2756+ else:
2757+ self.service.Message(_('Scary! Certificate Request Failed!'))
2758
2759=== modified file 'dmedia/service/tests/test_avahi.py'
2760--- dmedia/service/tests/test_avahi.py 2012-10-04 20:23:22 +0000
2761+++ dmedia/service/tests/test_avahi.py 2012-10-16 12:35:25 +0000
2762@@ -23,32 +23,66 @@
2763 Unit tests for `dmedia.service.avahi`.
2764 """
2765
2766-from unittest import TestCase
2767+from random import SystemRandom
2768 from copy import deepcopy
2769
2770-from microfiber import random_id
2771-from usercouch import random_oauth
2772+import microfiber
2773
2774+from dmedia.tests.couch import CouchCase
2775 from dmedia.service import avahi
2776
2777
2778-class TestAvahi(TestCase):
2779+random = SystemRandom()
2780+
2781+
2782+def random_port():
2783+ return random.randint(1001, 50000)
2784+
2785+
2786+class TestAvahi(CouchCase):
2787 def test_init(self):
2788- _id = random_id()
2789- inst = avahi.Avahi(_id, 42)
2790- self.assertEqual(inst.id, _id)
2791- self.assertEqual(inst.port, 42)
2792+ db = microfiber.Database('dmedia-0', self.env)
2793+ self.assertTrue(db.ensure())
2794+ port = random_port()
2795+ ssl_config = 'the SSL config'
2796+ inst = avahi.Avahi(self.env, port, ssl_config)
2797 self.assertIsNone(inst.group)
2798-
2799- def test_add_peer(self):
2800- inst = avahi.Avahi('the id', 42)
2801- with self.assertRaises(NotImplementedError) as cm:
2802- inst.add_peer('key', 'url')
2803- self.assertEqual(str(cm.exception), 'Avahi.add_peer()')
2804-
2805- def test_remove_peer(self):
2806- inst = avahi.Avahi('the id', 42)
2807- with self.assertRaises(NotImplementedError) as cm:
2808- inst.remove_peer('key')
2809- self.assertEqual(str(cm.exception), 'Avahi.remove_peer()')
2810+ self.assertIs(inst.machine_id, self.machine_id)
2811+ self.assertIs(inst.user_id, self.user_id)
2812+ self.assertIs(inst.port, port)
2813+ self.assertIs(inst.ssl_config, ssl_config)
2814+ self.assertIsInstance(inst.db, microfiber.Database)
2815+ self.assertIsInstance(inst.server, microfiber.Server)
2816+ self.assertIs(inst.db.ctx, inst.server.ctx)
2817+ self.assertEqual(inst.replications, {})
2818+ self.assertEqual(inst.peers,
2819+ {
2820+ '_id': '_local/peers',
2821+ '_rev': '0-1',
2822+ 'peers': {},
2823+ }
2824+ )
2825+ self.assertEqual(db.get('_local/peers'), inst.peers)
2826+
2827+ peers = deepcopy(inst.peers)
2828+ peers['peers'] = {'foo': 'bar'}
2829+ db.save(peers)
2830+ inst = avahi.Avahi(self.env, port, ssl_config)
2831+ self.assertEqual(inst.peers,
2832+ {
2833+ '_id': '_local/peers',
2834+ '_rev': '0-3',
2835+ 'peers': {},
2836+ }
2837+ )
2838+ inst = avahi.Avahi(self.env, port, ssl_config)
2839+ self.assertEqual(inst.peers,
2840+ {
2841+ '_id': '_local/peers',
2842+ '_rev': '0-3',
2843+ 'peers': {},
2844+ }
2845+ )
2846+
2847+ inst.__del__()
2848
2849
2850=== modified file 'dmedia/startup.py'
2851--- dmedia/startup.py 2012-10-03 19:45:56 +0000
2852+++ dmedia/startup.py 2012-10-16 12:35:25 +0000
2853@@ -79,29 +79,6 @@
2854 }
2855
2856
2857-def get_ssl_config(pki):
2858- assert isinstance(pki, PKI)
2859- if pki.user is None:
2860- return None
2861- return {
2862- 'check_hostname': False,
2863- 'max_depth': 1,
2864- 'ca_file': pki.user.ca_file,
2865- 'cert_file': pki.machine.cert_file,
2866- 'key_file': pki.machine.key_file,
2867- }
2868-
2869-
2870-def get_bootstrap_config(pki):
2871- assert isinstance(pki, PKI)
2872- if pki.user is None:
2873- return {'username': 'admin'}
2874- return {
2875- 'username': 'admin',
2876- 'replicator': get_ssl_config(pki),
2877- }
2878-
2879-
2880 class DmediaCouch(UserCouch):
2881 def __init__(self, basedir):
2882 super().__init__(basedir)
2883@@ -116,44 +93,67 @@
2884 save_config(path.join(self.basedir, name + '.json'), config)
2885
2886 def isfirstrun(self):
2887- return self.machine is None
2888+ return self.user is None
2889
2890- def firstrun_init(self, create_user=False):
2891- if not self.isfirstrun():
2892- raise Exception('not first run, cannot call firstrun_init()')
2893+ def create_machine(self):
2894+ if self.machine is not None:
2895+ raise Exception('machine already exists')
2896 log.info('Creating RSA machine identity...')
2897 machine_id = self.pki.create_key()
2898 log.info('... machine_id: %s', machine_id)
2899 self.pki.create_ca(machine_id)
2900- if create_user:
2901- if self.user is None:
2902- log.info('Creating RSA user identity...')
2903- user_id = self.pki.create_key()
2904- log.info('... user_id: %s', user_id)
2905- self.pki.create_ca(user_id)
2906- doc = create_doc(user_id, 'dmedia/user')
2907- self.save_config('user', doc)
2908- self.user = self.load_config('user')
2909- else:
2910- user_id = self.user['_id']
2911- self.pki.create_csr(machine_id)
2912- self.pki.issue_cert(machine_id, user_id)
2913 doc = create_doc(machine_id, 'dmedia/machine')
2914 self.save_config('machine', doc)
2915 self.machine = self.load_config('machine')
2916+ return machine_id
2917+
2918+ def create_user(self):
2919+ if self.machine is None:
2920+ raise Exception('must create machine first')
2921+ if self.user is not None:
2922+ raise Exception('user already exists')
2923+ log.info('Creating RSA user identity...')
2924+ user_id = self.pki.create_key()
2925+ log.info('... user_id: %s', user_id)
2926+ self.pki.create_ca(user_id)
2927+ self.pki.create_csr(self.machine['_id'])
2928+ self.pki.issue_cert(self.machine['_id'], user_id)
2929+ doc = create_doc(user_id, 'dmedia/user')
2930+ self.save_config('user', doc)
2931+ self.user = self.load_config('user')
2932+ return user_id
2933+
2934+ def set_user(self, user_id):
2935+ log.info('... user_id: %s', user_id)
2936+ doc = create_doc(user_id, 'dmedia/user')
2937+ self.save_config('user', doc)
2938+ self.user = self.load_config('user')
2939
2940 def load_pki(self):
2941- if self.machine is None:
2942- return
2943 if self.user is None:
2944 self.pki.load_pki(self.machine['_id'])
2945 else:
2946 self.pki.load_pki(self.machine['_id'], self.user['_id'])
2947
2948+ def get_ssl_config(self):
2949+ return {
2950+ 'check_hostname': False,
2951+ 'max_depth': 1,
2952+ 'ca_file': self.pki.user.ca_file,
2953+ 'cert_file': self.pki.machine.cert_file,
2954+ 'key_file': self.pki.machine.key_file,
2955+ }
2956+
2957+ def get_bootstrap_config(self):
2958+ return {
2959+ 'username': 'admin',
2960+ 'replicator': self.get_ssl_config(),
2961+ }
2962+
2963 def auto_bootstrap(self):
2964+ assert self.user is not None
2965+ assert self.machine is not None
2966 self.load_pki()
2967- config = get_bootstrap_config(self.pki)
2968+ config = self.get_bootstrap_config()
2969 return self.bootstrap('basic', config)
2970
2971- def get_ssl_config(self):
2972- return get_ssl_config(self.pki)
2973
2974=== modified file 'dmedia/tests/couch.py'
2975--- dmedia/tests/couch.py 2011-10-04 14:00:22 +0000
2976+++ dmedia/tests/couch.py 2012-10-16 12:35:25 +0000
2977@@ -38,6 +38,8 @@
2978
2979 def setUp(self):
2980 super().setUp()
2981- self.machine_id = random_id()
2982+ self.machine_id = random_id(30)
2983+ self.user_id = random_id(30)
2984 self.env['machine_id'] = self.machine_id
2985+ self.env['user_id'] = self.user_id
2986
2987
2988=== modified file 'dmedia/tests/test_core.py'
2989--- dmedia/tests/test_core.py 2012-10-04 18:04:14 +0000
2990+++ dmedia/tests/test_core.py 2012-10-16 12:35:25 +0000
2991@@ -63,71 +63,14 @@
2992 self.assertEqual(inst.local, {'_id': '_local/dmedia', 'stores': {}})
2993
2994 def test_load_identity(self):
2995- id1 = random_id()
2996- inst = core.Core(self.env)
2997- inst.load_identity({'_id': id1})
2998- doc = inst.db.get(id1)
2999- self.assertEqual(set(doc), set(['_id', '_rev']))
3000- self.assertTrue(doc['_rev'].startswith('1-'))
3001- self.assertEqual(
3002- inst.db.get('_local/dmedia'),
3003- {
3004- '_id': '_local/dmedia',
3005- '_rev': '0-1',
3006- 'stores': {},
3007- 'machine_id': id1,
3008- }
3009- )
3010- self.assertEqual(inst.local, inst.db.get('_local/dmedia'))
3011- self.assertEqual(self.env['machine_id'], id1)
3012-
3013- inst.load_identity({'_id': id1})
3014- doc = inst.db.get(id1)
3015- self.assertEqual(set(doc), set(['_id', '_rev']))
3016- self.assertTrue(doc['_rev'].startswith('1-'))
3017- self.assertEqual(
3018- inst.db.get('_local/dmedia'),
3019- {
3020- '_id': '_local/dmedia',
3021- '_rev': '0-1',
3022- 'stores': {},
3023- 'machine_id': id1,
3024- }
3025- )
3026- self.assertEqual(inst.local, inst.db.get('_local/dmedia'))
3027- self.assertEqual(self.env['machine_id'], id1)
3028-
3029- id2 = random_id()
3030- inst = core.Core(self.env)
3031- inst.load_identity({'_id': id2})
3032- doc = inst.db.get(id2)
3033- self.assertEqual(set(doc), set(['_id', '_rev']))
3034- self.assertTrue(doc['_rev'].startswith('1-'))
3035- self.assertEqual(
3036- inst.db.get('_local/dmedia'),
3037- {
3038- '_id': '_local/dmedia',
3039- '_rev': '0-2',
3040- 'stores': {},
3041- 'machine_id': id2,
3042- }
3043- )
3044- self.assertEqual(inst.local, inst.db.get('_local/dmedia'))
3045- self.assertEqual(self.env['machine_id'], id2)
3046-
3047- def test_load_identity2(self):
3048- """
3049- Test load_identity() with a user.
3050- """
3051- machine_id = random_id()
3052- user_id = random_id()
3053+ machine_id = random_id(30)
3054+ user_id = random_id(30)
3055 inst = core.Core(self.env)
3056 inst.load_identity({'_id': machine_id}, {'_id': user_id})
3057
3058 machine = inst.db.get(machine_id)
3059 self.assertEqual(set(machine), set(['_id', '_rev']))
3060 self.assertTrue(machine['_rev'].startswith('1-'))
3061-
3062 user = inst.db.get(user_id)
3063 self.assertEqual(set(user), set(['_id', '_rev']))
3064 self.assertTrue(user['_rev'].startswith('1-'))
3065@@ -147,6 +90,24 @@
3066 self.assertEqual(self.env['machine_id'], machine_id)
3067 self.assertEqual(self.env['user_id'], user_id)
3068
3069+ inst = core.Core(self.env)
3070+ inst.load_identity({'_id': machine_id}, {'_id': user_id})
3071+ self.assertTrue(inst.db.get(machine_id)['_rev'].startswith('1-'))
3072+ self.assertTrue(inst.db.get(user_id)['_rev'].startswith('1-'))
3073+
3074+ self.assertEqual(set(machine), set(['_id', '_rev']))
3075+ self.assertTrue(machine['_rev'].startswith('1-'))
3076+ self.assertEqual(
3077+ inst.db.get('_local/dmedia'),
3078+ {
3079+ '_id': '_local/dmedia',
3080+ '_rev': '0-1',
3081+ 'stores': {},
3082+ 'machine_id': machine_id,
3083+ 'user_id': user_id,
3084+ }
3085+ )
3086+
3087 def test_init_default_store(self):
3088 private = TempDir()
3089 shared = TempDir()
3090
3091=== modified file 'dmedia/tests/test_peering.py'
3092--- dmedia/tests/test_peering.py 2012-10-09 03:07:15 +0000
3093+++ dmedia/tests/test_peering.py 2012-10-16 12:35:25 +0000
3094@@ -25,207 +25,191 @@
3095
3096 from unittest import TestCase
3097 import os
3098-from os import path
3099+from os import path, urandom
3100 import subprocess
3101 import socket
3102-from queue import Queue
3103
3104-import microfiber
3105-from microfiber import random_id, CouchBase
3106+from microfiber import random_id
3107+from skein import skein512
3108
3109 from .base import TempDir
3110-from dmedia.httpd import make_server
3111 from dmedia.peering import encode, decode
3112 from dmedia import peering
3113
3114
3115-class TestSSLFunctions(TestCase):
3116- def test_create_key(self):
3117- tmp = TempDir()
3118- key = tmp.join('key.pem')
3119-
3120- # bits=1024
3121- sizes = [883, 887, 891]
3122- peering.create_key(key, bits=1024)
3123- self.assertLess(min(sizes) - 25, path.getsize(key))
3124- self.assertLess(path.getsize(key), max(sizes) + 25)
3125- os.remove(key)
3126-
3127- # bits=2048 (default)
3128- sizes = [1671, 1675, 1679]
3129- peering.create_key(key)
3130- self.assertLess(min(sizes) - 25, path.getsize(key))
3131- self.assertLess(path.getsize(key), max(sizes) + 25)
3132- os.remove(key)
3133-
3134- peering.create_key(key, bits=2048)
3135- self.assertLess(min(sizes) - 25, path.getsize(key))
3136- self.assertLess(path.getsize(key), max(sizes) + 25)
3137- os.remove(key)
3138-
3139- # bits=3072
3140- sizes = [2455, 2459]
3141- peering.create_key(key, bits=3072)
3142- self.assertLess(min(sizes) - 25, path.getsize(key))
3143- self.assertLess(path.getsize(key), max(sizes) + 25)
3144-
3145- def test_create_ca(self):
3146- tmp = TempDir()
3147- foo_key = tmp.join('foo.key')
3148- foo_ca = tmp.join('foo.ca')
3149- peering.create_key(foo_key)
3150- self.assertFalse(path.exists(foo_ca))
3151- peering.create_ca(foo_key, '/CN=foo', foo_ca)
3152- self.assertGreater(path.getsize(foo_ca), 0)
3153-
3154- def test_create_csr(self):
3155- tmp = TempDir()
3156- bar_key = tmp.join('bar.key')
3157- bar_csr = tmp.join('bar.csr')
3158- peering.create_key(bar_key)
3159- self.assertFalse(path.exists(bar_csr))
3160- peering.create_csr(bar_key, '/CN=bar', bar_csr)
3161- self.assertGreater(path.getsize(bar_csr), 0)
3162-
3163- def test_issue_cert(self):
3164- tmp = TempDir()
3165-
3166- foo_key = tmp.join('foo.key')
3167- foo_ca = tmp.join('foo.ca')
3168- foo_srl = tmp.join('foo.srl')
3169- peering.create_key(foo_key)
3170- peering.create_ca(foo_key, '/CN=foo', foo_ca)
3171-
3172- bar_key = tmp.join('bar.key')
3173- bar_csr = tmp.join('bar.csr')
3174- bar_cert = tmp.join('bar.cert')
3175- peering.create_key(bar_key)
3176- peering.create_csr(bar_key, '/CN=bar', bar_csr)
3177-
3178- files = (foo_srl, bar_cert)
3179- for f in files:
3180- self.assertFalse(path.exists(f))
3181- peering.issue_cert(bar_csr, foo_ca, foo_key, foo_srl, bar_cert)
3182- for f in files:
3183- self.assertGreater(path.getsize(f), 0)
3184-
3185- def test_get_pubkey(self):
3186- tmp = TempDir()
3187-
3188- # Create CA
3189- foo_key = tmp.join('foo.key')
3190- foo_ca = tmp.join('foo.ca')
3191- foo_srl = tmp.join('foo.srl')
3192- peering.create_key(foo_key)
3193- foo_pubkey = peering.get_rsa_pubkey(foo_key)
3194- peering.create_ca(foo_key, '/CN=foo', foo_ca)
3195-
3196- # Create CSR and issue cert
3197- bar_key = tmp.join('bar.key')
3198- bar_csr = tmp.join('bar.csr')
3199- bar_cert = tmp.join('bar.cert')
3200- peering.create_key(bar_key)
3201- bar_pubkey = peering.get_rsa_pubkey(bar_key)
3202- peering.create_csr(bar_key, '/CN=bar', bar_csr)
3203- peering.issue_cert(bar_csr, foo_ca, foo_key, foo_srl, bar_cert)
3204-
3205- # Now compare
3206- os.remove(foo_key)
3207- os.remove(bar_key)
3208- self.assertEqual(peering.get_pubkey(foo_ca), foo_pubkey)
3209- self.assertEqual(peering.get_csr_pubkey(bar_csr), bar_pubkey)
3210- self.assertEqual(peering.get_pubkey(bar_cert), bar_pubkey)
3211-
3212- def test_get_subject(self):
3213- tmp = TempDir()
3214-
3215- foo_subject = '/CN={}'.format(random_id(30))
3216- foo_key = tmp.join('foo.key')
3217- foo_ca = tmp.join('foo.ca')
3218- foo_srl = tmp.join('foo.srl')
3219- peering.create_key(foo_key)
3220- peering.create_ca(foo_key, foo_subject, foo_ca)
3221- self.assertEqual(peering.get_subject(foo_ca), foo_subject)
3222-
3223- bar_subject = '/CN={}'.format(random_id(30))
3224- bar_key = tmp.join('bar.key')
3225- bar_csr = tmp.join('bar.csr')
3226- bar_cert = tmp.join('bar.cert')
3227- peering.create_key(bar_key)
3228- peering.create_csr(bar_key, bar_subject, bar_csr)
3229- peering.issue_cert(bar_csr, foo_ca, foo_key, foo_srl, bar_cert)
3230- self.assertEqual(peering.get_csr_subject(bar_csr), bar_subject)
3231- self.assertEqual(peering.get_subject(bar_cert), bar_subject)
3232-
3233- def test_get_csr_subject(self):
3234- tmp = TempDir()
3235- subject = '/CN={}'.format(random_id(30))
3236- key_file = tmp.join('foo.key')
3237- csr_file = tmp.join('foo.csr')
3238- peering.create_key(key_file)
3239- peering.create_csr(key_file, subject, csr_file)
3240- os.remove(key_file)
3241- self.assertEqual(peering.get_csr_subject(csr_file), subject)
3242-
3243- def test_get_issuer(self):
3244- tmp = TempDir()
3245-
3246- foo_subject = '/CN={}'.format(random_id(30))
3247- foo_key = tmp.join('foo.key')
3248- foo_ca = tmp.join('foo.ca')
3249- foo_srl = tmp.join('foo.srl')
3250- peering.create_key(foo_key)
3251- peering.create_ca(foo_key, foo_subject, foo_ca)
3252- self.assertEqual(peering.get_issuer(foo_ca), foo_subject)
3253-
3254- bar_subject = '/CN={}'.format(random_id(30))
3255- bar_key = tmp.join('bar.key')
3256- bar_csr = tmp.join('bar.csr')
3257- bar_cert = tmp.join('bar.cert')
3258- peering.create_key(bar_key)
3259- peering.create_csr(bar_key, bar_subject, bar_csr)
3260- peering.issue_cert(bar_csr, foo_ca, foo_key, foo_srl, bar_cert)
3261- self.assertEqual(peering.get_csr_subject(bar_csr), bar_subject)
3262- self.assertEqual(peering.get_issuer(bar_cert), foo_subject)
3263-
3264- def test_ssl_verify(self):
3265- tmp = TempDir()
3266- pki = peering.PKI(tmp.dir)
3267-
3268- ca1 = pki.create_key()
3269- pki.create_ca(ca1)
3270- cert1 = pki.create_key()
3271- pki.create_csr(cert1)
3272- pki.issue_cert(cert1, ca1)
3273- ca1_file = pki.path(ca1, 'ca')
3274- cert1_file = pki.path(cert1, 'cert')
3275- self.assertEqual(peering.ssl_verify(ca1_file, ca1_file), ca1_file)
3276- self.assertEqual(peering.ssl_verify(cert1_file, ca1_file), cert1_file)
3277- with self.assertRaises(peering.VerificationError) as cm:
3278- peering.ssl_verify(ca1_file, cert1_file)
3279- with self.assertRaises(peering.VerificationError) as cm:
3280- peering.ssl_verify(cert1_file, cert1_file)
3281-
3282- ca2 = pki.create_key()
3283- pki.create_ca(ca2)
3284- cert2 = pki.create_key()
3285- pki.create_csr(cert2)
3286- pki.issue_cert(cert2, ca2)
3287- ca2_file = pki.path(ca2, 'ca')
3288- cert2_file = pki.path(cert2, 'cert')
3289- self.assertEqual(peering.ssl_verify(ca2_file, ca2_file), ca2_file)
3290- self.assertEqual(peering.ssl_verify(cert2_file, ca2_file), cert2_file)
3291- with self.assertRaises(peering.VerificationError) as cm:
3292- peering.ssl_verify(ca2_file, cert2_file)
3293- with self.assertRaises(peering.VerificationError) as cm:
3294- peering.ssl_verify(cert2_file, cert2_file)
3295-
3296- with self.assertRaises(peering.VerificationError) as cm:
3297- peering.ssl_verify(ca2_file, ca1_file)
3298- with self.assertRaises(peering.VerificationError) as cm:
3299- peering.ssl_verify(cert2_file, ca1_file)
3300- with self.assertRaises(peering.VerificationError) as cm:
3301- peering.ssl_verify(cert2_file, cert1_file)
3302+class TestSkeinFunctions(TestCase):
3303+ def test_hash_pubkey(self):
3304+ data = urandom(500)
3305+ _id = peering.hash_pubkey(data)
3306+ self.assertIsInstance(_id, str)
3307+ self.assertEqual(len(_id), 48)
3308+ skein = skein512(data,
3309+ digest_bits=240,
3310+ pers=b'20120918 jderose@novacut.com dmedia/pubkey',
3311+ )
3312+ self.assertEqual(
3313+ decode(_id),
3314+ skein.digest()
3315+ )
3316+
3317+ # Sanity check
3318+ for i in range(1000):
3319+ self.assertNotEqual(_id,
3320+ peering.hash_pubkey(urandom(500)),
3321+ )
3322+
3323+ def test_compute_response(self):
3324+ secret = urandom(5)
3325+ challenge = urandom(20)
3326+ nonce = urandom(20)
3327+ hash1 = urandom(30)
3328+ hash2 = urandom(30)
3329+ response = peering.compute_response(
3330+ secret, challenge, nonce, hash1, hash2
3331+ )
3332+ self.assertIsInstance(response, str)
3333+ self.assertEqual(len(response), 56)
3334+ skein = skein512(hash1 + hash2,
3335+ digest_bits=280,
3336+ pers=b'20120918 jderose@novacut.com dmedia/response',
3337+ key=secret,
3338+ nonce=(challenge + nonce),
3339+ )
3340+ self.assertEqual(
3341+ decode(response),
3342+ skein.digest()
3343+ )
3344+
3345+ # Test with direction reversed
3346+ self.assertNotEqual(response,
3347+ peering.compute_response(secret, challenge, nonce, hash2, hash1)
3348+ )
3349+
3350+ # Test with wrong secret
3351+ for i in range(100):
3352+ self.assertNotEqual(response,
3353+ peering.compute_response(urandom(5), challenge, nonce, hash1, hash2)
3354+ )
3355+
3356+ # Test with wrong challange
3357+ for i in range(100):
3358+ self.assertNotEqual(response,
3359+ peering.compute_response(secret, urandom(20), nonce, hash1, hash2)
3360+ )
3361+
3362+ # Test with wrong nonce
3363+ for i in range(100):
3364+ self.assertNotEqual(response,
3365+ peering.compute_response(secret, challenge, urandom(20), hash1, hash2)
3366+ )
3367+
3368+ # Test with wrong challenger_hash
3369+ for i in range(100):
3370+ self.assertNotEqual(response,
3371+ peering.compute_response(secret, challenge, nonce, urandom(30), hash2)
3372+ )
3373+
3374+ # Test with wrong responder_hash
3375+ for i in range(100):
3376+ self.assertNotEqual(response,
3377+ peering.compute_response(secret, challenge, nonce, hash1, urandom(30))
3378+ )
3379+
3380+ def test_compute_csr_mac(self):
3381+ secret = os.urandom(5)
3382+ remote_hash = os.urandom(30)
3383+ local_hash = os.urandom(30)
3384+ csr_data = os.urandom(500)
3385+ mac = peering.compute_csr_mac(secret, csr_data, remote_hash, local_hash)
3386+ self.assertIsInstance(mac, str)
3387+ self.assertEqual(len(mac), 56)
3388+ skein = skein512(csr_data,
3389+ digest_bits=280,
3390+ pers=b'20120918 jderose@novacut.com dmedia/csr',
3391+ key=secret,
3392+ key_id=(remote_hash + local_hash),
3393+ )
3394+ self.assertEqual(
3395+ decode(mac),
3396+ skein.digest()
3397+ )
3398+
3399+ # Test with direction reversed
3400+ self.assertNotEqual(mac,
3401+ peering.compute_csr_mac(secret, csr_data, local_hash, remote_hash)
3402+ )
3403+
3404+ # Test with wrong secret
3405+ for i in range(100):
3406+ self.assertNotEqual(mac,
3407+ peering.compute_csr_mac(os.urandom(5), csr_data, remote_hash, local_hash)
3408+ )
3409+
3410+ # Test with wrong cert_data
3411+ for i in range(100):
3412+ self.assertNotEqual(mac,
3413+ peering.compute_csr_mac(secret, os.urandom(500), remote_hash, local_hash)
3414+ )
3415+
3416+ # Test with wrong remote_hash
3417+ for i in range(100):
3418+ self.assertNotEqual(mac,
3419+ peering.compute_csr_mac(secret, csr_data, os.urandom(30), local_hash)
3420+ )
3421+
3422+ # Test with wrong local_hash
3423+ for i in range(100):
3424+ self.assertNotEqual(mac,
3425+ peering.compute_csr_mac(secret, csr_data, remote_hash, os.urandom(30))
3426+ )
3427+
3428+ def test_compute_cert_mac(self):
3429+ secret = os.urandom(5)
3430+ remote_hash = os.urandom(30)
3431+ local_hash = os.urandom(30)
3432+ cert_data = os.urandom(500)
3433+ mac = peering.compute_cert_mac(secret, cert_data, remote_hash, local_hash)
3434+ self.assertIsInstance(mac, str)
3435+ self.assertEqual(len(mac), 56)
3436+ skein = skein512(cert_data,
3437+ digest_bits=280,
3438+ pers=b'20120918 jderose@novacut.com dmedia/cert',
3439+ key=secret,
3440+ key_id=(remote_hash + local_hash),
3441+ )
3442+ self.assertEqual(
3443+ decode(mac),
3444+ skein.digest()
3445+ )
3446+
3447+ # Test with direction reversed
3448+ self.assertNotEqual(mac,
3449+ peering.compute_cert_mac(secret, cert_data, local_hash, remote_hash)
3450+ )
3451+
3452+ # Test with wrong secret
3453+ for i in range(100):
3454+ self.assertNotEqual(mac,
3455+ peering.compute_cert_mac(os.urandom(5), cert_data, remote_hash, local_hash)
3456+ )
3457+
3458+ # Test with wrong cert_data
3459+ for i in range(100):
3460+ self.assertNotEqual(mac,
3461+ peering.compute_cert_mac(secret, os.urandom(500), remote_hash, local_hash)
3462+ )
3463+
3464+ # Test with wrong remote_hash
3465+ for i in range(100):
3466+ self.assertNotEqual(mac,
3467+ peering.compute_cert_mac(secret, cert_data, os.urandom(30), local_hash)
3468+ )
3469+
3470+ # Test with wrong local_hash
3471+ for i in range(100):
3472+ self.assertNotEqual(mac,
3473+ peering.compute_cert_mac(secret, cert_data, remote_hash, os.urandom(30))
3474+ )
3475
3476
3477 class TestChallengeResponse(TestCase):
3478@@ -478,133 +462,193 @@
3479 inst.check_response(nonce, response)
3480
3481
3482-class TestServerApp(TestCase):
3483- def test_live(self):
3484+class TestSSLFunctions(TestCase):
3485+ def test_create_key(self):
3486+ tmp = TempDir()
3487+ key = tmp.join('key.pem')
3488+
3489+ # bits=1024
3490+ sizes = [883, 887, 891]
3491+ peering.create_key(key, bits=1024)
3492+ self.assertLess(min(sizes) - 25, path.getsize(key))
3493+ self.assertLess(path.getsize(key), max(sizes) + 25)
3494+ os.remove(key)
3495+
3496+ # bits=2048 (default)
3497+ sizes = [1671, 1675, 1679]
3498+ peering.create_key(key)
3499+ self.assertLess(min(sizes) - 25, path.getsize(key))
3500+ self.assertLess(path.getsize(key), max(sizes) + 25)
3501+ os.remove(key)
3502+
3503+ peering.create_key(key, bits=2048)
3504+ self.assertLess(min(sizes) - 25, path.getsize(key))
3505+ self.assertLess(path.getsize(key), max(sizes) + 25)
3506+ os.remove(key)
3507+
3508+ # bits=3072
3509+ sizes = [2455, 2459]
3510+ peering.create_key(key, bits=3072)
3511+ self.assertLess(min(sizes) - 25, path.getsize(key))
3512+ self.assertLess(path.getsize(key), max(sizes) + 25)
3513+
3514+ def test_create_ca(self):
3515+ tmp = TempDir()
3516+ foo_key = tmp.join('foo.key')
3517+ foo_ca = tmp.join('foo.ca')
3518+ peering.create_key(foo_key)
3519+ self.assertFalse(path.exists(foo_ca))
3520+ peering.create_ca(foo_key, '/CN=foo', foo_ca)
3521+ self.assertGreater(path.getsize(foo_ca), 0)
3522+
3523+ def test_create_csr(self):
3524+ tmp = TempDir()
3525+ bar_key = tmp.join('bar.key')
3526+ bar_csr = tmp.join('bar.csr')
3527+ peering.create_key(bar_key)
3528+ self.assertFalse(path.exists(bar_csr))
3529+ peering.create_csr(bar_key, '/CN=bar', bar_csr)
3530+ self.assertGreater(path.getsize(bar_csr), 0)
3531+
3532+ def test_issue_cert(self):
3533+ tmp = TempDir()
3534+
3535+ foo_key = tmp.join('foo.key')
3536+ foo_ca = tmp.join('foo.ca')
3537+ foo_srl = tmp.join('foo.srl')
3538+ peering.create_key(foo_key)
3539+ peering.create_ca(foo_key, '/CN=foo', foo_ca)
3540+
3541+ bar_key = tmp.join('bar.key')
3542+ bar_csr = tmp.join('bar.csr')
3543+ bar_cert = tmp.join('bar.cert')
3544+ peering.create_key(bar_key)
3545+ peering.create_csr(bar_key, '/CN=bar', bar_csr)
3546+
3547+ files = (foo_srl, bar_cert)
3548+ for f in files:
3549+ self.assertFalse(path.exists(f))
3550+ peering.issue_cert(bar_csr, foo_ca, foo_key, foo_srl, bar_cert)
3551+ for f in files:
3552+ self.assertGreater(path.getsize(f), 0)
3553+
3554+ def test_get_pubkey(self):
3555+ tmp = TempDir()
3556+
3557+ # Create CA
3558+ foo_key = tmp.join('foo.key')
3559+ foo_ca = tmp.join('foo.ca')
3560+ foo_srl = tmp.join('foo.srl')
3561+ peering.create_key(foo_key)
3562+ foo_pubkey = peering.get_rsa_pubkey(foo_key)
3563+ peering.create_ca(foo_key, '/CN=foo', foo_ca)
3564+
3565+ # Create CSR and issue cert
3566+ bar_key = tmp.join('bar.key')
3567+ bar_csr = tmp.join('bar.csr')
3568+ bar_cert = tmp.join('bar.cert')
3569+ peering.create_key(bar_key)
3570+ bar_pubkey = peering.get_rsa_pubkey(bar_key)
3571+ peering.create_csr(bar_key, '/CN=bar', bar_csr)
3572+ peering.issue_cert(bar_csr, foo_ca, foo_key, foo_srl, bar_cert)
3573+
3574+ # Now compare
3575+ os.remove(foo_key)
3576+ os.remove(bar_key)
3577+ self.assertEqual(peering.get_pubkey(foo_ca), foo_pubkey)
3578+ self.assertEqual(peering.get_csr_pubkey(bar_csr), bar_pubkey)
3579+ self.assertEqual(peering.get_pubkey(bar_cert), bar_pubkey)
3580+
3581+ def test_get_subject(self):
3582+ tmp = TempDir()
3583+
3584+ foo_subject = '/CN={}'.format(random_id(30))
3585+ foo_key = tmp.join('foo.key')
3586+ foo_ca = tmp.join('foo.ca')
3587+ foo_srl = tmp.join('foo.srl')
3588+ peering.create_key(foo_key)
3589+ peering.create_ca(foo_key, foo_subject, foo_ca)
3590+ self.assertEqual(peering.get_subject(foo_ca), foo_subject)
3591+
3592+ bar_subject = '/CN={}'.format(random_id(30))
3593+ bar_key = tmp.join('bar.key')
3594+ bar_csr = tmp.join('bar.csr')
3595+ bar_cert = tmp.join('bar.cert')
3596+ peering.create_key(bar_key)
3597+ peering.create_csr(bar_key, bar_subject, bar_csr)
3598+ peering.issue_cert(bar_csr, foo_ca, foo_key, foo_srl, bar_cert)
3599+ self.assertEqual(peering.get_csr_subject(bar_csr), bar_subject)
3600+ self.assertEqual(peering.get_subject(bar_cert), bar_subject)
3601+
3602+ def test_get_csr_subject(self):
3603+ tmp = TempDir()
3604+ subject = '/CN={}'.format(random_id(30))
3605+ key_file = tmp.join('foo.key')
3606+ csr_file = tmp.join('foo.csr')
3607+ peering.create_key(key_file)
3608+ peering.create_csr(key_file, subject, csr_file)
3609+ os.remove(key_file)
3610+ self.assertEqual(peering.get_csr_subject(csr_file), subject)
3611+
3612+ def test_get_issuer(self):
3613+ tmp = TempDir()
3614+
3615+ foo_subject = '/CN={}'.format(random_id(30))
3616+ foo_key = tmp.join('foo.key')
3617+ foo_ca = tmp.join('foo.ca')
3618+ foo_srl = tmp.join('foo.srl')
3619+ peering.create_key(foo_key)
3620+ peering.create_ca(foo_key, foo_subject, foo_ca)
3621+ self.assertEqual(peering.get_issuer(foo_ca), foo_subject)
3622+
3623+ bar_subject = '/CN={}'.format(random_id(30))
3624+ bar_key = tmp.join('bar.key')
3625+ bar_csr = tmp.join('bar.csr')
3626+ bar_cert = tmp.join('bar.cert')
3627+ peering.create_key(bar_key)
3628+ peering.create_csr(bar_key, bar_subject, bar_csr)
3629+ peering.issue_cert(bar_csr, foo_ca, foo_key, foo_srl, bar_cert)
3630+ self.assertEqual(peering.get_csr_subject(bar_csr), bar_subject)
3631+ self.assertEqual(peering.get_issuer(bar_cert), foo_subject)
3632+
3633+ def test_ssl_verify(self):
3634 tmp = TempDir()
3635 pki = peering.PKI(tmp.dir)
3636- local_id = pki.create_key()
3637- pki.create_ca(local_id)
3638- remote_id = pki.create_key()
3639- pki.create_ca(remote_id)
3640- server_config = {
3641- 'cert_file': pki.path(local_id, 'ca'),
3642- 'key_file': pki.path(local_id, 'key'),
3643- 'ca_file': pki.path(remote_id, 'ca'),
3644- }
3645- client_config = {
3646- 'check_hostname': False,
3647- 'ca_file': pki.path(local_id, 'ca'),
3648- 'cert_file': pki.path(remote_id, 'ca'),
3649- 'key_file': pki.path(remote_id, 'key'),
3650- }
3651- local = peering.ChallengeResponse(local_id, remote_id)
3652- remote = peering.ChallengeResponse(remote_id, local_id)
3653- q = Queue()
3654- app = peering.ServerApp(local, q, None)
3655- server = make_server(app, '127.0.0.1', server_config)
3656- client = CouchBase({'url': server.url, 'ssl': client_config})
3657- server.start()
3658- secret = local.get_secret()
3659- remote.set_secret(secret)
3660-
3661- self.assertIsNone(app.state)
3662- with self.assertRaises(microfiber.BadRequest) as cm:
3663- client.get('')
3664- self.assertEqual(
3665- str(cm.exception),
3666- '400 Bad Request State: GET /'
3667- )
3668- app.state = 'info'
3669- self.assertEqual(client.get(),
3670- {
3671- 'id': local_id,
3672- 'user': os.environ.get('USER'),
3673- 'host': socket.gethostname(),
3674- }
3675- )
3676- self.assertEqual(app.state, 'ready')
3677- with self.assertRaises(microfiber.BadRequest) as cm:
3678- client.get('')
3679- self.assertEqual(
3680- str(cm.exception),
3681- '400 Bad Request State: GET /'
3682- )
3683- self.assertEqual(app.state, 'ready')
3684-
3685- app.state = 'info'
3686- with self.assertRaises(microfiber.BadRequest) as cm:
3687- client.get('challenge')
3688- self.assertEqual(
3689- str(cm.exception),
3690- '400 Bad Request Order: GET /challenge'
3691- )
3692- with self.assertRaises(microfiber.BadRequest) as cm:
3693- client.put({'hello': 'world'}, 'response')
3694- self.assertEqual(
3695- str(cm.exception),
3696- '400 Bad Request Order: PUT /response'
3697- )
3698-
3699- app.state = 'ready'
3700- self.assertEqual(app.state, 'ready')
3701- obj = client.get('challenge')
3702- self.assertEqual(app.state, 'gave_challenge')
3703- self.assertIsInstance(obj, dict)
3704- self.assertEqual(set(obj), set(['challenge']))
3705- self.assertEqual(local.challenge, decode(obj['challenge']))
3706- with self.assertRaises(microfiber.BadRequest) as cm:
3707- client.get('challenge')
3708- self.assertEqual(
3709- str(cm.exception),
3710- '400 Bad Request Order: GET /challenge'
3711- )
3712- self.assertEqual(app.state, 'gave_challenge')
3713-
3714- (nonce, response) = remote.create_response(obj['challenge'])
3715- obj = {'nonce': nonce, 'response': response}
3716- self.assertEqual(client.put(obj, 'response'), {'ok': True})
3717- self.assertEqual(app.state, 'response_ok')
3718- with self.assertRaises(microfiber.BadRequest) as cm:
3719- client.put(obj, 'response')
3720- self.assertEqual(
3721- str(cm.exception),
3722- '400 Bad Request Order: PUT /response'
3723- )
3724- self.assertEqual(app.state, 'response_ok')
3725- self.assertEqual(q.get(), 'response_ok')
3726-
3727- # Test when an error occurs in put_response()
3728- app.state = 'gave_challenge'
3729- with self.assertRaises(microfiber.ServerError) as cm:
3730- client.put(b'bad json', 'response')
3731- self.assertEqual(app.state, 'in_response')
3732-
3733- # Test with wrong secret
3734- app.state = 'ready'
3735- secret = local.get_secret()
3736- remote.get_secret()
3737- challenge = client.get('challenge')['challenge']
3738- self.assertEqual(app.state, 'gave_challenge')
3739- (nonce, response) = remote.create_response(challenge)
3740- with self.assertRaises(microfiber.Unauthorized) as cm:
3741- client.put({'nonce': nonce, 'response': response}, 'response')
3742- self.assertEqual(app.state, 'wrong_response')
3743- self.assertFalse(hasattr(local, 'secret'))
3744- self.assertFalse(hasattr(local, 'challenge'))
3745-
3746- # Verify that you can't retry
3747- remote.set_secret(secret)
3748- (nonce, response) = remote.create_response(challenge)
3749- with self.assertRaises(microfiber.BadRequest) as cm:
3750- client.put({'nonce': nonce, 'response': response}, 'response')
3751- self.assertEqual(
3752- str(cm.exception),
3753- '400 Bad Request Order: PUT /response'
3754- )
3755- self.assertEqual(app.state, 'wrong_response')
3756- self.assertEqual(q.get(), 'wrong_response')
3757-
3758- server.shutdown()
3759+
3760+ ca1 = pki.create_key()
3761+ pki.create_ca(ca1)
3762+ cert1 = pki.create_key()
3763+ pki.create_csr(cert1)
3764+ pki.issue_cert(cert1, ca1)
3765+ ca1_file = pki.path(ca1, 'ca')
3766+ cert1_file = pki.path(cert1, 'cert')
3767+ self.assertEqual(peering.ssl_verify(ca1_file, ca1_file), ca1_file)
3768+ self.assertEqual(peering.ssl_verify(cert1_file, ca1_file), cert1_file)
3769+ with self.assertRaises(peering.VerificationError) as cm:
3770+ peering.ssl_verify(ca1_file, cert1_file)
3771+ with self.assertRaises(peering.VerificationError) as cm:
3772+ peering.ssl_verify(cert1_file, cert1_file)
3773+
3774+ ca2 = pki.create_key()
3775+ pki.create_ca(ca2)
3776+ cert2 = pki.create_key()
3777+ pki.create_csr(cert2)
3778+ pki.issue_cert(cert2, ca2)
3779+ ca2_file = pki.path(ca2, 'ca')
3780+ cert2_file = pki.path(cert2, 'cert')
3781+ self.assertEqual(peering.ssl_verify(ca2_file, ca2_file), ca2_file)
3782+ self.assertEqual(peering.ssl_verify(cert2_file, ca2_file), cert2_file)
3783+ with self.assertRaises(peering.VerificationError) as cm:
3784+ peering.ssl_verify(ca2_file, cert2_file)
3785+ with self.assertRaises(peering.VerificationError) as cm:
3786+ peering.ssl_verify(cert2_file, cert2_file)
3787+
3788+ with self.assertRaises(peering.VerificationError) as cm:
3789+ peering.ssl_verify(ca2_file, ca1_file)
3790+ with self.assertRaises(peering.VerificationError) as cm:
3791+ peering.ssl_verify(cert2_file, ca1_file)
3792+ with self.assertRaises(peering.VerificationError) as cm:
3793+ peering.ssl_verify(cert2_file, cert1_file)
3794
3795
3796 class TestPKI(TestCase):
3797@@ -935,17 +979,17 @@
3798
3799 cert1_file = pki.path(id1, 'cert')
3800 cert2_file = pki.path(id2, 'cert')
3801- self.assertEqual(pki.verify_cert(id1), cert1_file)
3802- self.assertEqual(pki.verify_cert(id2), cert2_file)
3803+ self.assertEqual(pki.verify_cert(id1, ca_id), cert1_file)
3804+ self.assertEqual(pki.verify_cert(id2, ca_id), cert2_file)
3805 os.remove(cert1_file)
3806 os.rename(cert2_file, cert1_file)
3807 with self.assertRaises(peering.PublicKeyError) as cm:
3808- pki.verify_cert(id1)
3809+ pki.verify_cert(id1, ca_id)
3810 self.assertEqual(cm.exception.filename, cert1_file)
3811 self.assertEqual(cm.exception.expected, id1)
3812 self.assertEqual(cm.exception.got, id2)
3813 with self.assertRaises(subprocess.CalledProcessError) as cm:
3814- pki.verify_cert(id2)
3815+ pki.verify_cert(id2, ca_id)
3816
3817 # Test with bad subject
3818 id3 = pki.create_key()
3819@@ -960,7 +1004,7 @@
3820 cert_file
3821 )
3822 with self.assertRaises(peering.SubjectError) as cm:
3823- pki.verify_cert(id3)
3824+ pki.verify_cert(id3, ca_id)
3825 self.assertEqual(cm.exception.filename, cert_file)
3826 self.assertEqual(cm.exception.expected, '/CN={}'.format(id3))
3827 self.assertEqual(cm.exception.got, '/CN={}'.format(id1))
3828@@ -968,7 +1012,7 @@
3829 def test_get_ca(self):
3830 tmp = TempDir()
3831 pki = peering.PKI(tmp.dir)
3832- ca_id = pki.create_key()
3833+ ca_id = pki.create_key(1024)
3834 pki.create_ca(ca_id)
3835
3836 ca = pki.get_ca(ca_id)
3837@@ -986,13 +1030,64 @@
3838 pki.create_csr(cert_id)
3839 pki.issue_cert(cert_id, ca_id)
3840
3841- cert = pki.get_cert(cert_id)
3842+ cert = pki.get_cert(cert_id, ca_id)
3843 self.assertIsInstance(cert, peering.Cert)
3844 self.assertEqual(cert.id, cert_id)
3845 self.assertEqual(cert.cert_file, pki.path(cert_id, 'cert'))
3846 self.assertEqual(cert.key_file, pki.path(cert_id, 'key'))
3847 self.assertEqual(cert, (cert.id, cert.cert_file, cert.key_file))
3848
3849+ def test_load_machine(self):
3850+ tmp = TempDir()
3851+ pki = peering.PKI(tmp.dir)
3852+ machine_id = pki.create_key()
3853+ pki.create_ca(machine_id)
3854+
3855+ machine = pki.load_machine(machine_id)
3856+ self.assertIsInstance(machine, peering.Machine)
3857+ self.assertEqual(machine.id, machine_id)
3858+ self.assertEqual(machine.ca_file, pki.path(machine_id, 'ca'))
3859+ self.assertEqual(machine.key_file, pki.path(machine_id, 'key'))
3860+ self.assertIsNone(machine.cert_file)
3861+ self.assertEqual(machine,
3862+ (machine.id, machine.ca_file, machine.key_file, None)
3863+ )
3864+
3865+ user_id = pki.create_key()
3866+ pki.create_ca(user_id)
3867+ pki.create_csr(machine_id)
3868+ pki.issue_cert(machine_id, user_id)
3869+ machine = pki.load_machine(machine_id, user_id)
3870+ self.assertIsInstance(machine, peering.Machine)
3871+ self.assertEqual(machine.id, machine_id)
3872+ self.assertEqual(machine.ca_file, pki.path(machine_id, 'ca'))
3873+ self.assertEqual(machine.key_file, pki.path(machine_id, 'key'))
3874+ self.assertEqual(machine.cert_file, pki.path(machine_id, 'cert'))
3875+ self.assertEqual(machine,
3876+ (machine.id, machine.ca_file, machine.key_file, machine.cert_file)
3877+ )
3878+
3879+ def test_load_user(self):
3880+ tmp = TempDir()
3881+ pki = peering.PKI(tmp.dir)
3882+ user_id = pki.create_key()
3883+ pki.create_ca(user_id)
3884+
3885+ user = pki.load_user(user_id)
3886+ self.assertIsInstance(user, peering.User)
3887+ self.assertEqual(user.id, user_id)
3888+ self.assertEqual(user.ca_file, pki.path(user_id, 'ca'))
3889+ self.assertEqual(user.key_file, pki.path(user_id, 'key'))
3890+ self.assertEqual(user, (user.id, user.ca_file, user.key_file))
3891+
3892+ os.remove(pki.path(user_id, 'key'))
3893+ user = pki.load_user(user_id)
3894+ self.assertIsInstance(user, peering.User)
3895+ self.assertEqual(user.id, user_id)
3896+ self.assertEqual(user.ca_file, pki.path(user_id, 'ca'))
3897+ self.assertIsNone(user.key_file)
3898+ self.assertEqual(user, (user.id, user.ca_file, None))
3899+
3900
3901 class TestTempPKI(TestCase):
3902 def test_init(self):
3903
3904=== modified file 'dmedia/tests/test_server.py'
3905--- dmedia/tests/test_server.py 2012-10-04 21:58:54 +0000
3906+++ dmedia/tests/test_server.py 2012-10-16 12:35:25 +0000
3907@@ -1,4 +1,4 @@
3908-# dmedia: dmedia hashing protocol and file layout
3909+# dmedia: distributed media library
3910 # Copyright (C) 2011 Novacut Inc
3911 #
3912 # This file is part of `dmedia`.
3913@@ -27,15 +27,21 @@
3914 import multiprocessing
3915 import time
3916 from copy import deepcopy
3917+import os
3918+import socket
3919+from base64 import b64encode, b64decode
3920+from queue import Queue
3921
3922 from usercouch.misc import TempCouch
3923 from filestore import DIGEST_B32LEN, DIGEST_BYTES
3924 import microfiber
3925-from microfiber import random_id
3926+from microfiber import random_id, dumps
3927
3928+from .base import TempDir
3929 import dmedia
3930-from dmedia.peering import TempPKI
3931-from dmedia import server, client
3932+from dmedia.httpd import make_server, WSGIError, Input
3933+from dmedia.peering import encode, decode
3934+from dmedia import server, client, peering
3935
3936
3937 def random_dbname():
3938@@ -47,180 +53,41 @@
3939 self.__called = False
3940
3941 def __call__(self, status, headers):
3942- assert not self.__callled
3943+ assert self.__called is False
3944 self.__called = True
3945 self.status = status
3946 self.headers = headers
3947
3948
3949 class TestFunctions(TestCase):
3950- def test_get_slice(self):
3951- # Test all the valid types of requests:
3952- _id = random_id(DIGEST_BYTES)
3953- self.assertEqual(
3954- server.get_slice({'PATH_INFO': '/{}'.format(_id)}),
3955- (_id, 0, None)
3956- )
3957-
3958- _id = random_id(DIGEST_BYTES)
3959- self.assertEqual(
3960- server.get_slice({'PATH_INFO': '/{}/0'.format(_id)}),
3961- (_id, 0, None)
3962- )
3963-
3964- _id = random_id(DIGEST_BYTES)
3965- self.assertEqual(
3966- server.get_slice({'PATH_INFO': '/{}/17'.format(_id)}),
3967- (_id, 17, None)
3968- )
3969-
3970- _id = random_id(DIGEST_BYTES)
3971- self.assertEqual(
3972- server.get_slice({'PATH_INFO': '/{}/17/21'.format(_id)}),
3973- (_id, 17, 21)
3974- )
3975-
3976- _id = random_id(DIGEST_BYTES)
3977- self.assertEqual(
3978- server.get_slice({'PATH_INFO': '/{}/0/1'.format(_id)}),
3979- (_id, 0, 1)
3980- )
3981-
3982- # Too many slashes
3983- with self.assertRaises(server.BadRequest) as cm:
3984- server.get_slice({'PATH_INFO': '/file-id/start/stop/other'})
3985- self.assertEqual(cm.exception.body, b'too many slashes in request path')
3986-
3987- with self.assertRaises(server.BadRequest) as cm:
3988- server.get_slice({'PATH_INFO': 'file-id/start/stop/'})
3989- self.assertEqual(cm.exception.body, b'too many slashes in request path')
3990-
3991- with self.assertRaises(server.BadRequest) as cm:
3992- server.get_slice({'PATH_INFO': '/file-id///'})
3993- self.assertEqual(cm.exception.body, b'too many slashes in request path')
3994-
3995- # Bad ID
3996- attack = 'CCCCCCCCCCCCCCCCCCCCCCCCCCC\..\..\..\.ssh\id_rsa'
3997- self.assertEqual(len(attack), DIGEST_B32LEN)
3998- with self.assertRaises(server.BadRequest) as cm:
3999- server.get_slice({'PATH_INFO': attack})
4000- self.assertEqual(cm.exception.body, b'badly formed dmedia ID')
4001-
4002- short = random_id(DIGEST_BYTES - 5)
4003- with self.assertRaises(server.BadRequest) as cm:
4004- server.get_slice({'PATH_INFO': short})
4005- self.assertEqual(cm.exception.body, b'badly formed dmedia ID')
4006-
4007- long = random_id(DIGEST_BYTES + 5)
4008- with self.assertRaises(server.BadRequest) as cm:
4009- server.get_slice({'PATH_INFO': long})
4010- self.assertEqual(cm.exception.body, b'badly formed dmedia ID')
4011-
4012- lower = random_id(DIGEST_BYTES).lower()
4013- with self.assertRaises(server.BadRequest) as cm:
4014- server.get_slice({'PATH_INFO': lower})
4015- self.assertEqual(cm.exception.body, b'badly formed dmedia ID')
4016-
4017- # start not integer
4018- bad = '/{}/17.9'.format(random_id(DIGEST_BYTES))
4019- with self.assertRaises(server.BadRequest) as cm:
4020- server.get_slice({'PATH_INFO': bad})
4021- self.assertEqual(cm.exception.body, b'start is not a valid integer')
4022-
4023- bad = '/{}/00ff'.format(random_id(DIGEST_BYTES))
4024- with self.assertRaises(server.BadRequest) as cm:
4025- server.get_slice({'PATH_INFO': bad})
4026- self.assertEqual(cm.exception.body, b'start is not a valid integer')
4027-
4028- bad = '/{}/foo'.format(random_id(DIGEST_BYTES))
4029- with self.assertRaises(server.BadRequest) as cm:
4030- server.get_slice({'PATH_INFO': bad})
4031- self.assertEqual(cm.exception.body, b'start is not a valid integer')
4032-
4033- bad = '/{}/17.9/333'.format(random_id(DIGEST_BYTES))
4034- with self.assertRaises(server.BadRequest) as cm:
4035- server.get_slice({'PATH_INFO': bad})
4036- self.assertEqual(cm.exception.body, b'start is not a valid integer')
4037-
4038- bad = '/{}/00ff/333'.format(random_id(DIGEST_BYTES))
4039- with self.assertRaises(server.BadRequest) as cm:
4040- server.get_slice({'PATH_INFO': bad})
4041- self.assertEqual(cm.exception.body, b'start is not a valid integer')
4042-
4043- bad = '/{}/foo/333'.format(random_id(DIGEST_BYTES))
4044- with self.assertRaises(server.BadRequest) as cm:
4045- server.get_slice({'PATH_INFO': bad})
4046- self.assertEqual(cm.exception.body, b'start is not a valid integer')
4047-
4048- # stop not integer
4049- bad = '/{}/18/21.2'.format(random_id(DIGEST_BYTES))
4050- with self.assertRaises(server.BadRequest) as cm:
4051- server.get_slice({'PATH_INFO': bad})
4052- self.assertEqual(cm.exception.body, b'stop is not a valid integer')
4053-
4054- bad = '/{}/18/00ff'.format(random_id(DIGEST_BYTES))
4055- with self.assertRaises(server.BadRequest) as cm:
4056- server.get_slice({'PATH_INFO': bad})
4057- self.assertEqual(cm.exception.body, b'stop is not a valid integer')
4058-
4059- bad = '/{}/18/foo'.format(random_id(DIGEST_BYTES))
4060- with self.assertRaises(server.BadRequest) as cm:
4061- server.get_slice({'PATH_INFO': bad})
4062- self.assertEqual(cm.exception.body, b'stop is not a valid integer')
4063-
4064- # start < 0
4065- bad = '/{}/-1'.format(random_id(DIGEST_BYTES))
4066- with self.assertRaises(server.BadRequest) as cm:
4067- server.get_slice({'PATH_INFO': bad})
4068- self.assertEqual(cm.exception.body, b'start cannot be less than zero')
4069-
4070- bad = '/{}/-1/18'.format(random_id(DIGEST_BYTES))
4071- with self.assertRaises(server.BadRequest) as cm:
4072- server.get_slice({'PATH_INFO': bad})
4073- self.assertEqual(cm.exception.body, b'start cannot be less than zero')
4074-
4075- # start >= stop
4076- bad = '/{}/18/17'.format(random_id(DIGEST_BYTES))
4077- with self.assertRaises(server.BadRequest) as cm:
4078- server.get_slice({'PATH_INFO': bad})
4079- self.assertEqual(cm.exception.body, b'start must be less than stop')
4080-
4081- bad = '/{}/17/17'.format(random_id(DIGEST_BYTES))
4082- with self.assertRaises(server.BadRequest) as cm:
4083- server.get_slice({'PATH_INFO': bad})
4084- self.assertEqual(cm.exception.body, b'start must be less than stop')
4085-
4086 def test_range_to_slice(self):
4087- with self.assertRaises(server.BadRangeRequest) as cm:
4088+ with self.assertRaises(WSGIError) as cm:
4089 (start, stop) = server.range_to_slice('goats=0-500')
4090- self.assertEqual(cm.exception.body, b'bad range units')
4091+ self.assertEqual(cm.exception.status, '400 Bad Range Units')
4092
4093- with self.assertRaises(server.BadRangeRequest) as cm:
4094+ with self.assertRaises(WSGIError) as cm:
4095 (start, stop) = server.range_to_slice('bytes=-500-999')
4096- self.assertEqual(cm.exception.body, b'range -start is not an integer')
4097+ self.assertEqual(cm.exception.status, '400 Bad Range Negative Start')
4098
4099- with self.assertRaises(server.BadRangeRequest) as cm:
4100+ with self.assertRaises(WSGIError) as cm:
4101 (start, stop) = server.range_to_slice('bytes=-foo')
4102- self.assertEqual(cm.exception.body, b'range -start is not an integer')
4103+ self.assertEqual(cm.exception.status, '400 Bad Range Negative Start')
4104
4105- with self.assertRaises(server.BadRangeRequest) as cm:
4106+ with self.assertRaises(WSGIError) as cm:
4107 (start, stop) = server.range_to_slice('bytes=500')
4108- self.assertEqual(cm.exception.body, b'not formatted as bytes=start-end')
4109+ self.assertEqual(cm.exception.status, '400 Bad Range Format')
4110
4111- with self.assertRaises(server.BadRangeRequest) as cm:
4112+ with self.assertRaises(WSGIError) as cm:
4113 (start, stop) = server.range_to_slice('bytes=foo-999')
4114- self.assertEqual(cm.exception.body, b'range start is not an integer')
4115+ self.assertEqual(cm.exception.status, '400 Bad Range Start')
4116
4117- with self.assertRaises(server.BadRangeRequest) as cm:
4118+ with self.assertRaises(WSGIError) as cm:
4119 (start, stop) = server.range_to_slice('bytes=500-bar')
4120- self.assertEqual(cm.exception.body, b'range end is not an integer')
4121+ self.assertEqual(cm.exception.status, '400 Bad Range End')
4122
4123- with self.assertRaises(server.BadRangeRequest) as cm:
4124+ with self.assertRaises(WSGIError) as cm:
4125 (start, stop) = server.range_to_slice('bytes=500-499')
4126- self.assertEqual(
4127- cm.exception.body,
4128- b'range end must be less than or equal to start'
4129- )
4130+ self.assertEqual(cm.exception.status, '400 Bad Range')
4131
4132 self.assertEqual(
4133 server.range_to_slice('bytes=0-0'), (0, 1)
4134@@ -257,31 +124,370 @@
4135 )
4136
4137
4138-class TestBaseWSGI(TestCase):
4139- def test_metaclass(self):
4140- self.assertEqual(server.BaseWSGI.http_methods, frozenset())
4141-
4142- class Example(server.BaseWSGI):
4143- def PUT(self, environ, start_response):
4144- pass
4145-
4146- def POST(self, environ, start_response):
4147- pass
4148-
4149- def GET(self, environ, start_response):
4150- pass
4151-
4152- def DELETE(self, environ, start_response):
4153- pass
4154-
4155- def HEAD(self, environ, start_response):
4156- pass
4157-
4158- self.assertEqual(
4159- Example.http_methods,
4160- frozenset(['PUT', 'POST', 'GET', 'DELETE', 'HEAD'])
4161- )
4162-
4163+class TestRootApp(TestCase):
4164+ def test_init(self):
4165+ user_id = random_id(30)
4166+ machine_id = random_id(30)
4167+ password = random_id()
4168+ env = {
4169+ 'user_id': user_id,
4170+ 'machine_id': machine_id,
4171+ 'basic': {'username': 'admin', 'password': password},
4172+ 'url': microfiber.HTTP_IPv4_URL,
4173+ }
4174+ app = server.RootApp(env)
4175+ self.assertIs(app.user_id, user_id)
4176+ self.assertEqual(
4177+ app.info,
4178+ microfiber.dumps(
4179+ {
4180+ 'user_id': user_id,
4181+ 'machine_id': machine_id,
4182+ 'version': dmedia.__version__,
4183+ 'user': os.environ.get('USER'),
4184+ 'host': socket.gethostname(),
4185+ }
4186+ ).encode('utf-8')
4187+ )
4188+ self.assertEqual(app.info_length, str(int(len(app.info))))
4189+ self.assertIsInstance(app.proxy, server.ProxyApp)
4190+ self.assertIsInstance(app.files, server.FilesApp)
4191+ self.assertEqual(app.map,
4192+ {
4193+ '': app.get_info,
4194+ 'couch': app.proxy,
4195+ 'files': app.files,
4196+ }
4197+ )
4198+
4199+ def test_call(self):
4200+ user_id = random_id(30)
4201+ machine_id = random_id(30)
4202+ password = random_id()
4203+ env = {
4204+ 'user_id': user_id,
4205+ 'machine_id': machine_id,
4206+ 'basic': {'username': 'admin', 'password': password},
4207+ 'url': microfiber.HTTP_IPv4_URL,
4208+ }
4209+ app = server.RootApp(env)
4210+
4211+ # SSL_CLIENT_VERIFY
4212+ with self.assertRaises(WSGIError) as cm:
4213+ app({}, None)
4214+ self.assertEqual(cm.exception.status, '403 Forbidden SSL')
4215+ with self.assertRaises(WSGIError) as cm:
4216+ app({'SSL_CLIENT_VERIFY': 'NOPE'}, None)
4217+ self.assertEqual(cm.exception.status, '403 Forbidden SSL')
4218+
4219+ # SSL_CLIENT_I_DN_CN
4220+ environ = {
4221+ 'SSL_CLIENT_VERIFY': 'SUCCESS',
4222+ }
4223+ with self.assertRaises(WSGIError) as cm:
4224+ app(environ, None)
4225+ self.assertEqual(cm.exception.status, '403 Forbidden Issuer')
4226+ environ = {
4227+ 'SSL_CLIENT_VERIFY': 'SUCCESS',
4228+ 'SSL_CLIENT_I_DN_CN': random_id(30),
4229+ }
4230+ with self.assertRaises(WSGIError) as cm:
4231+ app(environ, None)
4232+ self.assertEqual(cm.exception.status, '403 Forbidden Issuer')
4233+
4234+ # PATH_INFO
4235+ environ = {
4236+ 'SSL_CLIENT_VERIFY': 'SUCCESS',
4237+ 'SSL_CLIENT_I_DN_CN': user_id,
4238+ 'PATH_INFO': '/foo',
4239+ }
4240+ with self.assertRaises(WSGIError) as cm:
4241+ app(environ, None)
4242+ self.assertEqual(cm.exception.status, '410 Gone')
4243+
4244+ # REQUEST_METHOD
4245+ environ = {
4246+ 'SSL_CLIENT_VERIFY': 'SUCCESS',
4247+ 'SSL_CLIENT_I_DN_CN': user_id,
4248+ 'PATH_INFO': '/',
4249+ 'REQUEST_METHOD': 'HEAD',
4250+ }
4251+ with self.assertRaises(WSGIError) as cm:
4252+ app(environ, None)
4253+ self.assertEqual(cm.exception.status, '405 Method Not Allowed')
4254+ environ = {
4255+ 'SSL_CLIENT_VERIFY': 'SUCCESS',
4256+ 'SSL_CLIENT_I_DN_CN': user_id,
4257+ 'PATH_INFO': '/files/' + random_id(30),
4258+ 'REQUEST_METHOD': 'PUT',
4259+ }
4260+ with self.assertRaises(WSGIError) as cm:
4261+ app(environ, None)
4262+ self.assertEqual(cm.exception.status, '405 Method Not Allowed')
4263+
4264+ # Test when it's all good
4265+ environ = {
4266+ 'SSL_CLIENT_VERIFY': 'SUCCESS',
4267+ 'SSL_CLIENT_I_DN_CN': user_id,
4268+ 'PATH_INFO': '/',
4269+ 'REQUEST_METHOD': 'GET',
4270+ }
4271+ sr = StartResponse()
4272+ ret = app(environ, sr)
4273+ self.assertEqual(ret, [app.info])
4274+ self.assertEqual(sr.status, '200 OK')
4275+ self.assertEqual(sr.headers,
4276+ [
4277+ ('Content-Length', app.info_length),
4278+ ('Content-Type', 'application/json'),
4279+ ]
4280+ )
4281+
4282+
4283+class TestProxyApp(TestCase):
4284+ def test_init(self):
4285+ password = random_id()
4286+ env = {
4287+ 'basic': {'username': 'admin', 'password': password},
4288+ 'url': microfiber.HTTP_IPv4_URL,
4289+ }
4290+ app = server.ProxyApp(deepcopy(env))
4291+ self.assertIsInstance(app.client, microfiber.CouchBase)
4292+ self.assertEqual(app.client.env, env)
4293+ self.assertEqual(app.target_host, '127.0.0.1:5984')
4294+ self.assertEqual(app.basic_auth,
4295+ microfiber.basic_auth_header(env['basic'])
4296+ )
4297+
4298+ def test_call(self):
4299+ password = random_id()
4300+ env = {
4301+ 'basic': {'username': 'admin', 'password': password},
4302+ 'url': microfiber.HTTP_IPv4_URL,
4303+ }
4304+ app = server.ProxyApp(env)
4305+
4306+ # PATH_INFO
4307+ environ = {
4308+ 'REQUEST_METHOD': 'GET',
4309+ 'PATH_INFO': '/_config/foo',
4310+ 'wsgi.input': Input(None, {'REQUEST_METHOD': 'GET'}),
4311+ }
4312+ with self.assertRaises(WSGIError) as cm:
4313+ app(environ, None)
4314+ self.assertEqual(cm.exception.status, '403 Forbidden')
4315+
4316+
4317+class TestInfoApp(TestCase):
4318+ def test_init(self):
4319+ _id = random_id(30)
4320+ app = server.InfoApp(_id)
4321+ self.assertIs(app.id, _id)
4322+ self.assertEqual(
4323+ app.info,
4324+ microfiber.dumps(
4325+ {
4326+ 'id': _id,
4327+ 'version': dmedia.__version__,
4328+ 'user': os.environ.get('USER'),
4329+ 'host': socket.gethostname(),
4330+ }
4331+ ).encode('utf-8')
4332+ )
4333+ self.assertEqual(app.info_length, str(int(len(app.info))))
4334+
4335+ def test_call(self):
4336+ app = server.InfoApp(random_id(30))
4337+
4338+ # wsgi.multithread
4339+ with self.assertRaises(WSGIError) as cm:
4340+ app({'wsgi.multithread': True}, None)
4341+ self.assertEqual(cm.exception.status, '500 Internal Server Error')
4342+ with self.assertRaises(WSGIError) as cm:
4343+ app({'wsgi.multithread': 0}, None)
4344+ self.assertEqual(cm.exception.status, '500 Internal Server Error')
4345+
4346+ # PATH_INFO
4347+ environ = {
4348+ 'wsgi.multithread': False,
4349+ 'PATH_INFO': '/foo',
4350+ }
4351+ with self.assertRaises(WSGIError) as cm:
4352+ app(environ, None)
4353+ self.assertEqual(cm.exception.status, '410 Gone')
4354+
4355+ # REQUEST_METHOD
4356+ environ = {
4357+ 'wsgi.multithread': False,
4358+ 'PATH_INFO': '/',
4359+ 'REQUEST_METHOD': 'HEAD',
4360+ }
4361+ with self.assertRaises(WSGIError) as cm:
4362+ app(environ, None)
4363+ self.assertEqual(cm.exception.status, '405 Method Not Allowed')
4364+
4365+ # Test when it's all good
4366+ environ = {
4367+ 'wsgi.multithread': False,
4368+ 'PATH_INFO': '/',
4369+ 'REQUEST_METHOD': 'GET',
4370+ }
4371+ sr = StartResponse()
4372+ ret = app(environ, sr)
4373+ self.assertEqual(ret, [app.info])
4374+ self.assertEqual(sr.status, '200 OK')
4375+ self.assertEqual(sr.headers,
4376+ [
4377+ ('Content-Length', app.info_length),
4378+ ('Content-Type', 'application/json'),
4379+ ]
4380+ )
4381+
4382+
4383+class TestClientApp(TestCase):
4384+ def test_init(self):
4385+ id1 = random_id(30)
4386+ id2 = random_id(30)
4387+ cr = peering.ChallengeResponse(id1, id2)
4388+ q = Queue()
4389+ app = server.ClientApp(cr, q)
4390+ self.assertIs(app.cr, cr)
4391+ self.assertIs(app.queue, q)
4392+ self.assertEqual(app.map,
4393+ {
4394+ '/challenge': app.get_challenge,
4395+ '/response': app.post_response,
4396+ }
4397+ )
4398+
4399+ def test_call(self):
4400+ _id = random_id(30)
4401+ peer_id = random_id(30)
4402+ cr = peering.ChallengeResponse(_id, peer_id)
4403+ app = server.ClientApp(cr, Queue())
4404+
4405+ # wsgi.multithread
4406+ with self.assertRaises(WSGIError) as cm:
4407+ app({'wsgi.multithread': True}, None)
4408+ self.assertEqual(cm.exception.status, '500 Internal Server Error')
4409+ with self.assertRaises(WSGIError) as cm:
4410+ app({'wsgi.multithread': 0}, None)
4411+ self.assertEqual(cm.exception.status, '500 Internal Server Error')
4412+
4413+ # SSL_CLIENT_VERIFY
4414+ with self.assertRaises(WSGIError) as cm:
4415+ app({'wsgi.multithread': False}, None)
4416+ self.assertEqual(cm.exception.status, '403 Forbidden SSL')
4417+ environ = {
4418+ 'wsgi.multithread': False,
4419+ 'SSL_CLIENT_VERIFY': 'NOPE',
4420+ }
4421+ with self.assertRaises(WSGIError) as cm:
4422+ app(environ, None)
4423+ self.assertEqual(cm.exception.status, '403 Forbidden SSL')
4424+
4425+ # SSL_CLIENT_S_DN_CN
4426+ environ = {
4427+ 'wsgi.multithread': False,
4428+ 'SSL_CLIENT_VERIFY': 'SUCCESS',
4429+ }
4430+ with self.assertRaises(WSGIError) as cm:
4431+ app(environ, None)
4432+ self.assertEqual(cm.exception.status, '403 Forbidden Subject')
4433+ environ = {
4434+ 'wsgi.multithread': False,
4435+ 'SSL_CLIENT_VERIFY': 'SUCCESS',
4436+ 'SSL_CLIENT_S_DN_CN': random_id(30),
4437+ }
4438+ with self.assertRaises(WSGIError) as cm:
4439+ app(environ, None)
4440+ self.assertEqual(cm.exception.status, '403 Forbidden Subject')
4441+
4442+ # SSL_CLIENT_I_DN_CN
4443+ environ = {
4444+ 'wsgi.multithread': False,
4445+ 'SSL_CLIENT_VERIFY': 'SUCCESS',
4446+ 'SSL_CLIENT_S_DN_CN': peer_id,
4447+ }
4448+ with self.assertRaises(WSGIError) as cm:
4449+ app(environ, None)
4450+ self.assertEqual(cm.exception.status, '403 Forbidden Issuer')
4451+ environ = {
4452+ 'wsgi.multithread': False,
4453+ 'SSL_CLIENT_VERIFY': 'SUCCESS',
4454+ 'SSL_CLIENT_S_DN_CN': peer_id,
4455+ 'SSL_CLIENT_I_DN_CN': random_id(30),
4456+ }
4457+ with self.assertRaises(WSGIError) as cm:
4458+ app(environ, None)
4459+ self.assertEqual(cm.exception.status, '403 Forbidden Issuer')
4460+
4461+ # PATH_INFO
4462+ environ = {
4463+ 'wsgi.multithread': False,
4464+ 'SSL_CLIENT_VERIFY': 'SUCCESS',
4465+ 'SSL_CLIENT_S_DN_CN': peer_id,
4466+ 'SSL_CLIENT_I_DN_CN': peer_id,
4467+ 'PATH_INFO': '/',
4468+ }
4469+ with self.assertRaises(WSGIError) as cm:
4470+ app(environ, None)
4471+ self.assertEqual(cm.exception.status, '410 Gone')
4472+
4473+ # state
4474+ environ = {
4475+ 'wsgi.multithread': False,
4476+ 'SSL_CLIENT_VERIFY': 'SUCCESS',
4477+ 'SSL_CLIENT_S_DN_CN': peer_id,
4478+ 'SSL_CLIENT_I_DN_CN': peer_id,
4479+ 'PATH_INFO': '/challenge',
4480+ }
4481+ with self.assertRaises(WSGIError) as cm:
4482+ app(environ, None)
4483+ self.assertEqual(cm.exception.status, '400 Bad Request Order')
4484+
4485+ # REQUEST_METHOD
4486+ app.state = 'ready'
4487+ environ = {
4488+ 'wsgi.multithread': False,
4489+ 'SSL_CLIENT_VERIFY': 'SUCCESS',
4490+ 'SSL_CLIENT_S_DN_CN': peer_id,
4491+ 'SSL_CLIENT_I_DN_CN': peer_id,
4492+ 'PATH_INFO': '/challenge',
4493+ 'REQUEST_METHOD': 'POST',
4494+ }
4495+ with self.assertRaises(WSGIError) as cm:
4496+ app(environ, None)
4497+ self.assertEqual(cm.exception.status, '405 Method Not Allowed')
4498+ self.assertEqual(app.state, 'gave_challenge')
4499+
4500+ # Test when it's all good
4501+ app.state = 'ready'
4502+ environ = {
4503+ 'wsgi.multithread': False,
4504+ 'SSL_CLIENT_VERIFY': 'SUCCESS',
4505+ 'SSL_CLIENT_S_DN_CN': peer_id,
4506+ 'SSL_CLIENT_I_DN_CN': peer_id,
4507+ 'PATH_INFO': '/challenge',
4508+ 'REQUEST_METHOD': 'GET',
4509+ }
4510+ sr = StartResponse()
4511+ ret = app(environ, sr)
4512+ data = dumps({'challenge': encode(cr.challenge)}).encode('utf-8')
4513+ self.assertEqual(ret, [data])
4514+ self.assertEqual(sr.status, '200 OK')
4515+ self.assertEqual(sr.headers,
4516+ [
4517+ ('Content-Length', str(len(data))),
4518+ ('Content-Type', 'application/json'),
4519+ ]
4520+ )
4521+ self.assertEqual(app.state, 'gave_challenge')
4522+
4523+
4524+
4525+####################################
4526+# Live test cases using Dmedia HTTPD
4527
4528 class TempHTTPD:
4529 def __init__(self, couch_env, ssl_config):
4530@@ -306,14 +512,15 @@
4531 self.process.join()
4532
4533
4534-class TestRootApp(TestCase):
4535+class TestRootAppLive(TestCase):
4536 def test_call(self):
4537 couch = TempCouch()
4538 couch_env = couch.bootstrap()
4539- couch_env['user_id'] = random_id()
4540+ couch_env['user_id'] = random_id(30)
4541+ couch_env['machine_id'] = random_id(30)
4542
4543 # Test when client PKI isn't configured
4544- pki = TempPKI()
4545+ pki = peering.TempPKI()
4546 httpd = TempHTTPD(couch_env, pki.get_server_config())
4547 env = deepcopy(httpd.env)
4548 env['ssl'] = pki.get_client_config()
4549@@ -323,7 +530,7 @@
4550 self.assertEqual(cm.exception.response.reason, 'Forbidden SSL')
4551
4552 # Test when cert issuer is wrong
4553- pki = TempPKI(True)
4554+ pki = peering.TempPKI(True)
4555 httpd = TempHTTPD(couch_env, pki.get_server_config())
4556 env = deepcopy(httpd.env)
4557 env['ssl'] = pki.get_client_config()
4558@@ -333,14 +540,21 @@
4559 self.assertEqual(cm.exception.response.reason, 'Forbidden Issuer')
4560
4561 # Test when SSL config is correct, then test other aspects
4562- pki = TempPKI(True)
4563+ pki = peering.TempPKI(True)
4564 couch_env['user_id'] = pki.client_ca.id
4565+ couch_env['machine_id'] = pki.server.id
4566 httpd = TempHTTPD(couch_env, pki.get_server_config())
4567 env = deepcopy(httpd.env)
4568 env['ssl'] = pki.get_client_config()
4569 client = microfiber.CouchBase(env)
4570 self.assertEqual(client.get(),
4571- {'Dmedia': 'welcome', 'version': dmedia.__version__}
4572+ {
4573+ 'user_id': pki.client_ca.id,
4574+ 'machine_id': pki.server.id,
4575+ 'version': dmedia.__version__,
4576+ 'user': os.environ.get('USER'),
4577+ 'host': socket.gethostname(),
4578+ }
4579 )
4580 self.assertEqual(client.get('couch')['couchdb'], 'Welcome')
4581
4582@@ -357,7 +571,7 @@
4583 """
4584 Test push replication Couch1 => HTTPD => Couch2.
4585 """
4586- pki = TempPKI(True)
4587+ pki = peering.TempPKI(True)
4588 config = {'replicator': pki.get_client_config()}
4589 couch1 = TempCouch()
4590 couch2 = TempCouch()
4591@@ -369,6 +583,7 @@
4592 # couch2 needs env['user_id']
4593 env2 = couch2.bootstrap('basic', None)
4594 env2['user_id'] = pki.client_ca.id
4595+ env2['machine_id'] = pki.server.id
4596 s2 = microfiber.Server(env2)
4597
4598 # httpd is the SSL frontend for couch2
4599@@ -392,3 +607,157 @@
4600 for doc in docs:
4601 self.assertEqual(s2.get(name2, doc['_id']), doc)
4602
4603+
4604+class TestServerAppLive(TestCase):
4605+ def test_live(self):
4606+ tmp = TempDir()
4607+ pki = peering.PKI(tmp.dir)
4608+ local_id = pki.create_key()
4609+ pki.create_ca(local_id)
4610+ remote_id = pki.create_key()
4611+ pki.create_ca(remote_id)
4612+ server_config = {
4613+ 'cert_file': pki.path(local_id, 'ca'),
4614+ 'key_file': pki.path(local_id, 'key'),
4615+ 'ca_file': pki.path(remote_id, 'ca'),
4616+ }
4617+ client_config = {
4618+ 'check_hostname': False,
4619+ 'ca_file': pki.path(local_id, 'ca'),
4620+ 'cert_file': pki.path(remote_id, 'ca'),
4621+ 'key_file': pki.path(remote_id, 'key'),
4622+ }
4623+ local = peering.ChallengeResponse(local_id, remote_id)
4624+ remote = peering.ChallengeResponse(remote_id, local_id)
4625+ q = Queue()
4626+ app = server.ServerApp(local, q, None)
4627+ httpd = make_server(app, '127.0.0.1', server_config)
4628+ client = microfiber.CouchBase(
4629+ {'url': httpd.url, 'ssl': client_config}
4630+ )
4631+ httpd.start()
4632+ secret = local.get_secret()
4633+ remote.set_secret(secret)
4634+
4635+ self.assertIsNone(app.state)
4636+ with self.assertRaises(microfiber.BadRequest) as cm:
4637+ client.get('')
4638+ self.assertEqual(
4639+ str(cm.exception),
4640+ '400 Bad Request State: GET /'
4641+ )
4642+ app.state = 'info'
4643+ self.assertEqual(client.get(),
4644+ {
4645+ 'id': local_id,
4646+ 'user': os.environ.get('USER'),
4647+ 'host': socket.gethostname(),
4648+ }
4649+ )
4650+ self.assertEqual(app.state, 'ready')
4651+ with self.assertRaises(microfiber.BadRequest) as cm:
4652+ client.get('')
4653+ self.assertEqual(
4654+ str(cm.exception),
4655+ '400 Bad Request State: GET /'
4656+ )
4657+ self.assertEqual(app.state, 'ready')
4658+
4659+ app.state = 'info'
4660+ with self.assertRaises(microfiber.BadRequest) as cm:
4661+ client.get('challenge')
4662+ self.assertEqual(
4663+ str(cm.exception),
4664+ '400 Bad Request Order: GET /challenge'
4665+ )
4666+ with self.assertRaises(microfiber.BadRequest) as cm:
4667+ client.post({'hello': 'world'}, 'response')
4668+ self.assertEqual(
4669+ str(cm.exception),
4670+ '400 Bad Request Order: POST /response'
4671+ )
4672+
4673+ app.state = 'ready'
4674+ self.assertEqual(app.state, 'ready')
4675+ obj = client.get('challenge')
4676+ self.assertEqual(app.state, 'gave_challenge')
4677+ self.assertIsInstance(obj, dict)
4678+ self.assertEqual(set(obj), set(['challenge']))
4679+ self.assertEqual(local.challenge, decode(obj['challenge']))
4680+ with self.assertRaises(microfiber.BadRequest) as cm:
4681+ client.get('challenge')
4682+ self.assertEqual(
4683+ str(cm.exception),
4684+ '400 Bad Request Order: GET /challenge'
4685+ )
4686+ self.assertEqual(app.state, 'gave_challenge')
4687+
4688+ (nonce, response) = remote.create_response(obj['challenge'])
4689+ obj = {'nonce': nonce, 'response': response}
4690+ self.assertEqual(client.post(obj, 'response'), {'ok': True})
4691+ self.assertEqual(app.state, 'response_ok')
4692+ with self.assertRaises(microfiber.BadRequest) as cm:
4693+ client.post(obj, 'response')
4694+ self.assertEqual(
4695+ str(cm.exception),
4696+ '400 Bad Request Order: POST /response'
4697+ )
4698+ self.assertEqual(app.state, 'response_ok')
4699+ self.assertEqual(q.get(), 'response_ok')
4700+
4701+ # Test when an error occurs in put_response()
4702+ app.state = 'gave_challenge'
4703+ with self.assertRaises(microfiber.ServerError) as cm:
4704+ client.post(b'bad json', 'response')
4705+ self.assertEqual(app.state, 'in_response')
4706+
4707+ # Test with wrong secret
4708+ app.state = 'ready'
4709+ secret = local.get_secret()
4710+ remote.get_secret()
4711+ challenge = client.get('challenge')['challenge']
4712+ self.assertEqual(app.state, 'gave_challenge')
4713+ (nonce, response) = remote.create_response(challenge)
4714+ with self.assertRaises(microfiber.Unauthorized) as cm:
4715+ client.post({'nonce': nonce, 'response': response}, 'response')
4716+ self.assertEqual(app.state, 'wrong_response')
4717+ self.assertFalse(hasattr(local, 'secret'))
4718+ self.assertFalse(hasattr(local, 'challenge'))
4719+
4720+ # Verify that you can't retry
4721+ remote.set_secret(secret)
4722+ (nonce, response) = remote.create_response(challenge)
4723+ with self.assertRaises(microfiber.BadRequest) as cm:
4724+ client.post({'nonce': nonce, 'response': response}, 'response')
4725+ self.assertEqual(
4726+ str(cm.exception),
4727+ '400 Bad Request Order: POST /response'
4728+ )
4729+ self.assertEqual(app.state, 'wrong_response')
4730+ self.assertEqual(q.get(), 'wrong_response')
4731+
4732+ # Test POST /csr
4733+ secret = local.get_secret()
4734+ remote.set_secret(secret)
4735+ app.state = 'response_ok'
4736+ with self.assertRaises(microfiber.BadRequest) as cm:
4737+ client.post({}, 'csr')
4738+ self.assertEqual(
4739+ str(cm.exception),
4740+ '400 Bad Request Order: POST /csr'
4741+ )
4742+ self.assertEqual(app.state, 'response_ok')
4743+
4744+ app.state = 'counter_response_ok'
4745+ secret = local.get_secret()
4746+ remote.set_secret(secret)
4747+ pki.create_csr(remote_id)
4748+ csr_data = pki.read_csr(remote_id)
4749+ obj = {
4750+ 'csr': b64encode(csr_data).decode('utf-8'),
4751+ 'mac': local.csr_mac(csr_data),
4752+ }
4753+ # FIXME: Why isn't this working?
4754+ #r = client.post(obj, 'csr')
4755+
4756+ httpd.shutdown()
4757
4758=== modified file 'dmedia/tests/test_startup.py'
4759--- dmedia/tests/test_startup.py 2012-10-03 14:42:56 +0000
4760+++ dmedia/tests/test_startup.py 2012-10-16 12:35:25 +0000
4761@@ -68,69 +68,6 @@
4762 self.assertFalse(path.exists(filename + '.tmp'))
4763 self.assertEqual(json.load(open(filename, 'r')), config)
4764
4765- def test_get_ssl_config(self):
4766- tmp = TempDir()
4767- pki = PKI(tmp.dir)
4768- machine_id = pki.create_key()
4769- pki.create_ca(machine_id)
4770- pki.create_csr(machine_id)
4771- user_id = pki.create_key()
4772- pki.create_ca(user_id)
4773- pki.issue_cert(machine_id, user_id)
4774-
4775- self.assertIsNone(startup.get_ssl_config(pki))
4776-
4777- pki.machine = pki.get_cert(machine_id)
4778- self.assertIsNone(startup.get_ssl_config(pki))
4779-
4780- pki.user = pki.get_ca(user_id)
4781- self.assertEqual(
4782- startup.get_ssl_config(pki),
4783- {
4784- 'check_hostname': False,
4785- 'max_depth': 1,
4786- 'ca_file': pki.path(user_id, 'ca'),
4787- 'cert_file': pki.path(machine_id, 'cert'),
4788- 'key_file': pki.path(machine_id, 'key'),
4789- }
4790- )
4791-
4792- def test_get_bootstrap_config(self):
4793- tmp = TempDir()
4794- pki = PKI(tmp.dir)
4795- machine_id = pki.create_key()
4796- pki.create_ca(machine_id)
4797- pki.create_csr(machine_id)
4798- user_id = pki.create_key()
4799- pki.create_ca(user_id)
4800- pki.issue_cert(machine_id, user_id)
4801-
4802- self.assertEqual(
4803- startup.get_bootstrap_config(pki),
4804- {'username': 'admin'},
4805- )
4806-
4807- pki.machine = pki.get_cert(machine_id)
4808- self.assertEqual(
4809- startup.get_bootstrap_config(pki),
4810- {'username': 'admin'},
4811- )
4812-
4813- pki.user = pki.get_ca(user_id)
4814- self.assertEqual(
4815- startup.get_bootstrap_config(pki),
4816- {
4817- 'username': 'admin',
4818- 'replicator': {
4819- 'check_hostname': False,
4820- 'max_depth': 1,
4821- 'ca_file': pki.path(user_id, 'ca'),
4822- 'cert_file': pki.path(machine_id, 'cert'),
4823- 'key_file': pki.path(machine_id, 'key'),
4824- },
4825- }
4826- )
4827-
4828
4829 class TestDmediaCouch(TestCase):
4830 def test_init(self):
4831@@ -180,62 +117,105 @@
4832 tmp = TempDir()
4833 inst = startup.DmediaCouch(tmp.dir)
4834 self.assertTrue(inst.isfirstrun())
4835- inst.machine = 'foo'
4836+ inst.user = 'foo'
4837 self.assertFalse(inst.isfirstrun())
4838- inst.machine = None
4839+ inst.user = None
4840 self.assertTrue(inst.isfirstrun())
4841
4842- def test_firstrun_init(self):
4843- class Subclass(startup.DmediaCouch):
4844- def __init__(self):
4845- pass
4846-
4847- def isfirstrun(self):
4848- return False
4849-
4850- inst = Subclass()
4851- with self.assertRaises(Exception) as cm:
4852- inst.firstrun_init()
4853- self.assertEqual(
4854- str(cm.exception),
4855- 'not first run, cannot call firstrun_init()'
4856- )
4857-
4858- tmp = TempDir()
4859- inst = startup.DmediaCouch(tmp.dir)
4860- self.assertIsNone(inst.firstrun_init())
4861- self.assertIsInstance(inst.machine, dict)
4862- self.assertEqual(inst.machine, inst.load_config('machine'))
4863- self.assertIsNone(inst.user)
4864- self.assertIsNone(inst.load_config('user'))
4865-
4866- tmp = TempDir()
4867- inst = startup.DmediaCouch(tmp.dir)
4868- self.assertIsNone(inst.firstrun_init(create_user=True))
4869- self.assertIsInstance(inst.machine, dict)
4870- self.assertEqual(inst.machine, inst.load_config('machine'))
4871+ def test_create_machine(self):
4872+ class Subclass(startup.DmediaCouch):
4873+ def __init__(self):
4874+ self.machine = 'foo'
4875+
4876+ inst = Subclass()
4877+ with self.assertRaises(Exception) as cm:
4878+ inst.create_machine()
4879+ self.assertEqual(str(cm.exception), 'machine already exists')
4880+
4881+ tmp = TempDir()
4882+ inst = startup.DmediaCouch(tmp.dir)
4883+ machine_id = inst.create_machine()
4884+ self.assertIsInstance(machine_id, str)
4885+ self.assertEqual(len(machine_id), 48)
4886+ self.assertIsInstance(inst.machine, dict)
4887+ self.assertEqual(inst.machine['_id'], machine_id)
4888+ self.assertEqual(inst.load_config('machine'), inst.machine)
4889+
4890+ with self.assertRaises(Exception) as cm:
4891+ inst.create_machine()
4892+ self.assertEqual(str(cm.exception), 'machine already exists')
4893+
4894+ def test_create_user(self):
4895+ class Subclass(startup.DmediaCouch):
4896+ def __init__(self):
4897+ self.machine = 'foo'
4898+ self.user = 'bar'
4899+
4900+ inst = Subclass()
4901+ with self.assertRaises(Exception) as cm:
4902+ inst.create_user()
4903+ self.assertEqual(str(cm.exception), 'user already exists')
4904+
4905+ tmp = TempDir()
4906+ inst = startup.DmediaCouch(tmp.dir)
4907+ with self.assertRaises(Exception) as cm:
4908+ inst.create_user()
4909+ self.assertEqual(str(cm.exception), 'must create machine first')
4910+ machine_id = inst.create_machine()
4911+ user_id = inst.create_user()
4912+ self.assertNotEqual(machine_id, user_id)
4913+ self.assertIsInstance(user_id, str)
4914+ self.assertEqual(len(user_id), 48)
4915 self.assertIsInstance(inst.user, dict)
4916- self.assertEqual(inst.user, inst.load_config('user'))
4917+ self.assertEqual(inst.user['_id'], user_id)
4918+ self.assertEqual(inst.load_config('user'), inst.user)
4919+
4920+ with self.assertRaises(Exception) as cm:
4921+ inst.create_user()
4922+ self.assertEqual(str(cm.exception), 'user already exists')
4923+
4924+ def test_get_ssl_config(self):
4925+ tmp = TempDir()
4926+ inst = startup.DmediaCouch(tmp.dir)
4927+ machine_id = inst.create_machine()
4928+ user_id = inst.create_user()
4929+ inst.load_pki()
4930+ self.assertEqual(
4931+ inst.get_ssl_config(),
4932+ {
4933+ 'check_hostname': False,
4934+ 'max_depth': 1,
4935+ 'ca_file': inst.pki.path(user_id, 'ca'),
4936+ 'cert_file': inst.pki.path(machine_id, 'cert'),
4937+ 'key_file': inst.pki.path(machine_id, 'key'),
4938+ }
4939+ )
4940+
4941+ def test_get_bootstrap_config(self):
4942+ tmp = TempDir()
4943+ inst = startup.DmediaCouch(tmp.dir)
4944+ machine_id = inst.create_machine()
4945+ user_id = inst.create_user()
4946+ inst.load_pki()
4947+ self.assertEqual(
4948+ inst.get_bootstrap_config(),
4949+ {
4950+ 'username': 'admin',
4951+ 'replicator': {
4952+ 'check_hostname': False,
4953+ 'max_depth': 1,
4954+ 'ca_file': inst.pki.path(user_id, 'ca'),
4955+ 'cert_file': inst.pki.path(machine_id, 'cert'),
4956+ 'key_file': inst.pki.path(machine_id, 'key'),
4957+ },
4958+ }
4959+ )
4960
4961 def test_auto_bootstrap(self):
4962 tmp = TempDir()
4963 inst = startup.DmediaCouch(tmp.dir)
4964- env = inst.auto_bootstrap()
4965- s = Server(env)
4966- self.assertEqual(s.get()['couchdb'], 'Welcome')
4967- self.assertIsNone(inst.get_ssl_config())
4968-
4969- tmp = TempDir()
4970- inst = startup.DmediaCouch(tmp.dir)
4971- inst.firstrun_init(create_user=False)
4972- env = inst.auto_bootstrap()
4973- s = Server(env)
4974- self.assertEqual(s.get()['couchdb'], 'Welcome')
4975- self.assertIsNone(inst.get_ssl_config())
4976-
4977- tmp = TempDir()
4978- inst = startup.DmediaCouch(tmp.dir)
4979- inst.firstrun_init(create_user=True)
4980+ inst.create_machine()
4981+ inst.create_user()
4982 env = inst.auto_bootstrap()
4983 s = Server(env)
4984 self.assertEqual(s.get()['couchdb'], 'Welcome')
4985@@ -249,3 +229,4 @@
4986 'key_file': inst.pki.machine.key_file,
4987 }
4988 )
4989+
4990
4991=== modified file 'run-browse.py'
4992--- run-browse.py 2012-10-09 04:05:45 +0000
4993+++ run-browse.py 2012-10-16 12:35:25 +0000
4994@@ -130,7 +130,8 @@
4995 class Browse:
4996 def __init__(self):
4997 self.couch = DmediaCouch(tempfile.mkdtemp())
4998- self.couch.firstrun_init(create_user=True)
4999+ self.couch.create_machine()
5000+ self.couch.create_user()
The diff has been truncated for viewing.

Subscribers

People subscribed via source and target branches