a little nicer wrt pep8
[sfa.git] / sfa / client / sfaclientlib.py
1 # Thierry Parmentelat -- INRIA
2 """
3 a minimal library for writing "lightweight" SFA clients
4 """
5
6
7
8 # xxx todo
9 # this library should probably check for the expiration date of the various
10 # certificates and automatically retrieve fresh ones when expired
11
12 import sys
13 import os
14 import os.path
15 import subprocess
16 from datetime import datetime
17 from sfa.util.xrn import Xrn
18
19 import sfa.util.sfalogging
20 # importing sfa.utils.faults does pull a lot of stuff
21 # OTOH it's imported from Certificate anyways, so..
22 from sfa.util.faults import RecordNotFound
23
24 from sfa.client.sfaserverproxy import SfaServerProxy
25
26 # see optimizing dependencies below
27 from sfa.trust.certificate import Keypair, Certificate
28 from sfa.trust.credential import Credential
29 from sfa.trust.gid import GID
30 ##########
31 # a helper class to implement the bootstrapping of crypto. material
32 # assuming we are starting from scratch on the client side
33 # what's needed to complete a full slice creation cycle
34 # (**) prerequisites:
35 #  (*) a local private key
36 #  (*) the corresp. public key in the registry
37 # (**) step1: a self-signed certificate
38 #      default filename is <hrn>.sscert
39 # (**) step2: a user credential
40 #      obtained at the registry with GetSelfCredential
41 #      using the self-signed certificate as the ssl cert
42 #      default filename is <hrn>.user.cred
43 # (**) step3: a registry-provided certificate (i.e. a GID)
44 #      obtained at the registry using Resolve
45 #      using the step2 credential as credential
46 #      default filename is <hrn>.user.gid
47 #
48 # From that point on, the GID is used as the SSL certificate
49 # and the following can be done
50 #
51 # (**) retrieve a slice (or authority) credential
52 #      obtained at the registry with GetCredential
53 #      using the (step2) user-credential as credential
54 #      default filename is <hrn>.<type>.cred
55 # (**) retrieve a slice (or authority) GID
56 #      obtained at the registry with Resolve
57 #      using the (step2) user-credential as credential
58 #      default filename is <hrn>.<type>.cred
59 #
60 # (**) additionnally, it might make sense to upgrade a GID file
61 # into a pkcs12 certificate usable in a browser
62 # this bundled format allows for embedding the private key
63 #
64
65 # Implementation notes
66 #
67 # (*) decorators
68 #
69 # this implementation is designed as a guideline for
70 # porting to other languages
71 #
72 # the decision to go for decorators aims at focusing
73 # on the core of what needs to be done when everything
74 # works fine, and to take caching and error management
75 # out of the way
76 #
77 # for non-pythonic developers, it should be enough to
78 # implement the bulk of this code, namely the _produce methods
79 # and to add caching and error management by whichever means
80 # is available, including inline
81 #
82 # (*) self-signed certificates
83 #
84 # still with other languages in mind, we've tried to keep the
85 # dependencies to the rest of the code as low as possible
86 #
87 # however this still relies on the sfa.trust.certificate module
88 # for the initial generation of a self-signed-certificate that
89 # is associated to the user's ssh-key
90 # (for user-friendliness, and for smooth operations with planetlab,
91 # the usage model is to reuse an existing keypair)
92 #
93 # there might be a more portable, i.e. less language-dependant way, to
94 # implement this step by exec'ing the openssl command.
95 # a known successful attempt at this approach that worked
96 # for Java is documented below
97 # http://nam.ece.upatras.gr/fstoolkit/trac/wiki/JavaSFAClient
98 #
99 # (*) pkcs12
100 #
101 # the implementation of the pkcs12 wrapping, which is a late addition,
102 # is done through direct calls to openssl
103 #
104 ####################
105
106
107 class SfaClientException(Exception):
108     pass
109
110
111 class SfaClientBootstrap:
112
113     # dir is mandatory but defaults to '.'
114     def __init__(self, user_hrn, registry_url, dir=None,
115                  verbose=False, timeout=None, logger=None):
116         self.hrn = user_hrn
117         self.registry_url = registry_url
118         if dir is None:
119             dir = "."
120         self.dir = dir
121         self.verbose = verbose
122         self.timeout = timeout
123         # default for the logger is to use the global sfa logger
124         if logger is None:
125             logger = sfa.util.sfalogging.logger
126         self.logger = logger
127
128     # *_produce methods
129     # step1
130     # unconditionnally create a self-signed certificate
131     def self_signed_cert_produce(self, output):
132         self.assert_private_key()
133         private_key_filename = self.private_key_filename()
134         keypair = Keypair(filename=private_key_filename)
135         self_signed = Certificate(subject=self.hrn)
136         self_signed.set_pubkey(keypair)
137         self_signed.set_issuer(keypair, self.hrn)
138         self_signed.sign()
139         self_signed.save_to_file(output)
140         self.logger.debug("SfaClientBootstrap: Created self-signed certificate for {} in {}"
141                           .format(self.hrn, output))
142         return output
143
144     # step2
145     # unconditionnally retrieve my credential (GetSelfCredential)
146     # we always use the self-signed-cert as the SSL cert
147     def my_credential_produce(self, output):
148         self.assert_self_signed_cert()
149         certificate_filename = self.self_signed_cert_filename()
150         certificate_string = self.plain_read(certificate_filename)
151         self.assert_private_key()
152         registry_proxy = SfaServerProxy(self.registry_url,
153                                         self.private_key_filename(),
154                                         certificate_filename)
155         try:
156             credential_string = registry_proxy.GetSelfCredential(
157                 certificate_string, self.hrn, "user")
158         except:
159             # some urns hrns may replace non hierarchy delimiters '.' with an
160             # '_' instead of escaping the '.'
161             hrn = Xrn(self.hrn).get_hrn().replace('\.', '_')
162             credential_string = registry_proxy.GetSelfCredential(
163                 certificate_string, hrn, "user")
164         self.plain_write(output, credential_string)
165         self.logger.debug(
166             "SfaClientBootstrap: Wrote result of GetSelfCredential in {}".format(output))
167         return output
168
169     # step3
170     # unconditionnally retrieve my GID - use the general form
171     def my_gid_produce(self, output):
172         return self.gid_produce(output, self.hrn, "user")
173
174     # retrieve any credential (GetCredential) unconditionnal form
175     # we always use the GID as the SSL cert
176     def credential_produce(self, output, hrn, type):
177         self.assert_my_gid()
178         certificate_filename = self.my_gid_filename()
179         self.assert_private_key()
180         registry_proxy = SfaServerProxy(self.registry_url, self.private_key_filename(),
181                                         certificate_filename)
182         self.assert_my_credential()
183         my_credential_string = self.my_credential_string()
184         credential_string = registry_proxy.GetCredential(
185             my_credential_string, hrn, type)
186         self.plain_write(output, credential_string)
187         self.logger.debug(
188             "SfaClientBootstrap: Wrote result of GetCredential in {}".format(output))
189         return output
190
191     def slice_credential_produce(self, output, hrn):
192         return self.credential_produce(output, hrn, "slice")
193
194     def authority_credential_produce(self, output, hrn):
195         return self.credential_produce(output, hrn, "authority")
196
197     # retrieve any gid(Resolve) - unconditionnal form
198     # use my GID when available as the SSL cert, otherwise the self-signed
199     def gid_produce(self, output, hrn, type):
200         try:
201             self.assert_my_gid()
202             certificate_filename = self.my_gid_filename()
203         except:
204             self.assert_self_signed_cert()
205             certificate_filename = self.self_signed_cert_filename()
206
207         self.assert_private_key()
208         registry_proxy = SfaServerProxy(self.registry_url, self.private_key_filename(),
209                                         certificate_filename)
210         credential_string = self.plain_read(self.my_credential())
211         records = registry_proxy.Resolve(hrn, credential_string)
212         records = [record for record in records if record['type'] == type]
213         if not records:
214             raise RecordNotFound("hrn {} ({}) unknown to registry {}".format(
215                 hrn, type, self.registry_url))
216         record = records[0]
217         self.plain_write(output, record['gid'])
218         self.logger.debug(
219             "SfaClientBootstrap: Wrote GID for {} ({}) in {}".format(hrn, type, output))
220         return output
221
222
223 # http://trac.myslice.info/wiki/MySlice/Developer/SFALogin
224 # produce a pkcs12 bundled certificate from GID and private key
225 # xxx for now we put a hard-wired password that's just, well, 'password'
226 # when leaving this empty on the mac, result can't seem to be loaded in
227 # keychain..
228     def my_pkcs12_produce(self, filename):
229         password = input("Enter password for p12 certificate: ")
230         openssl_command = ['openssl', 'pkcs12', "-export"]
231         openssl_command += ["-password", "pass:{}".format(password)]
232         openssl_command += ["-inkey", self.private_key_filename()]
233         openssl_command += ["-in",    self.my_gid_filename()]
234         openssl_command += ["-out",   filename]
235         if subprocess.call(openssl_command) == 0:
236             print("Successfully created {}".format(filename))
237         else:
238             print("Failed to create {}".format(filename))
239
240     # Returns True if credential file is valid. Otherwise return false.
241     def validate_credential(self, filename):
242         valid = True
243         cred = Credential(filename=filename)
244         # check if credential is expires
245         if cred.get_expiration() < datetime.utcnow():
246             valid = False
247         return valid
248
249     # public interface
250
251     # return my_gid, run all missing steps in the bootstrap sequence
252     def bootstrap_my_gid(self):
253         self.self_signed_cert()
254         self.my_credential()
255         return self.my_gid()
256
257     # once we've bootstrapped we can use this object to issue any other SFA call
258     # always use my gid
259     def server_proxy(self, url):
260         self.assert_my_gid()
261         return SfaServerProxy(
262             url, self.private_key_filename(), self.my_gid_filename(),
263             verbose=self.verbose, timeout=self.timeout)
264
265     # now in some cases the self-signed is enough
266     def server_proxy_simple(self, url):
267         self.assert_self_signed_cert()
268         return SfaServerProxy(
269             url, self.private_key_filename(), self.self_signed_cert_filename(),
270             verbose=self.verbose, timeout=self.timeout)
271
272     # this method can optionnally be invoked to ensure proper
273     # installation of the private key that belongs to this user
274     # installs private_key in working dir with expected name -- preserve mode
275     # typically user_private_key would be ~/.ssh/id_rsa
276     # xxx should probably check the 2 files are identical
277     def init_private_key_if_missing(self, user_private_key):
278         private_key_filename = self.private_key_filename()
279         if not os.path.isfile(private_key_filename):
280             key = self.plain_read(user_private_key)
281             self.plain_write(private_key_filename, key)
282             os.chmod(private_key_filename, os.stat(user_private_key).st_mode)
283             self.logger.debug(
284                 "SfaClientBootstrap: Copied private key from {} into {}"
285                 .format(user_private_key, private_key_filename))
286
287     # private details
288     # stupid stuff
289     def fullpath(self, file):
290         return os.path.join(self.dir, file)
291
292     # the expected filenames for the various pieces
293     def private_key_filename(self):
294         return self.fullpath("{}.pkey".format(Xrn.unescape(self.hrn)))
295
296     def self_signed_cert_filename(self):
297         return self.fullpath("{}.sscert".format(self.hrn))
298
299     def my_credential_filename(self):
300         return self.credential_filename(self.hrn, "user")
301     # the tests use sfi -u <pi-user>; meaning that the slice credential filename
302     # needs to keep track of the user too
303
304     def credential_filename(self, hrn, type):
305         if type in ['user']:
306             basename = "{}.{}.cred".format(hrn, type)
307         else:
308             basename = "{}-{}.{}.cred".format(self.hrn, hrn, type)
309         return self.fullpath(basename)
310
311     def slice_credential_filename(self, hrn):
312         return self.credential_filename(hrn, 'slice')
313
314     def authority_credential_filename(self, hrn):
315         return self.credential_filename(hrn, 'authority')
316
317     def my_gid_filename(self):
318         return self.gid_filename(self.hrn, "user")
319
320     def gid_filename(self, hrn, type):
321         return self.fullpath("{}.{}.gid".format(hrn, type))
322
323     def my_pkcs12_filename(self):
324         return self.fullpath("{}.p12".format(self.hrn))
325
326 # optimizing dependencies
327 # originally we used classes GID or Credential or Certificate
328 # like e.g.
329 #        return Credential(filename=self.my_credential()).save_to_string()
330 # but in order to make it simpler to other implementations/languages..
331     def plain_read(self, filename):
332         with open(filename) as infile:
333             return infile.read()
334
335     def plain_write(self, filename, contents):
336         with open(filename, "w") as outfile:
337             outfile.write(contents)
338
339     def assert_filename(self, filename, kind):
340         if not os.path.isfile(filename):
341             raise IOError("Missing {} file {}".format(kind, filename))
342         return True
343
344     def assert_private_key(self):
345         return self.assert_filename(self.private_key_filename(), "private key")
346
347     def assert_self_signed_cert(self):
348         return self.assert_filename(self.self_signed_cert_filename(),
349                                     "self-signed certificate")
350
351     def assert_my_credential(self):
352         return self.assert_filename(self.my_credential_filename(),
353                                     "user's credential")
354
355     def assert_my_gid(self):
356         return self.assert_filename(self.my_gid_filename(), "user's GID")
357
358     # decorator to make up the other methods
359     def get_or_produce(filename_method, produce_method, validate_method=None):
360         # default validator returns true
361         def wrap(f):
362             def wrapped(self, *args, **kw):
363                 filename = filename_method(self, *args, **kw)
364                 if os.path.isfile(filename):
365                     if not validate_method:
366                         return filename
367                     elif validate_method(self, filename):
368                         return filename
369                     else:
370                         # remove invalid file
371                         self.logger.warning(
372                             "Removing {} - has expired".format(filename))
373                         os.unlink(filename)
374                 try:
375                     produce_method(self, filename, *args, **kw)
376                     return filename
377                 except IOError:
378                     raise
379                 except:
380                     error = sys.exc_info()[:2]
381                     message = "Could not produce/retrieve {} ({} -- {})"\
382                               .format(filename, error[0], error[1])
383                     self.logger.log_exc(message)
384                     raise Exception(message)
385             return wrapped
386         return wrap
387
388     @get_or_produce(self_signed_cert_filename, self_signed_cert_produce)
389     def self_signed_cert(self): pass
390
391     @get_or_produce(my_credential_filename, my_credential_produce, validate_credential)
392     def my_credential(self): pass
393
394     @get_or_produce(my_gid_filename, my_gid_produce)
395     def my_gid(self): pass
396
397     @get_or_produce(my_pkcs12_filename, my_pkcs12_produce)
398     def my_pkcs12(self): pass
399
400     @get_or_produce(credential_filename, credential_produce, validate_credential)
401     def credential(self, hrn, type): pass
402
403     @get_or_produce(slice_credential_filename, slice_credential_produce, validate_credential)
404     def slice_credential(self, hrn): pass
405
406     @get_or_produce(authority_credential_filename, authority_credential_produce, validate_credential)
407     def authority_credential(self, hrn): pass
408
409     @get_or_produce(gid_filename, gid_produce)
410     def gid(self, hrn, type): pass
411
412     # get the credentials as strings, for inserting as API arguments
413     def my_credential_string(self):
414         self.my_credential()
415         return self.plain_read(self.my_credential_filename())
416
417     def slice_credential_string(self, hrn):
418         self.slice_credential(hrn)
419         return self.plain_read(self.slice_credential_filename(hrn))
420
421     def authority_credential_string(self, hrn):
422         self.authority_credential(hrn)
423         return self.plain_read(self.authority_credential_filename(hrn))
424
425     # for consistency
426     def private_key(self):
427         self.assert_private_key()
428         return self.private_key_filename()
429
430     def delegate_credential_string(self, original_credential, to_hrn, to_type='authority'):
431         """
432         sign a delegation credential to someone else
433
434         original_credential : typically one's user- or slice- credential to be delegated to s/b else
435         to_hrn : the hrn of the person that will be allowed to do stuff on our behalf
436         to_type : goes with to_hrn, usually 'user' or 'authority'
437
438         returns a string with the delegated credential
439
440         this internally uses self.my_gid()
441         it also retrieves the gid for to_hrn/to_type
442         and uses Credential.delegate()"""
443
444         # the gid and hrn of the object we are delegating
445         if isinstance(original_credential, str):
446             original_credential = Credential(string=original_credential)
447         original_gid = original_credential.get_gid_object()
448         original_hrn = original_gid.get_hrn()
449
450         if not original_credential.get_privileges().get_all_delegate():
451             self.logger.error("delegate_credential_string: original credential {} does not have delegate bit set"
452                               .format(original_hrn))
453             return
454
455         # the delegating user's gid
456         my_gid = self.my_gid()
457
458         # retrieve the GID for the entity that we're delegating to
459         to_gidfile = self.gid(to_hrn, to_type)
460 #        to_gid = GID(to_gidfile )
461 #        to_hrn = delegee_gid.get_hrn()
462 #        print 'to_hrn', to_hrn
463         delegated_credential = original_credential.delegate(
464             to_gidfile, self.private_key(), my_gid)
465         return delegated_credential.save_to_string(save_parents=True)