Merge lp:~canonical-isd-hackers/canonical-identity-provider/yubi-internal-validation into lp:~canonical-isd-hackers/canonical-identity-provider/add-yubikey-support

Proposed by Łukasz Czyżykowski
Status: Merged
Approved by: Michael Foord
Approved revision: no longer in the source branch.
Merged at revision: 163
Proposed branch: lp:~canonical-isd-hackers/canonical-identity-provider/yubi-internal-validation
Merge into: lp:~canonical-isd-hackers/canonical-identity-provider/add-yubikey-support
Diff against target: 332 lines (+288/-1)
5 files modified
identityprovider/admin.py (+9/-1)
identityprovider/models/__init__.py (+1/-0)
identityprovider/models/yubi.py (+121/-0)
identityprovider/tests/test_models_yubi.py (+156/-0)
requirements.txt (+1/-0)
To merge this branch: bzr merge lp:~canonical-isd-hackers/canonical-identity-provider/yubi-internal-validation
Reviewer Review Type Date Requested Status
Michael Foord (community) Approve
Review via email: mp+60625@code.launchpad.net

Commit message

Added support for internal OTP validation.

Description of the change

Overview
========
This branch adds support for internal validation of OTPs.

Details
=======
Added new model ``identityprovider.models.yubi.YubiKey`` which
contains ``validate_otp(otp)`` class method. This will return boolean
if the OTP is valid for the keys registered in the server.

Also added full test suite, including code to programatically
generating OTPs.

To post a comment you must log in.
Revision history for this message
Michael Foord (mfoord) wrote :

Nice work.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'identityprovider/admin.py'
2--- identityprovider/admin.py 2010-11-11 16:14:06 +0000
3+++ identityprovider/admin.py 2011-05-12 08:45:28 +0000
4@@ -8,7 +8,7 @@
5 from identityprovider.const import SREG_LABELS
6 from identityprovider.fields import CommaSeparatedField
7 from identityprovider.models import (Account, AccountPassword, EmailAddress,
8- OpenIDRPConfig, APIUser)
9+ OpenIDRPConfig, APIUser, YubiKey)
10 from identityprovider.models.const import AccountStatus, EmailStatus
11 from identityprovider.utils import encrypt_launchpad_password
12 from identityprovider.widgets import (StatusWidget, ReadOnlyDateTimeWidget,
13@@ -215,3 +215,11 @@
14 admin.site.register(OpenIDRPConfig, OpenIDRPConfigAdmin)
15 admin.site.register(APIUser, APIUserAdmin)
16 admin.site.register(EmailAddress, EmailAddressAdmin)
17+
18+
19+class YubiKeyAdmin(admin.ModelAdmin):
20+ list_display = ("publicname", "created", "active")
21+ search_fields = ("publicname",)
22+ list_filter = ("active", "created")
23+
24+admin.site.register(YubiKey, YubiKeyAdmin)
25
26=== modified file 'identityprovider/models/__init__.py'
27--- identityprovider/models/__init__.py 2011-05-10 14:32:26 +0000
28+++ identityprovider/models/__init__.py 2011-05-12 08:45:28 +0000
29@@ -10,3 +10,4 @@
30 from openidmodels import *
31 from team import *
32 from api import *
33+from yubi import *
34
35=== added file 'identityprovider/models/yubi.py'
36--- identityprovider/models/yubi.py 1970-01-01 00:00:00 +0000
37+++ identityprovider/models/yubi.py 2011-05-12 08:45:28 +0000
38@@ -0,0 +1,121 @@
39+# Copyright 2010 Canonical Ltd. This software is licensed under the
40+# GNU Affero General Public License version 3 (see the file LICENSE).
41+
42+import re
43+
44+from Crypto.Cipher import AES
45+from string import maketrans
46+from django.db import models
47+
48+
49+class OTPValidator(object):
50+
51+ otp_re = re.compile(
52+ r'''
53+ (?P<userid>[cbdefghijklnrtuv]{0,16})
54+ (?P<token>[cbdefghijklnrtuv]{32})
55+ ''',
56+ re.VERBOSE
57+ )
58+
59+ def validate(self, otp):
60+ if len(otp) > 48 or len(otp) < 32:
61+ return False
62+ match = self.otp_re.match(otp)
63+ if not match:
64+ return False
65+
66+ userid = match.groupdict()['userid']
67+ token = self.modhex2hex(match.groupdict()['token'])
68+
69+ yks = YubiKey.objects.filter(publicname=userid, active=True)
70+ if yks.count() != 1:
71+ return False
72+
73+ yk = yks[0]
74+
75+ plaintext = self.aes128ecb_decrypt(yk.aeskey, token)
76+ uid = plaintext[:12]
77+
78+ if yk.internalname != uid:
79+ return False
80+
81+ if not self.crc_is_valid(plaintext):
82+ return False
83+
84+ internal_counter = self.hexdec(
85+ plaintext[14:16] + plaintext[12:14])
86+ timestamp = self.hexdec(
87+ plaintext[20:22] + plaintext[18:20] + plaintext[16:18])
88+
89+ if yk.counter >= internal_counter:
90+ return False
91+
92+ if yk.time >= timestamp:
93+ if (yk.counter >> 8) == (internal_counter >> 8):
94+ return False
95+
96+ yk.counter = internal_counter
97+ yk.time = timestamp
98+ yk.save()
99+
100+ return True
101+
102+ def modhex2hex(self, string):
103+ return string.translate(
104+ maketrans("cbdefghijklnrtuv", "0123456789abcdef"))
105+
106+ def hexdec(self, hexstr):
107+ return int(hexstr, 16)
108+
109+ def crc(self, s):
110+ crc = 0xffff
111+ for i in range(0, 16):
112+ b = self.hexdec(s[i * 2] + s[(i * 2) + 1])
113+ crc = crc ^ (b & 0xff)
114+ for j in range(0, 8):
115+ n = crc & 1
116+ crc = crc >> 1
117+ if n != 0:
118+ crc = crc ^ 0x8408
119+ return crc
120+
121+ def crc_is_valid(self, token):
122+ crc = self.crc(token)
123+ return crc == 0xf0b8
124+
125+ def aes128ecb_decrypt(self, aeskey, aesdata):
126+ aes = AES.new(aeskey.decode('hex'), AES.MODE_ECB)
127+ return aes.decrypt(aesdata.decode('hex')).encode('hex')
128+
129+
130+
131+class YubiKey(models.Model):
132+
133+ publicname = models.CharField(
134+ max_length=12, unique=True,
135+ help_text="The key public identity (the beginning of the OTPs)")
136+ created = models.DateTimeField(auto_now_add=True, editable=False)
137+ internalname = models.CharField(
138+ max_length=12,
139+ help_text="The private identity of the OTP")
140+ aeskey = models.CharField(max_length=32, help_text="AES Key")
141+ active = models.BooleanField(default=True)
142+ counter = models.IntegerField(default=1, editable=False)
143+ time = models.IntegerField(default=1, editable=False)
144+
145+ class Meta:
146+ app_label = 'identityprovider'
147+ db_table = u'yubikey'
148+
149+ @classmethod
150+ def sanitize_otp(cls, otp):
151+ if re.match(r"[jxe.uidchtnbpygk]+", otp):
152+ return otp.translate(
153+ maketrans("jxe.uidchtnbpygk", "cbdefghijklnrtuv"))
154+ return otp
155+
156+ @classmethod
157+ def validate_otp(cls, otp):
158+ otp = cls.sanitize_otp(otp)
159+ return OTPValidator().validate(otp)
160
161=== added file 'identityprovider/tests/test_models_yubi.py'
162--- identityprovider/tests/test_models_yubi.py 1970-01-01 00:00:00 +0000
163+++ identityprovider/tests/test_models_yubi.py 2011-05-12 08:45:28 +0000
164@@ -0,0 +1,156 @@
165+# Copyright 2010 Canonical Ltd. This software is licensed under the
166+# GNU Affero General Public License version 3 (see the file LICENSE).
167+
168+from random import randint
169+from Crypto.Cipher import AES
170+from string import maketrans
171+
172+from django.test import TestCase
173+
174+from identityprovider.models.yubi import OTPValidator, YubiKey
175+
176+
177+
178+class OTPValidatorTestCase(TestCase):
179+
180+ def setUp(self):
181+ self.validator = OTPValidator()
182+
183+ def test_validate_otp_fails_too_short(self):
184+ self.assertFalse(self.validator.validate('cbdefghijklnrtuv'))
185+
186+ def test_validate_otp_fails_too_long(self):
187+ self.assertFalse(self.validator.validate('cbdefghijklnrtuv' * 10))
188+
189+ def test_validate_otp_fails_match_regexp(self):
190+ self.assertFalse(self.validator.validate('a' * 44))
191+
192+ def test_modhex2hex(self):
193+ self.assertEquals(self.validator.modhex2hex("fibb"), "4711")
194+
195+ def test_hexdec(self):
196+ self.assertEquals(self.validator.hexdec("ff"), 255)
197+ self.assertEquals(self.validator.hexdec("0a"), 10)
198+
199+
200+
201+class YubiKeyTestCase(TestCase):
202+
203+ def compute_crc(self, plaintext):
204+ crc = 0xffff
205+ for v in plaintext:
206+ crc ^= ord(v)
207+ for i in range(0, 8):
208+ n = crc & 1
209+ crc >>= 1
210+ if n != 0:
211+ crc ^= 0x8408
212+ crc = ~crc & 0xffff
213+ return "%02x%02x" % (crc & 0xff, crc >> 8)
214+
215+ def create_otp(self, key, counter=None, time=None, session=1):
216+ counter = (key.counter + 1) if counter is None else counter
217+ time = (key.time + 1) if time is None else time
218+
219+ counter = counter
220+ plaintext = "".join([
221+ "%12s" % key.internalname,
222+ "%02x" % (counter & 0xff), # 12:14
223+ "%02x" % ((counter >> 8) & 0xff), # 14:16
224+ "%02x" % (time & 0xff), # 16:18
225+ "%02x" % ((time & 0xff00) >> 8), # 18:20
226+ "%02x" % ((time & 0xff0000) >> 16), # 20:22
227+ "%02x" % (session & 0xff), # 22:24
228+ "%04x" % randint(0, 0xFFFF) # 24:28
229+ ])
230+
231+ crc = self.compute_crc(plaintext.decode("hex"))
232+ plaintext = plaintext + crc
233+
234+ assert len(plaintext) == 32
235+
236+ aes = AES.new(key.aeskey.decode("hex"), AES.MODE_ECB)
237+ token = aes.encrypt(plaintext.decode("hex")).encode("hex")
238+
239+ assert len(token) == 32
240+
241+ token = token.translate(
242+ maketrans("0123456789abcdef", "cbdefghijklnrtuv"))
243+ otp = key.publicname + token
244+
245+ assert len(otp) == 44
246+
247+ return otp
248+
249+ def create_key(self, publicname=None, internalname=None, aeskey=None,
250+ **kwargs):
251+ defaults = {}
252+ if publicname is None:
253+ defaults['publicname'] = "vvkdtkjureru"
254+
255+ if internalname is None:
256+ defaults['internalname'] = "980a8608b307"
257+
258+ if aeskey is None:
259+ defaults['aeskey'] = "f1dc9c6585d600d06f9aae1abea2969e"
260+
261+ defaults.update(kwargs)
262+ return YubiKey.objects.create(**defaults)
263+
264+ def test_otp_validates_properly(self):
265+ self.create_key()
266+
267+ # The otp below is generated by yubikey dongle
268+ self.assertTrue(YubiKey.validate_otp(
269+ "vvkdtkjurerunkdcnkinjvgidtttggvegjdekdvhugvf"))
270+
271+ def test_otp_fails_because_key_is_not_in_the_db(self):
272+ self.assertFalse(YubiKey.validate_otp(
273+ "vvkdtkjurerunkdcnkinjvgidtttggvegjdekdvhugvf"))
274+
275+ def test_otp_fails_uid_doesnt_match(self):
276+ self.create_key(internalname="980a8608b307")
277+
278+ self.assertFalse(YubiKey.validate_otp(
279+ "vvkdtkjurerunkdcnkinjvgidtttggvegjdekdvhugvf"))
280+
281+ def test_otp_fails_on_crc_validation(self):
282+ key = self.create_key()
283+ otp = self.create_otp(key)
284+ # Setting obviously invalid CRC bit
285+ otp = otp[:-4] + "cccc"
286+ self.assertFalse(YubiKey.validate_otp(otp))
287+
288+ def test_otp_fails_if_counter_is_too_late(self):
289+ key = self.create_key(counter=5001)
290+ otp = self.create_otp(key, counter=5000)
291+
292+ self.assertFalse(YubiKey.validate_otp(otp))
293+
294+ def test_otp_fails_if_timestamp_is_too_late(self):
295+ key = self.create_key(time=10)
296+ otp = self.create_otp(key, time=9)
297+
298+ self.assertFalse(YubiKey.validate_otp(otp))
299+
300+ def test_otp_fails_if_used_twice(self):
301+ key = self.create_key()
302+ otp = self.create_otp(key)
303+
304+ self.assertTrue(YubiKey.validate_otp(otp))
305+ self.assertFalse(YubiKey.validate_otp(otp))
306+
307+ def test_otp_validates_when_entered_with_dvorak(self):
308+ self.create_key()
309+ otp = 'kkteythgp.pgpedkucydtbxkpkdkpgxejubytiuubntb'
310+ self.assertTrue(YubiKey.validate_otp(otp))
311+
312+ def test_sanitize_otp_leaves_alone_normal_otp(self):
313+ otp = "vvkdtkjurerunkdcnkinjvgidtttggvegjdekdvhugvf"
314+ self.assertEquals(YubiKey.sanitize_otp(otp), otp)
315+
316+ def test_sanitize_otp_properly_handles_dvorak_keyboard(self):
317+ otp = 'kkteythgp.pgpedkucydtbxkpkdkpgxejubytiuubntb'
318+ self.assertEquals(
319+ YubiKey.sanitize_otp(otp),
320+ 'vvkdtkjurerurdhvfithknbvrvhvrubdcfntkgffnlkn')
321
322=== modified file 'requirements.txt'
323--- requirements.txt 2011-03-04 16:49:25 +0000
324+++ requirements.txt 2011-05-12 08:45:28 +0000
325@@ -44,6 +44,7 @@
326 zope.tales==3.5.0
327 zope.testing==3.8.3
328 zope.traversing==3.12.0
329+pycrypto==2.3
330
331 # project dependencies
332 -f http://launchpad.net/lazr.restfulclient/trunk/0.9.11/+download/lazr.restfulclient-0.9.11.tar.gz#egg=lazr.restfulclient-0.9.11

Subscribers

People subscribed via source and target branches

to all changes: