5557b95a0018b84666598fd8ea2dbd1889333c0b
[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 cryptoa. 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(url, self.private_key_filename(), self.my_gid_filename(),
262                               verbose=self.verbose, timeout=self.timeout)
263
264     # now in some cases the self-signed is enough
265     def server_proxy_simple(self, url):
266         self.assert_self_signed_cert()
267         return SfaServerProxy(url, self.private_key_filename(), self.self_signed_cert_filename(),
268                               verbose=self.verbose, timeout=self.timeout)
269
270     # this method can optionnally be invoked to ensure proper
271     # installation of the private key that belongs to this user
272     # installs private_key in working dir with expected name -- preserve mode
273     # typically user_private_key would be ~/.ssh/id_rsa
274     # xxx should probably check the 2 files are identical
275     def init_private_key_if_missing(self, user_private_key):
276         private_key_filename = self.private_key_filename()
277         if not os.path.isfile(private_key_filename):
278             key = self.plain_read(user_private_key)
279             self.plain_write(private_key_filename, key)
280             os.chmod(private_key_filename, os.stat(user_private_key).st_mode)
281             self.logger.debug("SfaClientBootstrap: Copied private key from {} into {}"
282                               .format(user_private_key, private_key_filename))
283
284     # private details
285     # stupid stuff
286     def fullpath(self, file):
287         return os.path.join(self.dir, file)
288
289     # the expected filenames for the various pieces
290     def private_key_filename(self):
291         return self.fullpath("{}.pkey".format(Xrn.unescape(self.hrn)))
292
293     def self_signed_cert_filename(self):
294         return self.fullpath("{}.sscert".format(self.hrn))
295
296     def my_credential_filename(self):
297         return self.credential_filename(self.hrn, "user")
298     # the tests use sfi -u <pi-user>; meaning that the slice credential filename
299     # needs to keep track of the user too
300
301     def credential_filename(self, hrn, type):
302         if type in ['user']:
303             basename = "{}.{}.cred".format(hrn, type)
304         else:
305             basename = "{}-{}.{}.cred".format(self.hrn, hrn, type)
306         return self.fullpath(basename)
307
308     def slice_credential_filename(self, hrn):
309         return self.credential_filename(hrn, 'slice')
310
311     def authority_credential_filename(self, hrn):
312         return self.credential_filename(hrn, 'authority')
313
314     def my_gid_filename(self):
315         return self.gid_filename(self.hrn, "user")
316
317     def gid_filename(self, hrn, type):
318         return self.fullpath("{}.{}.gid".format(hrn, type))
319
320     def my_pkcs12_filename(self):
321         return self.fullpath("{}.p12".format(self.hrn))
322
323 # optimizing dependencies
324 # originally we used classes GID or Credential or Certificate
325 # like e.g.
326 #        return Credential(filename=self.my_credential()).save_to_string()
327 # but in order to make it simpler to other implementations/languages..
328     def plain_read(self, filename):
329         with open(filename) as infile:
330             return infile.read()
331
332     def plain_write(self, filename, contents):
333         with open(filename, "w") as outfile:
334             outfile.write(contents)
335
336     def assert_filename(self, filename, kind):
337         if not os.path.isfile(filename):
338             raise IOError("Missing {} file {}".format(kind, filename))
339         return True
340
341     def assert_private_key(self):
342         return self.assert_filename(self.private_key_filename(), "private key")
343
344     def assert_self_signed_cert(self):
345         return self.assert_filename(self.self_signed_cert_filename(), "self-signed certificate")
346
347     def assert_my_credential(self):
348         return self.assert_filename(self.my_credential_filename(), "user's credential")
349
350     def assert_my_gid(self):
351         return self.assert_filename(self.my_gid_filename(), "user's GID")
352
353     # decorator to make up the other methods
354     def get_or_produce(filename_method, produce_method, validate_method=None):
355         # default validator returns true
356         def wrap(f):
357             def wrapped(self, *args, **kw):
358                 filename = filename_method(self, *args, **kw)
359                 if os.path.isfile(filename):
360                     if not validate_method:
361                         return filename
362                     elif validate_method(self, filename):
363                         return filename
364                     else:
365                         # remove invalid file
366                         self.logger.warning(
367                             "Removing {} - has expired".format(filename))
368                         os.unlink(filename)
369                 try:
370                     produce_method(self, filename, *args, **kw)
371                     return filename
372                 except IOError:
373                     raise
374                 except:
375                     error = sys.exc_info()[:2]
376                     message = "Could not produce/retrieve {} ({} -- {})"\
377                               .format(filename, error[0], error[1])
378                     self.logger.log_exc(message)
379                     raise Exception(message)
380             return wrapped
381         return wrap
382
383     @get_or_produce(self_signed_cert_filename, self_signed_cert_produce)
384     def self_signed_cert(self): pass
385
386     @get_or_produce(my_credential_filename, my_credential_produce, validate_credential)
387     def my_credential(self): pass
388
389     @get_or_produce(my_gid_filename, my_gid_produce)
390     def my_gid(self): pass
391
392     @get_or_produce(my_pkcs12_filename, my_pkcs12_produce)
393     def my_pkcs12(self): pass
394
395     @get_or_produce(credential_filename, credential_produce, validate_credential)
396     def credential(self, hrn, type): pass
397
398     @get_or_produce(slice_credential_filename, slice_credential_produce, validate_credential)
399     def slice_credential(self, hrn): pass
400
401     @get_or_produce(authority_credential_filename, authority_credential_produce, validate_credential)
402     def authority_credential(self, hrn): pass
403
404     @get_or_produce(gid_filename, gid_produce)
405     def gid(self, hrn, type): pass
406
407     # get the credentials as strings, for inserting as API arguments
408     def my_credential_string(self):
409         self.my_credential()
410         return self.plain_read(self.my_credential_filename())
411
412     def slice_credential_string(self, hrn):
413         self.slice_credential(hrn)
414         return self.plain_read(self.slice_credential_filename(hrn))
415
416     def authority_credential_string(self, hrn):
417         self.authority_credential(hrn)
418         return self.plain_read(self.authority_credential_filename(hrn))
419
420     # for consistency
421     def private_key(self):
422         self.assert_private_key()
423         return self.private_key_filename()
424
425     def delegate_credential_string(self, original_credential, to_hrn, to_type='authority'):
426         """
427         sign a delegation credential to someone else
428
429         original_credential : typically one's user- or slice- credential to be delegated to s/b else
430         to_hrn : the hrn of the person that will be allowed to do stuff on our behalf
431         to_type : goes with to_hrn, usually 'user' or 'authority'
432
433         returns a string with the delegated credential
434
435         this internally uses self.my_gid()
436         it also retrieves the gid for to_hrn/to_type
437         and uses Credential.delegate()"""
438
439         # the gid and hrn of the object we are delegating
440         if isinstance(original_credential, str):
441             original_credential = Credential(string=original_credential)
442         original_gid = original_credential.get_gid_object()
443         original_hrn = original_gid.get_hrn()
444
445         if not original_credential.get_privileges().get_all_delegate():
446             self.logger.error("delegate_credential_string: original credential {} does not have delegate bit set"
447                               .format(original_hrn))
448             return
449
450         # the delegating user's gid
451         my_gid = self.my_gid()
452
453         # retrieve the GID for the entity that we're delegating to
454         to_gidfile = self.gid(to_hrn, to_type)
455 #        to_gid = GID(to_gidfile )
456 #        to_hrn = delegee_gid.get_hrn()
457 #        print 'to_hrn', to_hrn
458         delegated_credential = original_credential.delegate(
459             to_gidfile, self.private_key(), my_gid)
460         return delegated_credential.save_to_string(save_parents=True)