Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(96)

Side by Side Diff: net/tools/testserver/device_management.py

Issue 12235003: Split out policy code from net/tools/testserver. (Closed) Base URL: svn://svn.chromium.org/chrome/trunk/src
Patch Set: 80 chars. Created 7 years, 10 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch | Annotate | Revision Log
« no previous file with comments | « net/tools/testserver/asn1der.py ('k') | net/tools/testserver/testserver.py » ('j') | no next file with comments »
Toggle Intra-line Diffs ('i') | Expand Comments ('e') | Collapse Comments ('c') | Show Comments Hide Comments ('s')
OLDNEW
(Empty)
1 # Copyright (c) 2012 The Chromium Authors. All rights reserved.
2 # Use of this source code is governed by a BSD-style license that can be
3 # found in the LICENSE file.
4
5 """A bare-bones test server for testing cloud policy support.
6
7 This implements a simple cloud policy test server that can be used to test
8 chrome's device management service client. The policy information is read from
9 the file named device_management in the server's data directory. It contains
10 enforced and recommended policies for the device and user scope, and a list
11 of managed users.
12
13 The format of the file is JSON. The root dictionary contains a list under the
14 key "managed_users". It contains auth tokens for which the server will claim
15 that the user is managed. The token string "*" indicates that all users are
16 claimed to be managed. Other keys in the root dictionary identify request
17 scopes. The user-request scope is described by a dictionary that holds two
18 sub-dictionaries: "mandatory" and "recommended". Both these hold the policy
19 definitions as key/value stores, their format is identical to what the Linux
20 implementation reads from /etc.
21 The device-scope holds the policy-definition directly as key/value stores in the
22 protobuf-format.
23
24 Example:
25
26 {
27 "google/chromeos/device" : {
28 "guest_mode_enabled" : false
29 },
30 "google/chromeos/user" : {
31 "mandatory" : {
32 "HomepageLocation" : "http://www.chromium.org",
33 "IncognitoEnabled" : false
34 },
35 "recommended" : {
36 "JavascriptEnabled": false
37 }
38 },
39 "google/chromeos/publicaccount/user@example.com" : {
40 "mandatory" : {
41 "HomepageLocation" : "http://www.chromium.org"
42 },
43 "recommended" : {
44 }
45 },
46 "managed_users" : [
47 "secret123456"
48 ],
49 "current_key_index": 0
50 }
51
52 """
53
54 import cgi
55 import hashlib
56 import logging
57 import os
58 import random
59 import re
60 import sys
61 import time
62 import tlslite
63 import tlslite.api
64 import tlslite.utils
65
66 # The name and availability of the json module varies in python versions.
67 try:
68 import simplejson as json
69 except ImportError:
70 try:
71 import json
72 except ImportError:
73 json = None
74
75 import asn1der
76 import device_management_backend_pb2 as dm
77 import cloud_policy_pb2 as cp
78 import chrome_device_policy_pb2 as dp
79
80 # ASN.1 object identifier for PKCS#1/RSA.
81 PKCS1_RSA_OID = '\x2a\x86\x48\x86\xf7\x0d\x01\x01\x01'
82
83 # SHA256 sum of "0".
84 SHA256_0 = hashlib.sha256('0').digest()
85
86 # List of bad machine identifiers that trigger the |valid_serial_number_missing|
87 # flag to be set set in the policy fetch response.
88 BAD_MACHINE_IDS = [ '123490EN400015' ];
89
90 # List of machines that trigger the server to send kiosk enrollment response
91 # for the register request.
92 KIOSK_MACHINE_IDS = [ 'KIOSK' ];
93
94 class RequestHandler(object):
95 """Decodes and handles device management requests from clients.
96
97 The handler implements all the request parsing and protobuf message decoding
98 and encoding. It calls back into the server to lookup, register, and
99 unregister clients.
100 """
101
102 def __init__(self, server, path, headers, request):
103 """Initialize the handler.
104
105 Args:
106 server: The TestServer object to use for (un)registering clients.
107 path: A string containing the request path and query parameters.
108 headers: A rfc822.Message-like object containing HTTP headers.
109 request: The request data received from the client as a string.
110 """
111 self._server = server
112 self._path = path
113 self._headers = headers
114 self._request = request
115 self._params = None
116
117 def GetUniqueParam(self, name):
118 """Extracts a unique query parameter from the request.
119
120 Args:
121 name: Names the parameter to fetch.
122 Returns:
123 The parameter value or None if the parameter doesn't exist or is not
124 unique.
125 """
126 if not self._params:
127 self._params = cgi.parse_qs(self._path[self._path.find('?') + 1:])
128
129 param_list = self._params.get(name, [])
130 if len(param_list) == 1:
131 return param_list[0]
132 return None;
133
134 def HandleRequest(self):
135 """Handles a request.
136
137 Parses the data supplied at construction time and returns a pair indicating
138 http status code and response data to be sent back to the client.
139
140 Returns:
141 A tuple of HTTP status code and response data to send to the client.
142 """
143 rmsg = dm.DeviceManagementRequest()
144 rmsg.ParseFromString(self._request)
145
146 logging.debug('gaia auth token -> ' +
147 self._headers.getheader('Authorization', ''))
148 logging.debug('oauth token -> ' + str(self.GetUniqueParam('oauth_token')))
149 logging.debug('deviceid -> ' + str(self.GetUniqueParam('deviceid')))
150 self.DumpMessage('Request', rmsg)
151
152 request_type = self.GetUniqueParam('request')
153 # Check server side requirements, as defined in
154 # device_management_backend.proto.
155 if (self.GetUniqueParam('devicetype') != '2' or
156 self.GetUniqueParam('apptype') != 'Chrome' or
157 (request_type != 'ping' and
158 len(self.GetUniqueParam('deviceid')) >= 64) or
159 len(self.GetUniqueParam('agent')) >= 64):
160 return (400, 'Invalid request parameter')
161 if request_type == 'register':
162 return self.ProcessRegister(rmsg.register_request)
163 elif request_type == 'unregister':
164 return self.ProcessUnregister(rmsg.unregister_request)
165 elif request_type == 'policy' or request_type == 'ping':
166 return self.ProcessPolicy(rmsg.policy_request, request_type)
167 elif request_type == 'enterprise_check':
168 return self.ProcessAutoEnrollment(rmsg.auto_enrollment_request)
169 else:
170 return (400, 'Invalid request parameter')
171
172 def CheckGoogleLogin(self):
173 """Extracts the auth token from the request and returns it. The token may
174 either be a GoogleLogin token from an Authorization header, or an OAuth V2
175 token from the oauth_token query parameter. Returns None if no token is
176 present.
177 """
178 oauth_token = self.GetUniqueParam('oauth_token')
179 if oauth_token:
180 return oauth_token
181
182 match = re.match('GoogleLogin auth=(\\w+)',
183 self._headers.getheader('Authorization', ''))
184 if match:
185 return match.group(1)
186
187 return None
188
189 def ProcessRegister(self, msg):
190 """Handles a register request.
191
192 Checks the query for authorization and device identifier, registers the
193 device with the server and constructs a response.
194
195 Args:
196 msg: The DeviceRegisterRequest message received from the client.
197
198 Returns:
199 A tuple of HTTP status code and response data to send to the client.
200 """
201 # Check the auth token and device ID.
202 auth = self.CheckGoogleLogin()
203 if not auth:
204 return (403, 'No authorization')
205
206 policy = self._server.GetPolicies()
207 if ('*' not in policy['managed_users'] and
208 auth not in policy['managed_users']):
209 return (403, 'Unmanaged')
210
211 device_id = self.GetUniqueParam('deviceid')
212 if not device_id:
213 return (400, 'Missing device identifier')
214
215 token_info = self._server.RegisterDevice(device_id,
216 msg.machine_id,
217 msg.type)
218
219 # Send back the reply.
220 response = dm.DeviceManagementResponse()
221 response.register_response.device_management_token = (
222 token_info['device_token'])
223 response.register_response.machine_name = token_info['machine_name']
224 response.register_response.enrollment_type = token_info['enrollment_mode']
225
226 self.DumpMessage('Response', response)
227
228 return (200, response.SerializeToString())
229
230 def ProcessUnregister(self, msg):
231 """Handles a register request.
232
233 Checks for authorization, unregisters the device and constructs the
234 response.
235
236 Args:
237 msg: The DeviceUnregisterRequest message received from the client.
238
239 Returns:
240 A tuple of HTTP status code and response data to send to the client.
241 """
242 # Check the management token.
243 token, response = self.CheckToken();
244 if not token:
245 return response
246
247 # Unregister the device.
248 self._server.UnregisterDevice(token['device_token']);
249
250 # Prepare and send the response.
251 response = dm.DeviceManagementResponse()
252 response.unregister_response.CopyFrom(dm.DeviceUnregisterResponse())
253
254 self.DumpMessage('Response', response)
255
256 return (200, response.SerializeToString())
257
258 def ProcessPolicy(self, msg, request_type):
259 """Handles a policy request.
260
261 Checks for authorization, encodes the policy into protobuf representation
262 and constructs the response.
263
264 Args:
265 msg: The DevicePolicyRequest message received from the client.
266
267 Returns:
268 A tuple of HTTP status code and response data to send to the client.
269 """
270 for request in msg.request:
271 if (request.policy_type in
272 ('google/chrome/user',
273 'google/chromeos/user',
274 'google/chromeos/device',
275 'google/chromeos/publicaccount')):
276 if request_type != 'policy':
277 return (400, 'Invalid request type')
278 else:
279 return self.ProcessCloudPolicy(request)
280 else:
281 return (400, 'Invalid policy_type')
282
283 def ProcessAutoEnrollment(self, msg):
284 """Handles an auto-enrollment check request.
285
286 The reply depends on the value of the modulus:
287 1: replies with no new modulus and the sha256 hash of "0"
288 2: replies with a new modulus, 4.
289 4: replies with a new modulus, 2.
290 8: fails with error 400.
291 16: replies with a new modulus, 16.
292 32: replies with a new modulus, 1.
293 anything else: replies with no new modulus and an empty list of hashes
294
295 These allow the client to pick the testing scenario its wants to simulate.
296
297 Args:
298 msg: The DeviceAutoEnrollmentRequest message received from the client.
299
300 Returns:
301 A tuple of HTTP status code and response data to send to the client.
302 """
303 auto_enrollment_response = dm.DeviceAutoEnrollmentResponse()
304
305 if msg.modulus == 1:
306 auto_enrollment_response.hash.append(SHA256_0)
307 elif msg.modulus == 2:
308 auto_enrollment_response.expected_modulus = 4
309 elif msg.modulus == 4:
310 auto_enrollment_response.expected_modulus = 2
311 elif msg.modulus == 8:
312 return (400, 'Server error')
313 elif msg.modulus == 16:
314 auto_enrollment_response.expected_modulus = 16
315 elif msg.modulus == 32:
316 auto_enrollment_response.expected_modulus = 1
317
318 response = dm.DeviceManagementResponse()
319 response.auto_enrollment_response.CopyFrom(auto_enrollment_response)
320 return (200, response.SerializeToString())
321
322 def SetProtobufMessageField(self, group_message, field, field_value):
323 '''Sets a field in a protobuf message.
324
325 Args:
326 group_message: The protobuf message.
327 field: The field of the message to set, it should be a member of
328 group_message.DESCRIPTOR.fields.
329 field_value: The value to set.
330 '''
331 if field.label == field.LABEL_REPEATED:
332 assert type(field_value) == list
333 entries = group_message.__getattribute__(field.name)
334 if field.message_type is None:
335 for list_item in field_value:
336 entries.append(list_item)
337 else:
338 # This field is itself a protobuf.
339 sub_type = field.message_type
340 for sub_value in field_value:
341 assert type(sub_value) == dict
342 # Add a new sub-protobuf per list entry.
343 sub_message = entries.add()
344 # Now iterate over its fields and recursively add them.
345 for sub_field in sub_message.DESCRIPTOR.fields:
346 if sub_field.name in sub_value:
347 value = sub_value[sub_field.name]
348 self.SetProtobufMessageField(sub_message, sub_field, value)
349 return
350 elif field.type == field.TYPE_BOOL:
351 assert type(field_value) == bool
352 elif field.type == field.TYPE_STRING:
353 assert type(field_value) == str or type(field_value) == unicode
354 elif field.type == field.TYPE_INT64:
355 assert type(field_value) == int
356 elif (field.type == field.TYPE_MESSAGE and
357 field.message_type.name == 'StringList'):
358 assert type(field_value) == list
359 entries = group_message.__getattribute__(field.name).entries
360 for list_item in field_value:
361 entries.append(list_item)
362 return
363 else:
364 raise Exception('Unknown field type %s' % field.type)
365 group_message.__setattr__(field.name, field_value)
366
367 def GatherDevicePolicySettings(self, settings, policies):
368 '''Copies all the policies from a dictionary into a protobuf of type
369 CloudDeviceSettingsProto.
370
371 Args:
372 settings: The destination ChromeDeviceSettingsProto protobuf.
373 policies: The source dictionary containing policies in JSON format.
374 '''
375 for group in settings.DESCRIPTOR.fields:
376 # Create protobuf message for group.
377 group_message = eval('dp.' + group.message_type.name + '()')
378 # Indicates if at least one field was set in |group_message|.
379 got_fields = False
380 # Iterate over fields of the message and feed them from the
381 # policy config file.
382 for field in group_message.DESCRIPTOR.fields:
383 field_value = None
384 if field.name in policies:
385 got_fields = True
386 field_value = policies[field.name]
387 self.SetProtobufMessageField(group_message, field, field_value)
388 if got_fields:
389 settings.__getattribute__(group.name).CopyFrom(group_message)
390
391 def GatherUserPolicySettings(self, settings, policies):
392 '''Copies all the policies from a dictionary into a protobuf of type
393 CloudPolicySettings.
394
395 Args:
396 settings: The destination: a CloudPolicySettings protobuf.
397 policies: The source: a dictionary containing policies under keys
398 'recommended' and 'mandatory'.
399 '''
400 for field in settings.DESCRIPTOR.fields:
401 # |field| is the entry for a specific policy in the top-level
402 # CloudPolicySettings proto.
403
404 # Look for this policy's value in the mandatory or recommended dicts.
405 if field.name in policies.get('mandatory', {}):
406 mode = cp.PolicyOptions.MANDATORY
407 value = policies['mandatory'][field.name]
408 elif field.name in policies.get('recommended', {}):
409 mode = cp.PolicyOptions.RECOMMENDED
410 value = policies['recommended'][field.name]
411 else:
412 continue
413
414 # Create protobuf message for this policy.
415 policy_message = eval('cp.' + field.message_type.name + '()')
416 policy_message.policy_options.mode = mode
417 field_descriptor = policy_message.DESCRIPTOR.fields_by_name['value']
418 self.SetProtobufMessageField(policy_message, field_descriptor, value)
419 settings.__getattribute__(field.name).CopyFrom(policy_message)
420
421 def ProcessCloudPolicy(self, msg):
422 """Handles a cloud policy request. (New protocol for policy requests.)
423
424 Checks for authorization, encodes the policy into protobuf representation,
425 signs it and constructs the repsonse.
426
427 Args:
428 msg: The CloudPolicyRequest message received from the client.
429
430 Returns:
431 A tuple of HTTP status code and response data to send to the client.
432 """
433
434 token_info, error = self.CheckToken()
435 if not token_info:
436 return error
437
438 if msg.machine_id:
439 self._server.UpdateMachineId(token_info['device_token'], msg.machine_id)
440
441 # Response is only given if the scope is specified in the config file.
442 # Normally 'google/chromeos/device', 'google/chromeos/user' and
443 # 'google/chromeos/publicaccount' should be accepted.
444 policy = self._server.GetPolicies()
445 policy_value = ''
446 policy_key = msg.policy_type
447 if msg.settings_entity_id:
448 policy_key += '/' + msg.settings_entity_id
449 if msg.policy_type in token_info['allowed_policy_types']:
450 if (msg.policy_type == 'google/chromeos/user' or
451 msg.policy_type == 'google/chrome/user' or
452 msg.policy_type == 'google/chromeos/publicaccount'):
453 settings = cp.CloudPolicySettings()
454 self.GatherUserPolicySettings(settings, policy.get(policy_key, {}))
455 elif msg.policy_type == 'google/chromeos/device':
456 settings = dp.ChromeDeviceSettingsProto()
457 self.GatherDevicePolicySettings(settings, policy.get(policy_key, {}))
458
459 # Sign with 'current_key_index', defaulting to key 0.
460 signing_key = None
461 req_key = None
462 current_key_index = policy.get('current_key_index', 0)
463 nkeys = len(self._server.keys)
464 if (msg.signature_type == dm.PolicyFetchRequest.SHA1_RSA and
465 current_key_index in range(nkeys)):
466 signing_key = self._server.keys[current_key_index]
467 if msg.public_key_version in range(1, nkeys + 1):
468 # requested key exists, use for signing and rotate.
469 req_key = self._server.keys[msg.public_key_version - 1]['private_key']
470
471 # Fill the policy data protobuf.
472 policy_data = dm.PolicyData()
473 policy_data.policy_type = msg.policy_type
474 policy_data.timestamp = int(time.time() * 1000)
475 policy_data.request_token = token_info['device_token']
476 policy_data.policy_value = settings.SerializeToString()
477 policy_data.machine_name = token_info['machine_name']
478 policy_data.valid_serial_number_missing = (
479 token_info['machine_id'] in BAD_MACHINE_IDS)
480 policy_data.settings_entity_id = msg.settings_entity_id
481
482 if signing_key:
483 policy_data.public_key_version = current_key_index + 1
484 if msg.policy_type == 'google/chromeos/publicaccount':
485 policy_data.username = msg.settings_entity_id
486 else:
487 # For regular user/device policy, there is no way for the testserver to
488 # know the user name belonging to the GAIA auth token we received (short
489 # of actually talking to GAIA). To address this, we read the username from
490 # the policy configuration dictionary, or use a default.
491 policy_data.username = policy.get('policy_user', 'user@example.com')
492 policy_data.device_id = token_info['device_id']
493 signed_data = policy_data.SerializeToString()
494
495 response = dm.DeviceManagementResponse()
496 fetch_response = response.policy_response.response.add()
497 fetch_response.policy_data = signed_data
498 if signing_key:
499 fetch_response.policy_data_signature = (
500 signing_key['private_key'].hashAndSign(signed_data).tostring())
501 if msg.public_key_version != current_key_index + 1:
502 fetch_response.new_public_key = signing_key['public_key']
503 if req_key:
504 fetch_response.new_public_key_signature = (
505 req_key.hashAndSign(fetch_response.new_public_key).tostring())
506
507 self.DumpMessage('Response', response)
508
509 return (200, response.SerializeToString())
510
511 def CheckToken(self):
512 """Helper for checking whether the client supplied a valid DM token.
513
514 Extracts the token from the request and passed to the server in order to
515 look up the client.
516
517 Returns:
518 A pair of token information record and error response. If the first
519 element is None, then the second contains an error code to send back to
520 the client. Otherwise the first element is the same structure that is
521 returned by LookupToken().
522 """
523 error = 500
524 dmtoken = None
525 request_device_id = self.GetUniqueParam('deviceid')
526 match = re.match('GoogleDMToken token=(\\w+)',
527 self._headers.getheader('Authorization', ''))
528 if match:
529 dmtoken = match.group(1)
530 if not dmtoken:
531 error = 401
532 else:
533 token_info = self._server.LookupToken(dmtoken)
534 if (not token_info or
535 not request_device_id or
536 token_info['device_id'] != request_device_id):
537 error = 410
538 else:
539 return (token_info, None)
540
541 logging.debug('Token check failed with error %d' % error)
542
543 return (None, (error, 'Server error %d' % error))
544
545 def DumpMessage(self, label, msg):
546 """Helper for logging an ASCII dump of a protobuf message."""
547 logging.debug('%s\n%s' % (label, str(msg)))
548
549 class TestServer(object):
550 """Handles requests and keeps global service state."""
551
552 def __init__(self, policy_path, private_key_paths):
553 """Initializes the server.
554
555 Args:
556 policy_path: Names the file to read JSON-formatted policy from.
557 private_key_paths: List of paths to read private keys from.
558 """
559 self._registered_tokens = {}
560 self.policy_path = policy_path
561
562 self.keys = []
563 if private_key_paths:
564 # Load specified keys from the filesystem.
565 for key_path in private_key_paths:
566 try:
567 key = tlslite.api.parsePEMKey(open(key_path).read(), private=True)
568 except IOError:
569 print 'Failed to load private key from %s' % key_path
570 continue
571
572 assert key is not None
573 self.keys.append({ 'private_key' : key })
574 else:
575 # Generate 2 private keys if none were passed from the command line.
576 for i in range(2):
577 key = tlslite.api.generateRSAKey(512)
578 assert key is not None
579 self.keys.append({ 'private_key' : key })
580
581 # Derive the public keys from the private keys.
582 for entry in self.keys:
583 key = entry['private_key']
584
585 algorithm = asn1der.Sequence(
586 [ asn1der.Data(asn1der.OBJECT_IDENTIFIER, PKCS1_RSA_OID),
587 asn1der.Data(asn1der.NULL, '') ])
588 rsa_pubkey = asn1der.Sequence([ asn1der.Integer(key.n),
589 asn1der.Integer(key.e) ])
590 pubkey = asn1der.Sequence([ algorithm, asn1der.Bitstring(rsa_pubkey) ])
591 entry['public_key'] = pubkey;
592
593 def GetPolicies(self):
594 """Returns the policies to be used, reloaded form the backend file every
595 time this is called.
596 """
597 policy = {}
598 if json is None:
599 print 'No JSON module, cannot parse policy information'
600 else :
601 try:
602 policy = json.loads(open(self.policy_path).read())
603 except IOError:
604 print 'Failed to load policy from %s' % self.policy_path
605 return policy
606
607 def HandleRequest(self, path, headers, request):
608 """Handles a request.
609
610 Args:
611 path: The request path and query parameters received from the client.
612 headers: A rfc822.Message-like object containing HTTP headers.
613 request: The request data received from the client as a string.
614 Returns:
615 A pair of HTTP status code and response data to send to the client.
616 """
617 handler = RequestHandler(self, path, headers, request)
618 return handler.HandleRequest()
619
620 def RegisterDevice(self, device_id, machine_id, type):
621 """Registers a device or user and generates a DM token for it.
622
623 Args:
624 device_id: The device identifier provided by the client.
625
626 Returns:
627 The newly generated device token for the device.
628 """
629 dmtoken_chars = []
630 while len(dmtoken_chars) < 32:
631 dmtoken_chars.append(random.choice('0123456789abcdef'))
632 dmtoken = ''.join(dmtoken_chars)
633 allowed_policy_types = {
634 dm.DeviceRegisterRequest.BROWSER: ['google/chrome/user'],
635 dm.DeviceRegisterRequest.USER: ['google/chromeos/user'],
636 dm.DeviceRegisterRequest.DEVICE: [
637 'google/chromeos/device',
638 'google/chromeos/publicaccount'
639 ],
640 dm.DeviceRegisterRequest.TT: ['google/chromeos/user',
641 'google/chrome/user'],
642 }
643 if machine_id in KIOSK_MACHINE_IDS:
644 enrollment_mode = dm.DeviceRegisterResponse.RETAIL
645 else:
646 enrollment_mode = dm.DeviceRegisterResponse.ENTERPRISE
647 self._registered_tokens[dmtoken] = {
648 'device_id': device_id,
649 'device_token': dmtoken,
650 'allowed_policy_types': allowed_policy_types[type],
651 'machine_name': 'chromeos-' + machine_id,
652 'machine_id': machine_id,
653 'enrollment_mode': enrollment_mode,
654 }
655 return self._registered_tokens[dmtoken]
656
657 def UpdateMachineId(self, dmtoken, machine_id):
658 """Updates the machine identifier for a registered device.
659
660 Args:
661 dmtoken: The device management token provided by the client.
662 machine_id: Updated hardware identifier value.
663 """
664 if dmtoken in self._registered_tokens:
665 self._registered_tokens[dmtoken]['machine_id'] = machine_id
666
667 def LookupToken(self, dmtoken):
668 """Looks up a device or a user by DM token.
669
670 Args:
671 dmtoken: The device management token provided by the client.
672
673 Returns:
674 A dictionary with information about a device or user that is registered by
675 dmtoken, or None if the token is not found.
676 """
677 return self._registered_tokens.get(dmtoken, None)
678
679 def UnregisterDevice(self, dmtoken):
680 """Unregisters a device identified by the given DM token.
681
682 Args:
683 dmtoken: The device management token provided by the client.
684 """
685 if dmtoken in self._registered_tokens.keys():
686 del self._registered_tokens[dmtoken]
OLDNEW
« no previous file with comments | « net/tools/testserver/asn1der.py ('k') | net/tools/testserver/testserver.py » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698