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