blob: 323345aeb831ce7a135cd1ec87033cf55d066d91 [file] [log] [blame]
Joe Gregorio8b4c1732011-12-06 11:28:29 -05001#!/usr/bin/python2.4
2# -*- coding: utf-8 -*-
3#
4# Copyright (C) 2011 Google Inc.
5#
6# Licensed under the Apache License, Version 2.0 (the "License");
7# you may not use this file except in compliance with the License.
8# You may obtain a copy of the License at
9#
10# http://www.apache.org/licenses/LICENSE-2.0
11#
12# Unless required by applicable law or agreed to in writing, software
13# distributed under the License is distributed on an "AS IS" BASIS,
14# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15# See the License for the specific language governing permissions and
16# limitations under the License.
17
18import base64
19import hashlib
20import logging
21import time
22
23from OpenSSL import crypto
24
25try: # pragma: no cover
26 import simplejson
27except ImportError: # pragma: no cover
28 try:
29 # Try to import from django, should work on App Engine
30 from django.utils import simplejson
31 except ImportError:
32 # Should work for Python2.6 and higher.
33 import json as simplejson
34
35
36CLOCK_SKEW_SECS = 300 # 5 minutes in seconds
37AUTH_TOKEN_LIFETIME_SECS = 300 # 5 minutes in seconds
38MAX_TOKEN_LIFETIME_SECS = 86400 # 1 day in seconds
39
40
41class AppIdentityError(Exception):
42 pass
43
44
45class Verifier(object):
46 """Verifies the signature on a message."""
47
48 def __init__(self, pubkey):
49 """Constructor.
50
51 Args:
52 pubkey, OpenSSL.crypto.PKey, The public key to verify with.
53 """
54 self._pubkey = pubkey
55
56 def verify(self, message, signature):
57 """Verifies a message against a signature.
58
59 Args:
60 message: string, The message to verify.
61 signature: string, The signature on the message.
62
63 Returns:
64 True if message was singed by the private key associated with the public
65 key that this object was constructed with.
66 """
67 try:
68 crypto.verify(self._pubkey, signature, message, 'sha256')
69 return True
70 except:
71 return False
72
73 @staticmethod
74 def from_string(key_pem, is_x509_cert):
75 """Construct a Verified instance from a string.
76
77 Args:
78 key_pem: string, public key in PEM format.
79 is_x509_cert: bool, True if key_pem is an X509 cert, otherwise it is
80 expected to be an RSA key in PEM format.
81
82 Returns:
83 Verifier instance.
84
85 Raises:
86 OpenSSL.crypto.Error if the key_pem can't be parsed.
87 """
88 if is_x509_cert:
89 pubkey = crypto.load_certificate(crypto.FILETYPE_PEM, key_pem)
90 else:
91 pubkey = crypto.load_privatekey(crypto.FILETYPE_PEM, key_pem)
92 return Verifier(pubkey)
93
94
95class Signer(object):
96 """Signs messages with a private key."""
97
98 def __init__(self, pkey):
99 """Constructor.
100
101 Args:
102 pkey, OpenSSL.crypto.PKey, The private key to sign with.
103 """
104 self._key = pkey
105
106 def sign(self, message):
107 """Signs a message.
108
109 Args:
110 message: string, Message to be signed.
111
112 Returns:
113 string, The signature of the message for the given key.
114 """
115 return crypto.sign(self._key, message, 'sha256')
116
117 @staticmethod
118 def from_string(key, password='notasecret'):
119 """Construct a Signer instance from a string.
120
121 Args:
122 key: string, private key in P12 format.
123 password: string, password for the private key file.
124
125 Returns:
126 Signer instance.
127
128 Raises:
129 OpenSSL.crypto.Error if the key can't be parsed.
130 """
131 pkey = crypto.load_pkcs12(key, password).get_privatekey()
132 return Signer(pkey)
133
134
135def _urlsafe_b64encode(raw_bytes):
136 return base64.urlsafe_b64encode(raw_bytes).rstrip('=')
137
138
139def _urlsafe_b64decode(b64string):
Joe Gregoriobd512b52011-12-06 15:39:26 -0500140 # Guard against unicode strings, which base64 can't handle.
141 b64string = b64string.encode('ascii')
Joe Gregorio8b4c1732011-12-06 11:28:29 -0500142 padded = b64string + '=' * (4 - len(b64string) % 4)
143 return base64.urlsafe_b64decode(padded)
144
145
146def _json_encode(data):
147 return simplejson.dumps(data, separators = (',', ':'))
148
149
150def make_signed_jwt(signer, payload):
151 """Make a signed JWT.
152
153 See http://self-issued.info/docs/draft-jones-json-web-token.html.
154
155 Args:
156 signer: crypt.Signer, Cryptographic signer.
157 payload: dict, Dictionary of data to convert to JSON and then sign.
158
159 Returns:
160 string, The JWT for the payload.
161 """
162 header = {'typ': 'JWT', 'alg': 'RS256'}
163
164 segments = [
165 _urlsafe_b64encode(_json_encode(header)),
166 _urlsafe_b64encode(_json_encode(payload)),
167 ]
168 signing_input = '.'.join(segments)
169
170 signature = signer.sign(signing_input)
171 segments.append(_urlsafe_b64encode(signature))
172
173 logging.debug(str(segments))
174
175 return '.'.join(segments)
176
177
178def verify_signed_jwt_with_certs(jwt, certs, audience):
179 """Verify a JWT against public certs.
180
181 See http://self-issued.info/docs/draft-jones-json-web-token.html.
182
183 Args:
184 jwt: string, A JWT.
185 certs: dict, Dictionary where values of public keys in PEM format.
186 audience: string, The audience, 'aud', that this JWT should contain. If
187 None then the JWT's 'aud' parameter is not verified.
188
189 Returns:
190 dict, The deserialized JSON payload in the JWT.
191
192 Raises:
193 AppIdentityError if any checks are failed.
194 """
195 segments = jwt.split('.')
196
197 if (len(segments) != 3):
198 raise AppIdentityError(
199 'Wrong number of segments in token: %s' % jwt)
200 signed = '%s.%s' % (segments[0], segments[1])
201
202 signature = _urlsafe_b64decode(segments[2])
203
204 # Parse token.
205 json_body = _urlsafe_b64decode(segments[1])
206 try:
207 parsed = simplejson.loads(json_body)
208 except:
209 raise AppIdentityError('Can\'t parse token: %s' % json_body)
210
211 # Check signature.
212 verified = False
213 for (keyname, pem) in certs.items():
214 verifier = Verifier.from_string(pem, True)
215 if (verifier.verify(signed, signature)):
216 verified = True
217 break
218 if not verified:
219 raise AppIdentityError('Invalid token signature: %s' % jwt)
220
221 # Check creation timestamp.
222 iat = parsed.get('iat')
223 if iat is None:
224 raise AppIdentityError('No iat field in token: %s' % json_body)
225 earliest = iat - CLOCK_SKEW_SECS
226
227 # Check expiration timestamp.
228 now = long(time.time())
229 exp = parsed.get('exp')
230 if exp is None:
231 raise AppIdentityError('No exp field in token: %s' % json_body)
232 if exp >= now + MAX_TOKEN_LIFETIME_SECS:
233 raise AppIdentityError(
234 'exp field too far in future: %s' % json_body)
235 latest = exp + CLOCK_SKEW_SECS
236
237 if now < earliest:
238 raise AppIdentityError('Token used too early, %d < %d: %s' %
239 (now, earliest, json_body))
240 if now > latest:
241 raise AppIdentityError('Token used too late, %d > %d: %s' %
242 (now, latest, json_body))
243
244 # Check audience.
245 if audience is not None:
246 aud = parsed.get('aud')
247 if aud is None:
248 raise AppIdentityError('No aud field in token: %s' % json_body)
249 if aud != audience:
250 raise AppIdentityError('Wrong recipient, %s != %s: %s' %
251 (aud, audience, json_body))
252
253 return parsed