[Date Prev][Date Next] [Thread Prev][Thread Next] [Date Index] [Thread Index]

[RFC][PATCH] UEFI Secure Boot support



In the UEFI Secure Boot session at this year's DebConf - unfortunately
there were no ftpmasters there - I noted that I'd implemented the
signing system that Ubuntu uses to support UEFI Secure Boot; I talked to
Mark Hymers briefly about this at the mini-DebConf in Cambridge last
month.  He seemed generally willing to offer broadly the same interfaces
as Ubuntu (which would make my life as grub2 maintainer much simpler),
but obviously wanted to see a patch.

The general idea here is to sign a boot loader image (i.e. GRUB) with a
Debian key: for reasons best known to the UEFI specifiers, this has to
be a 2048-bit RSA key.  The public half of this Debian key would be
embedded in the shim package, which we would then submit to Microsoft
for their signature [1].  Upgrading shim would be fairly inconvenient,
but that doesn't have to be done very often, and this effectively
arranges to delegate control to a Debian key so that we can change GRUB
much more easily.  shim also implements the "Machine Owner Key" scheme
so that we can give users a way to add user-controlled signing keys
without having to figure out how to get at setup mode [2].

Storing sensitive data such as the private half of Debian's UEFI signing
key on the buildds is obviously a non-starter, so the signing has to
happen on ftp-master.  The simplest way to do this is by way of a
"raw-uefi" byhand-style object attached via dpkg-distaddfile, which of
course wants to have a degree of automation.

Here's a strawman patch to get a discussion started; I've written tests
for the copier class, but the byhand script is entirely untested.

The main thing I have not done in this patch is as follows.  In Ubuntu,
we have no manual processing of byhand-style objects; any such object
has to have entirely automatic processing.  This means that we have to
defend against somebody getting upload rights for some relatively
inconsequential package and promptly uploading a version of it that
attaches UEFI malware which we'd then merrily sign with our key.  To do
this, we arrange for any binaryful uploads with "raw-uefi" objects
attached to hit an UNAPPROVED queue, which an ftpmaster then has to
accept.  I gather Debian doesn't have an UNAPPROVED queue as such, but
Mark suggested that it could just go through NEW.

I am a bit concerned about the idea that any grub2 upload could
henceforth end up having a multi-day or multi-week turnaround.  There
may not be any real way around this, but any thoughts would be welcome.
Maybe known boot loaders could be whitelisted; but then that means any
DD could NMU grub2 to get their malware in.  On the other hand the
converse means that now an ftpmaster might have to do at least a cursory
source review of all grub2 uploads, which might not exactly fill you
with joy.  Comments appreciated.

[1] There's no real way around this on x86, since pretty much all SB
    systems have to have the Microsoft key present for option ROMs and
    the like, and images signed by multiple keys don't work reliably on
    many firmware implementations seen in the wild.
[2] See e.g. http://lwn.net/Articles/519618/

diff --git a/config/debian/dak.conf b/config/debian/dak.conf
index 26e4616..27a5392 100644
--- a/config/debian/dak.conf
+++ b/config/debian/dak.conf
@@ -191,6 +191,13 @@ AutomaticByHandPackages {
     Section "byhand";
     Script "/srv/ftp-master.debian.org/dak/scripts/debian/byhand-win32-loader";
   };
+
+  "grub2" {
+    Source "grub2";
+    Section "raw-uefi";
+    Extension "tar.gz";
+    Script "/srv/ftp-master.debian.org/dak/scripts/debian/byhand-uefi";
+  };
 };
 
 Dir
diff --git a/dak/copy_uefi.py b/dak/copy_uefi.py
new file mode 100755
index 0000000..f7401b1
--- /dev/null
+++ b/dak/copy_uefi.py
@@ -0,0 +1,140 @@
+#!/usr/bin/env python
+
+""" Copies a signed UEFI image from one suite to another """
+# Copyright (C) 2011  Torsten Werner <twerner@debian.org>
+# Copyright (C) 2013  Colin Watson <cjwatson@debian.org>
+
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+
+################################################################################
+
+from daklib.config import Config
+
+import apt_pkg, glob, os.path, re, shutil, sys
+
+def usage(exit_code = 0):
+    print """Usage: dak copy-uefi [OPTION]... TYPE VERSION
+  -h, --help         show this help and exit
+  -s, --source       source suite      (defaults to unstable)
+  -d, --destination  destination suite (defaults to testing)
+  -n, --no-action    don't change anything
+
+Exactly 1 version must be specified."""
+    sys.exit(exit_code)
+
+def main():
+    cnf = Config()
+    Arguments = [
+            ('h', "help",        "Copy-UEFI::Options::Help"),
+            ('s', "source",      "Copy-UEFI::Options::Source",      "HasArg"),
+            ('d', "destination", "Copy-UEFI::Options::Destination", "HasArg"),
+            ('n', "no-action",   "Copy-UEFI::Options::No-Action"),
+            ]
+    for option in [ "help", "source", "destination", "no-action" ]:
+        if not cnf.has_key("Copy-UEFI::Options::%s" % (option)):
+            cnf["Copy-UEFI::Options::%s" % (option)] = ""
+    extra_arguments = apt_pkg.parse_commandline(cnf.Cnf, Arguments, sys.argv)
+    Options = cnf.subtree("Copy-UEFI::Options")
+
+    if Options["Help"]:
+        usage()
+    if len(extra_arguments) != 2:
+        usage(1)
+
+    initializer = { "image_type": extra_arguments[0],
+                    "version": extra_arguments[1] }
+    if Options["Source"] != "":
+        initializer["source"] = Options["Source"]
+    if Options["Destination"] != "":
+        initializer["dest"] = Options["Destination"]
+
+    copier = UEFICopier(**initializer)
+    print copier.get_message()
+    if Options["No-Action"]:
+        print 'Do nothing because --no-action has been set.'
+    else:
+        copier.do_copy()
+        print 'UEFI image has been copied successfully.'
+
+root_dir = Config()['Dir::Root']
+
+class UEFICopier:
+    def __init__(self, source = 'unstable', dest = 'testing',
+            **keywords):
+        self.source = source
+        self.dest = dest
+        if 'image_type' not in keywords:
+            raise KeyError('no image_type specified')
+        self.image_type = keywords['image_type']
+        if 'version' not in keywords:
+            raise KeyError('no version specified')
+        self.version = keywords['version']
+
+        self.source_dir = os.path.join(root_dir, 'dists', source, 'main')
+        self.dest_dir = os.path.join(root_dir, 'dists', dest, 'main')
+        self.check_dir(self.source_dir, 'source does not exist')
+        self.check_dir(self.dest_dir, 'destination does not exist')
+
+        self.architectures = []
+        self.skip_architectures = []
+        self.trees_to_copy = []
+        self.symlinks_to_create = []
+        arch_pattern = os.path.join(
+            self.source_dir, 'uefi', '%s-*' % self.image_type, self.version)
+        for arch_dir in glob.glob(arch_pattern):
+            self.check_architecture(arch_dir)
+
+    def check_dir(self, dir, message):
+        if not os.path.isdir(dir):
+            raise IOError(message)
+
+    def check_architecture(self, arch_dir):
+        architecture = re.sub(
+            '.*?/uefi/%s-(.*?)/.*' % self.image_type, r'\1', arch_dir)
+        dest_basedir = os.path.join(
+            self.dest_dir, 'uefi', '%s-%s' % (self.image_type, architecture))
+        dest_dir = os.path.join(dest_basedir, self.version)
+        if os.path.isdir(dest_dir):
+            self.skip_architectures.append(architecture)
+        else:
+            self.architectures.append(architecture)
+            self.trees_to_copy.append((arch_dir, dest_dir))
+            symlink_target = os.path.join(dest_basedir, 'current')
+            self.symlinks_to_create.append((self.version, symlink_target))
+
+    def get_message(self):
+        return """
+Will copy UEFI %(image_type)s image version %(version)s from suite %(source)s to
+%(dest)s.
+Architectures to copy: %(arch_list)s
+Architectures to skip: %(skip_arch_list)s""" % {
+            'image_type':     self.image_type,
+            'version':        self.version,
+            'source':         self.source,
+            'dest':           self.dest,
+            'arch_list':      ', '.join(self.architectures),
+            'skip_arch_list': ', '.join(self.skip_architectures)}
+
+    def do_copy(self):
+        for source, dest in self.trees_to_copy:
+            shutil.copytree(source, dest, symlinks=True)
+        for source, dest in self.symlinks_to_create:
+            if os.path.lexists(dest):
+                os.unlink(dest)
+            os.symlink(source, dest)
+
+
+if __name__ == '__main__':
+    main()
diff --git a/dak/dak.py b/dak/dak.py
index 7cb80f4..f2e32ff 100755
--- a/dak/dak.py
+++ b/dak/dak.py
@@ -147,6 +147,8 @@ def init():
          "Generate changelog between two suites"),
         ("copy-installer",
          "Copies the installer from one suite to another"),
+        ("copy-uefi",
+         "Copies a signed UEFI image from one suite to another"),
         ("override-disparity",
          "Generate a list of override disparities"),
         ("external-overrides",
diff --git a/docs/README.stable-point-release b/docs/README.stable-point-release
index 47667d6..e4c14a8 100644
--- a/docs/README.stable-point-release
+++ b/docs/README.stable-point-release
@@ -60,6 +60,10 @@ if [ "${dioldver}" != "empty" ]; then
         fi
     done
 fi
+# for each signed UEFI image type:
+uefitype=grub2
+uefiver=2.00-21
+dak copy-uefi -s ${pusuite} -d ${suite} ${uefitype} ${uefiver}
 cd $ftpdir/dists/${suite}
 
 - Updates for win32-loader?
diff --git a/scripts/debian/byhand-uefi b/scripts/debian/byhand-uefi
new file mode 100755
index 0000000..d2f99a6
--- /dev/null
+++ b/scripts/debian/byhand-uefi
@@ -0,0 +1,128 @@
+#!/bin/bash
+
+# UEFI boot loader custom upload.
+# 
+# The filename must be of the form:
+# 
+#     <TYPE>_<VERSION>_<ARCH>.tar.gz
+# 
+# where:
+# 
+#   * TYPE: loader type (e.g. "grub2");
+#   * VERSION: encoded version;
+#   * ARCH: targeted architecture tag (e.g. "amd64").
+# 
+# The contents are extracted in the archive in the following path:
+# 
+#     <ARCHIVE>/dists/<SUITE>/main/uefi/<TYPE>-<ARCH>/<VERSION>
+# 
+# A "current" symbolic link points to the most recent version.  The
+# tarfile must contain at least one file matching the wildcard *.efi, and
+# any such files are signed using the archive's UEFI signing key.
+# 
+# TODO fix language for dak
+# Signing keys may be installed in the "uefiroot" directory specified in
+# publisher configuration.  In this directory, the private key is
+# "uefi.key" and the certificate is "uefi.crt".
+
+set -u
+set -e
+set -o pipefail
+
+if [ $# -lt 4 ]; then
+	echo "Usage: $0 filename version arch changes_file"
+	exit 1
+fi
+
+export SCRIPTVARS=/srv/ftp-master.debian.org/dak/config/debian/vars
+. $SCRIPTVARS
+
+error() {
+	echo "$*"
+	exit 1
+}
+
+KEYS="$base/scripts/uefikeys"
+KEY="$KEYS/uefi.key"
+CERT="$KEYS/uefi.crt"
+if [ ! -r "$KEY" ]; then
+	error "UEFI private key $KEY not readable"
+fi
+if [ ! -r "$CERT" ]; then
+	error "UEFI certificate $CERT not readable"
+fi
+
+TARBALL="$1"	# Tarball to read, compressed with gzip
+TYPE="${TARBALL%%_*}"
+VERSION="$2"
+ARCH="$3"
+CHANGES="$4"	# Changes file for the upload
+
+# Get the target suite from the Changes file
+# NOTE: it may be better to pass this to the script as a parameter!
+SUITE="$(grep "^Distribution:" "$CHANGES" | awk '{print $2}')"
+case $SUITE in
+    "")
+	error "Error: unable to determine suite from Changes file"
+	;;
+    unstable|sid|*-proposed-updates)
+	: # nothing to do
+	;;
+    *)
+	SUITE="${SUITE}-proposed-updates"
+	;;
+esac
+
+# This must end with /
+TARGET="/srv/ftp-master.debian.org/ftp/dists/$SUITE/main/uefi/$TYPE-$ARCH/"
+mkdir -p "$TARGET"
+
+# Check that there isn't already a directory for this version
+if [ -d "$TARGET/$VERSION" ]; then
+	error "Directory already exists: $TARGET/$VERSION"
+fi
+
+# Escape any regexp metacharacters in the VERSION
+VERSIONREGEXP="$(echo $VERSION | sed 's@\([^A-Za-z0-9-]\)@\\\1@g')"
+
+# We know all data to be in ./<version>; see if there's anything else in the
+# tarball except that
+if tar tzf "$TARBALL" | \
+   grep -Eqv "^\./($VERSIONREGEXP/.*|)$"; then
+	error "Tarball contains unexpected contents"
+fi
+
+# Create a temporary directory where to store the images
+umask 002
+TMPDIR="$(mktemp -td byhand-uefi.XXXXXX)"
+
+# If we fail somewhere, cleanup the temporary directory
+cleanup() {
+        rm -rf "$TMPDIR"
+}
+trap cleanup EXIT
+
+# Extract the data into the temporary directory
+tar xzf "$TARBALL" --directory="$TMPDIR" "./$VERSION/"
+
+for image in $(find "$TMPDIR" -name \*.efi); do
+	rm -f "$image.signed"
+	sbsign --key "$KEY" --cert "$CERT" "$image"
+done
+
+# Move the data to the final location
+mv "$TMPDIR/$VERSION" "$TARGET"
+ln -nsf "$VERSION" "$TARGET/current"
+
+# Fixup permissions
+find "$TARGET/$VERSION" -type d -exec chmod 755 {} +
+find "$TARGET/$VERSION" -type f -exec chmod 644 {} +
+
+# Make sure nothing symlinks outside of the ftpdir
+# Shouldn't happen, but better be sure.
+symlinks -d -r /srv/ftp-master.debian.org/ftp
+
+trap - EXIT
+cleanup
+
+exit 0
diff --git a/tests/fixtures/ftp/dists/testing/main/uefi/grub2-i386/2.00-21/somedir/file b/tests/fixtures/ftp/dists/testing/main/uefi/grub2-i386/2.00-21/somedir/file
new file mode 100644
index 0000000..e69de29
diff --git a/tests/fixtures/ftp/dists/testing/main/uefi/grub2-i386/2.00-21/something b/tests/fixtures/ftp/dists/testing/main/uefi/grub2-i386/2.00-21/something
new file mode 100644
index 0000000..e69de29
diff --git a/tests/fixtures/ftp/dists/unstable/main/uefi/grub2-amd64/2.00-21/somedir/file b/tests/fixtures/ftp/dists/unstable/main/uefi/grub2-amd64/2.00-21/somedir/file
new file mode 100644
index 0000000..e69de29
diff --git a/tests/fixtures/ftp/dists/unstable/main/uefi/grub2-amd64/2.00-21/something b/tests/fixtures/ftp/dists/unstable/main/uefi/grub2-amd64/2.00-21/something
new file mode 100644
index 0000000..e69de29
diff --git a/tests/fixtures/ftp/dists/unstable/main/uefi/grub2-i386/2.00-21/somedir/file b/tests/fixtures/ftp/dists/unstable/main/uefi/grub2-i386/2.00-21/somedir/file
new file mode 100644
index 0000000..e69de29
diff --git a/tests/fixtures/ftp/dists/unstable/main/uefi/grub2-i386/2.00-21/something b/tests/fixtures/ftp/dists/unstable/main/uefi/grub2-i386/2.00-21/something
new file mode 100644
index 0000000..e69de29
diff --git a/tests/test_copy_uefi.py b/tests/test_copy_uefi.py
new file mode 100755
index 0000000..2d2abc7
--- /dev/null
+++ b/tests/test_copy_uefi.py
@@ -0,0 +1,65 @@
+#!/usr/bin/env python
+
+from base_test import DakTestCase
+
+from dak.copy_uefi import UEFICopier
+
+import unittest
+
+class ImportTestCase(DakTestCase):
+    def test_arguments(self):
+        '''test constructor arguments'''
+        # image_type and version arguments are required
+        self.assertRaises(KeyError, UEFICopier)
+        self.assertRaises(KeyError, UEFICopier, image_type = 'grub2')
+        self.assertRaises(KeyError, UEFICopier, version = '2.00-21')
+
+        copier = UEFICopier(image_type = 'grub2', version = '2.00-21')
+        self.assertEqual('grub2', copier.image_type)
+        self.assertEqual('2.00-21', copier.version)
+        self.assertEqual('unstable', copier.source)
+        self.assertEqual('testing', copier.dest)
+
+        copier = UEFICopier(
+            image_type = 'grub2', version = '2.00-21',
+            source = 'proposed-updates')
+        self.assertEqual('proposed-updates', copier.source)
+
+        copier = UEFICopier(
+            image_type = 'grub2', version = '2.00-21', dest = 'stable')
+        self.assertEqual('stable', copier.dest)
+
+    def test_dir_names(self):
+        copier = UEFICopier(image_type = 'grub2', version = '2.00-21')
+        self.assertEqual('tests/fixtures/ftp/dists/unstable/main',
+                copier.source_dir)
+        self.assertEqual('tests/fixtures/ftp/dists/testing/main',
+                copier.dest_dir)
+
+    def test_suites(self):
+        self.assertRaises(
+            IOError, UEFICopier,
+            image_type = 'grub2', version = '2.00-21', source = 'foo')
+        self.assertRaises(
+            IOError, UEFICopier,
+            image_type = 'grub2', version = '2.00-21', dest = 'bar')
+
+    def test_copy(self):
+        copier = UEFICopier(image_type = 'grub2', version = '2.00-21')
+        self.assertEqual(['amd64'], copier.architectures)
+        self.assertEqual(['i386'], copier.skip_architectures)
+        self.assertEqual( \
+            [('tests/fixtures/ftp/dists/unstable/main/uefi/grub2-amd64/2.00-21', \
+              'tests/fixtures/ftp/dists/testing/main/uefi/grub2-amd64/2.00-21'),], \
+            copier.trees_to_copy)
+        self.assertEqual([('2.00-21', \
+            'tests/fixtures/ftp/dists/testing/main/uefi/grub2-amd64/current')], \
+            copier.symlinks_to_create)
+        self.assertEqual('''
+Will copy UEFI grub2 image version 2.00-21 from suite unstable to
+testing.
+Architectures to copy: amd64
+Architectures to skip: i386''', copier.get_message())
+
+if __name__ == '__main__':
+    unittest.main()

Thanks,

-- 
Colin Watson                                       [cjwatson@ubuntu.com]


Reply to: