Add a way for devserver to transmit public key and signed metadata hash.

The option --public-key which can be used with a public RSA key (in
PEM format) to have it be included as the value (base64 encoded) of
the PublicKeyRsa value in the XML response.

The option --private_key_for_metadata_hash_signature can be used with
a private RSA key to have devserver sign the metadata hash and include
it as the value of the MetadataSignatureRsa in the XML response, just
like the Omaha server.

Combined with CL:175285 for update_engine, this can be used to write
tests to assure that update_engine works correctly, e.g. that it

 - Accepts payloads where both the metadata hash and the payload is
   signed by a trusted key:

   $ ./devserver.py --test_image                                     \
         --private_key unittest_key.pem                              \
         --private_key_for_metadata_hash_signature unittest_key.pem  \
         --public_key unittest_key.pub.pem

 - Rejects payloads where the metadata hash is signed by an untrusted
   key and the payload is signed by a trusted key:

   $ ./devserver.py --test_image                                     \
         --private_key unittest_key.pem                              \
         --private_key_for_metadata_hash_signature unittest_key2.pem \
         --public_key unittest_key.pub.pem

 - Rejects payloads where the metadata hash is signed by a trusted key,
   but the payload is signed by an untrusted key:

   $ ./devserver.py --test_image                                     \
         --private_key unittest_key.pem                              \
         --private_key_for_metadata_hash_signature unittest_key2.pem \
         --public_key unittest_key2.pub.pem

BUG=chromium:264352
TEST=Unit tests pass + manual testing (see above.)

Change-Id: I4a0297549a61a559d074de4f2bf45b3c4012f58d
Reviewed-on: https://chromium-review.googlesource.com/175283
Commit-Queue: David Zeuthen <zeuthen@chromium.org>
Tested-by: David Zeuthen <zeuthen@chromium.org>
Reviewed-by: David Zeuthen <zeuthen@chromium.org>
diff --git a/autoupdate.py b/autoupdate.py
index 97315d6..c9f807d 100644
--- a/autoupdate.py
+++ b/autoupdate.py
@@ -2,8 +2,10 @@
 # Use of this source code is governed by a BSD-style license that can be
 # found in the LICENSE file.
 
+import base64
 import json
 import os
+import struct
 import subprocess
 import sys
 import time
@@ -121,11 +123,14 @@
 class UpdateMetadata(object):
   """Object containing metadata about an update payload."""
 
-  def __init__(self, sha1, sha256, size, is_delta_format):
+  def __init__(self, sha1, sha256, size, is_delta_format, metadata_size,
+               metadata_hash):
     self.sha1 = sha1
     self.sha256 = sha256
     self.size = size
     self.is_delta_format = is_delta_format
+    self.metadata_size = metadata_size
+    self.metadata_hash = metadata_hash
 
 
 class Autoupdate(build_util.BuildObject):
@@ -142,6 +147,8 @@
     board:           board for the image. Needed for pre-generating of updates.
     copy_to_static_root:  copies images generated from the cache to ~/static.
     private_key:          path to private key in PEM format.
+    private_key_for_metadata_hash_signature: path to private key in PEM format.
+    public_key:       path to public key in PEM format.
     critical_update:  whether provisioned payload is critical.
     remote_payload:   whether provisioned payload is remotely staged.
     max_updates:      maximum number of updates we'll try to provision.
@@ -156,10 +163,13 @@
   SHA256_ATTR = 'sha256'
   SIZE_ATTR = 'size'
   ISDELTA_ATTR = 'is_delta'
+  METADATA_SIZE_ATTR = 'metadata_size'
+  METADATA_HASH_ATTR = 'metadata_hash'
 
   def __init__(self, xbuddy, urlbase=None, forced_image=None, payload_path=None,
                proxy_port=None, src_image='', patch_kernel=True, board=None,
                copy_to_static_root=True, private_key=None,
+               private_key_for_metadata_hash_signature=None, public_key=None,
                critical_update=False, remote_payload=False, max_updates= -1,
                host_log=False, *args, **kwargs):
     super(Autoupdate, self).__init__(*args, **kwargs)
@@ -173,6 +183,9 @@
     self.board = board or self.GetDefaultBoardID()
     self.copy_to_static_root = copy_to_static_root
     self.private_key = private_key
+    self.private_key_for_metadata_hash_signature = \
+      private_key_for_metadata_hash_signature
+    self.public_key = public_key
     self.critical_update = critical_update
     self.remote_payload = remote_payload
     self.max_updates = max_updates
@@ -199,7 +212,10 @@
     sha256 = file_attr_dict.get(cls.SHA256_ATTR)
     size = file_attr_dict.get(cls.SIZE_ATTR)
     is_delta = file_attr_dict.get(cls.ISDELTA_ATTR)
-    return UpdateMetadata(sha1, sha256, size, is_delta)
+    metadata_size = file_attr_dict.get(cls.METADATA_SIZE_ATTR)
+    metadata_hash = file_attr_dict.get(cls.METADATA_HASH_ATTR)
+    return UpdateMetadata(sha1, sha256, size, is_delta, metadata_size,
+                          metadata_hash)
 
   @staticmethod
   def _ReadMetadataFromFile(payload_dir):
@@ -215,7 +231,9 @@
     file_dict = {cls.SHA1_ATTR: metadata_obj.sha1,
                  cls.SHA256_ATTR: metadata_obj.sha256,
                  cls.SIZE_ATTR: metadata_obj.size,
-                 cls.ISDELTA_ATTR: metadata_obj.is_delta_format}
+                 cls.ISDELTA_ATTR: metadata_obj.is_delta_format,
+                 cls.METADATA_SIZE_ATTR: metadata_obj.metadata_size,
+                 cls.METADATA_HASH_ATTR: metadata_obj.metadata_hash}
     metadata_file = os.path.join(payload_dir, constants.METADATA_FILE)
     with open(metadata_file, 'w') as file_handle:
       json.dump(file_dict, file_handle)
@@ -271,6 +289,8 @@
     update_command = [
         'cros_generate_update_payload',
         '--image', image_path,
+        '--out_metadata_hash_file', os.path.join(output_dir,
+                                                 constants.METADATA_HASH_FILE),
         '--output', update_path,
     ]
 
@@ -485,8 +505,7 @@
     Args:
       url: URL of statically staged remote file (http://host:port/static/...)
     Returns:
-      A tuple containing the SHA1, SHA256, file size and whether or not it's a
-      delta payload (Boolean).
+      A UpdateMetadata object.
     """
     if self._PAYLOAD_URL_PREFIX not in url:
       raise AutoupdateError(
@@ -512,14 +531,41 @@
     except IOError as e:
       raise AutoupdateError('Failed to obtain remote payload info: %s', e)
 
+  @staticmethod
+  def _GetMetadataHash(payload_dir):
+    """Gets the metadata hash.
+
+    Args:
+      payload_dir: The payload directory.
+    Returns:
+      The metadata hash, base-64 encoded.
+    """
+    path = os.path.join(payload_dir, constants.METADATA_HASH_FILE)
+    return base64.b64encode(open(path, 'rb').read())
+
+  @staticmethod
+  def _GetMetadataSize(payload_filename):
+    """Gets the size of the metadata in a payload file.
+
+    Args:
+      payload_filename: Path to the payload file.
+    Returns:
+      The size of the payload metadata, as reported in the payload header.
+    """
+    # Handle corner-case where unit tests pass in empty payload files.
+    if os.path.getsize(payload_filename) < 20:
+      return 0
+    stream = open(payload_filename, 'rb')
+    stream.seek(16)
+    return struct.unpack('>I', stream.read(4))[0] + 20
+
   def GetLocalPayloadAttrs(self, payload_dir):
     """Returns hashes, size and delta flag of a local update payload.
 
     Args:
       payload_dir: Path to the directory the payload is in.
     Returns:
-      A tuple containing the SHA1, SHA256, file size and whether or not it's a
-      delta payload (Boolean).
+      A UpdateMetadata object.
     """
     filename = os.path.join(payload_dir, constants.UPDATE_FILE)
     if not os.path.exists(filename):
@@ -534,7 +580,10 @@
       sha256 = common_util.GetFileSha256(filename)
       size = common_util.GetFileSize(filename)
       is_delta_format = self.IsDeltaFormatFile(filename)
-      metadata_obj = UpdateMetadata(sha1, sha256, size, is_delta_format)
+      metadata_size = self._GetMetadataSize(filename)
+      metadata_hash = self._GetMetadataHash(payload_dir)
+      metadata_obj = UpdateMetadata(sha1, sha256, size, is_delta_format,
+                                    metadata_size, metadata_hash)
       Autoupdate._StoreMetadataToFile(payload_dir, metadata_obj)
 
     return metadata_obj
@@ -696,6 +745,29 @@
     else:
       return path_to_payload
 
+  @staticmethod
+  def _SignMetadataHash(private_key_path, metadata_hash):
+    """Signs metadata hash.
+
+    Signs a metadata hash with a private key. This includes padding the
+    hash with PKCS#1 v1.5 padding as well as an ASN.1 header.
+
+    Args:
+      private_key_path: The path to a private key to use for signing.
+      metadata_hash: A raw SHA-256 hash (32 bytes).
+    Returns:
+      The raw signature.
+    """
+    args = ['openssl', 'rsautl', '-pkcs', '-sign', '-inkey', private_key_path]
+    padded_metadata_hash = ('\x30\x31\x30\x0d\x06\x09\x60\x86'
+                            '\x48\x01\x65\x03\x04\x02\x01\x05'
+                            '\x00\x04\x20') + metadata_hash
+    child = subprocess.Popen(args,
+                             stdin=subprocess.PIPE,
+                             stdout=subprocess.PIPE)
+    signature, _ = child.communicate(input=padded_metadata_hash)
+    return signature
+
   def HandleUpdatePing(self, data, label=''):
     """Handles an update ping from an update client.
 
@@ -767,10 +839,23 @@
       _Log('Failed to process an update: %r', e)
       return autoupdate_lib.GetNoUpdateResponse(protocol)
 
+    # Sign the metadata hash, if requested.
+    signed_metadata_hash = None
+    if self.private_key_for_metadata_hash_signature:
+      signed_metadata_hash = base64.b64encode(Autoupdate._SignMetadataHash(
+          self.private_key_for_metadata_hash_signature,
+          base64.b64decode(metadata_obj.metadata_hash)))
+
+    # Include public key, if requested.
+    public_key_data = None
+    if self.public_key:
+      public_key_data = base64.b64encode(open(self.public_key, 'r').read())
+
     _Log('Responding to client to use url %s to get image', url)
     return autoupdate_lib.GetUpdateResponse(
         metadata_obj.sha1, metadata_obj.sha256, metadata_obj.size, url,
-        metadata_obj.is_delta_format, protocol, self.critical_update)
+        metadata_obj.is_delta_format, metadata_obj.metadata_size,
+        signed_metadata_hash, public_key_data, protocol, self.critical_update)
 
   def HandleHostInfoPing(self, ip):
     """Returns host info dictionary for the given IP in JSON format."""
diff --git a/autoupdate_lib.py b/autoupdate_lib.py
index 33e1be3..a61d5e5 100644
--- a/autoupdate_lib.py
+++ b/autoupdate_lib.py
@@ -120,7 +120,8 @@
   return response_xml
 
 
-def GetUpdateResponse(sha1, sha256, size, url, is_delta_format, protocol,
+def GetUpdateResponse(sha1, sha256, size, url, is_delta_format, metadata_size,
+                      signed_metadata_hash, public_key, protocol,
                       critical_update=False):
   """Returns a protocol-specific response to the client for a new update.
 
@@ -130,6 +131,9 @@
     size: size of update blob
     url: where to find update blob
     is_delta_format: true if url refers to a delta payload
+    metadata_size: the size of the metadata, in bytes.
+    signed_metadata_hash: the signed metadata hash or None if not signed.
+    public_key: the public key to transmit to the client or None if no key.
     protocol: client's protocol version from the request Xml.
     critical_update: whether this is a critical update.
   Returns:
@@ -152,6 +156,13 @@
     date_str = datetime.date.today().strftime('%Y%m%d')
     extra_attributes.append('deadline="%s"' % date_str)
 
+  if metadata_size:
+    extra_attributes.append('MetadataSize="%d"' % metadata_size)
+  if signed_metadata_hash:
+    extra_attributes.append('MetadataSignatureRsa="%s"' % signed_metadata_hash)
+  if public_key:
+    extra_attributes.append('PublicKeyRsa="%s"' % public_key)
+
   response_values['extra_attr'] = ' '.join(extra_attributes)
   return GetSubstitutedResponse(UPDATE_RESPONSE, protocol, response_values)
 
diff --git a/autoupdate_unittest.py b/autoupdate_unittest.py
index 988b038..7451e38 100755
--- a/autoupdate_unittest.py
+++ b/autoupdate_unittest.py
@@ -155,6 +155,9 @@
       update_image = os.path.join(cache_dir, constants.UPDATE_FILE)
       with open(update_image, 'w') as fh:
         fh.write('')
+      metadata_hash = os.path.join(cache_dir, constants.METADATA_HASH_FILE)
+      with open(metadata_hash, 'w') as fh:
+        fh.write('')
 
     common_util.IsInsideChroot().AndReturn(True)
     au_mock.GenerateUpdateImageWithCache(forced_image).WithSideEffects(
@@ -171,8 +174,8 @@
     forced_url = 'http://%s/static/%s/update.gz' % (self.hostname,
                                                     'cache')
     autoupdate_lib.GetUpdateResponse(
-        self.sha1, self.sha256, self.size, forced_url, False, '3.0',
-        False).AndReturn(self.payload)
+        self.sha1, self.sha256, self.size, forced_url, False, 0, None, None,
+        '3.0', False).AndReturn(self.payload)
 
     self.mox.ReplayAll()
     au_mock.forced_image = forced_image
@@ -254,6 +257,9 @@
     update_gz = os.path.join(new_image_dir, constants.UPDATE_FILE)
     with open(update_gz, 'w') as fh:
       fh.write('')
+    metadata_hash = os.path.join(new_image_dir, constants.METADATA_HASH_FILE)
+    with open(metadata_hash, 'w') as fh:
+      fh.write('')
 
     common_util.GetFileSha1(os.path.join(
         new_image_dir, 'update.gz')).AndReturn(self.sha1)
@@ -264,8 +270,8 @@
     au_mock._StoreMetadataToFile(new_image_dir,
                                  mox.IsA(autoupdate.UpdateMetadata))
     autoupdate_lib.GetUpdateResponse(
-        self.sha1, self.sha256, self.size, new_url, False, '3.0',
-        False).AndReturn(self.payload)
+        self.sha1, self.sha256, self.size, new_url, False, 0, None, None,
+        '3.0', False).AndReturn(self.payload)
 
     self.mox.ReplayAll()
     au_mock.HandleSetUpdatePing('127.0.0.1', test_label)
@@ -310,9 +316,10 @@
     test_data = _TEST_REQUEST % self.test_dict
 
     au_mock._GetRemotePayloadAttrs(remote_url).AndReturn(
-        autoupdate.UpdateMetadata(self.sha1, self.sha256, self.size, False))
+        autoupdate.UpdateMetadata(self.sha1, self.sha256, self.size, False,
+                                  0, ''))
     autoupdate_lib.GetUpdateResponse(
-        self.sha1, self.sha256, self.size, remote_url, False,
+        self.sha1, self.sha256, self.size, remote_url, False, 0, None, None,
         '3.0', False).AndReturn(self.payload)
 
     self.mox.ReplayAll()
diff --git a/devserver.py b/devserver.py
index 1971ee6..9094264 100755
--- a/devserver.py
+++ b/devserver.py
@@ -915,6 +915,16 @@
                    help='path to the private key in pem format. If this is set '
                    'the devserver will generate update payloads that are '
                    'signed with this key.')
+  group.add_option('--private_key_for_metadata_hash_signature',
+                   metavar='PATH', default=None,
+                   help='path to the private key in pem format. If this is set '
+                   'the devserver will sign the metadata hash with the given '
+                   'key and transmit in the Omaha-style XML response.')
+  group.add_option('--public_key',
+                   metavar='PATH', default=None,
+                   help='path to the public key in pem format. If this is set '
+                   'the devserver will transmit a base64 encoded version of '
+                   'the content in the Omaha-style XML response.')
   group.add_option('--proxy_port',
                    metavar='PORT', default=None, type='int',
                    help='port to have the client connect to -- basically the '
@@ -1089,6 +1099,9 @@
       board=options.board,
       copy_to_static_root=not options.exit,
       private_key=options.private_key,
+      private_key_for_metadata_hash_signature=
+        options.private_key_for_metadata_hash_signature,
+      public_key=options.public_key,
       critical_update=options.critical_update,
       remote_payload=options.remote_payload,
       max_updates=options.max_updates,
diff --git a/devserver_constants.py b/devserver_constants.py
index c0ac645..aecd444 100644
--- a/devserver_constants.py
+++ b/devserver_constants.py
@@ -35,5 +35,6 @@
 #### Update files
 CACHE_DIR = 'cache'
 METADATA_FILE = 'update.meta'
+METADATA_HASH_FILE = 'metadata_hash'
 STATEFUL_FILE = 'stateful.tgz'
 UPDATE_FILE = 'update.gz'
diff --git a/host/cros_generate_update_payload b/host/cros_generate_update_payload
index 519c2a2..efc9ad6 100755
--- a/host/cros_generate_update_payload
+++ b/host/cros_generate_update_payload
@@ -139,6 +139,7 @@
 DEFINE_boolean patch_kernel "$FLAGS_FALSE" "Whether or not to patch the kernel \
 with the patch from the stateful partition (default: false)"
 DEFINE_string private_key "" "Path to private key in .pem format."
+DEFINE_string out_metadata_hash_file "" "Path to output metadata hash file."
 DEFINE_boolean extract "$FLAGS_FALSE" "If set, extract old/new kernel/rootfs \
 to [old|new]_[kern|root].dat. Useful for debugging (default: false)"
 DEFINE_boolean full_kernel "$FLAGS_FALSE" "Generate a full kernel update even \
@@ -268,6 +269,18 @@
       -new_build_version "$FLAGS_build_version"
 fi
 
+if [ -n "$FLAGS_out_metadata_hash_file" ]; then
+    # The manifest - unfortunately - contain two fields called
+    # signature_offset and signature_size with data about the how the
+    # manifest is signed. This means we have to pass the signature
+    # size used. The value 256 is the number of bytes the SHA-256 hash
+    # value of the manifest signed with a 2048-bit RSA key occupies.
+    "$GENERATOR" \
+        -in_file "$FLAGS_output" \
+        -signature_size 256 \
+        -out_metadata_hash_file "$FLAGS_out_metadata_hash_file"
+fi
+
 trap - INT TERM EXIT
 cleanup noexit