Merge ~pappacena/launchpad:unrevert-lp-signing-integration into launchpad:master

Proposed by Thiago F. Pappacena
Status: Merged
Approved by: Thiago F. Pappacena
Approved revision: bc4d26955af50bf15869baf8599a80b9323d8ce8
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~pappacena/launchpad:unrevert-lp-signing-integration
Merge into: launchpad:master
Diff against target: 2871 lines (+2098/-98)
23 files modified
configs/development/launchpad-lazr.conf (+5/-0)
lib/lp/archivepublisher/archivegpgsigningkey.py (+4/-13)
lib/lp/archivepublisher/signing.py (+206/-30)
lib/lp/archivepublisher/tests/test_publisher.py (+4/-2)
lib/lp/archivepublisher/tests/test_signing.py (+550/-45)
lib/lp/services/compat.py (+7/-0)
lib/lp/services/config/schema-lazr.conf (+12/-6)
lib/lp/services/configure.zcml (+2/-1)
lib/lp/services/features/flags.py (+7/-1)
lib/lp/services/signing/__init__.py (+0/-0)
lib/lp/services/signing/configure.zcml (+27/-0)
lib/lp/services/signing/enums.py (+65/-0)
lib/lp/services/signing/interfaces/__init__.py (+0/-0)
lib/lp/services/signing/interfaces/signingkey.py (+126/-0)
lib/lp/services/signing/interfaces/signingserviceclient.py (+47/-0)
lib/lp/services/signing/model/__init__.py (+0/-0)
lib/lp/services/signing/model/signingkey.py (+196/-0)
lib/lp/services/signing/proxy.py (+182/-0)
lib/lp/services/signing/tests/__init__.py (+0/-0)
lib/lp/services/signing/tests/helpers.py (+67/-0)
lib/lp/services/signing/tests/test_proxy.py (+318/-0)
lib/lp/services/signing/tests/test_signingkey.py (+244/-0)
lib/lp/testing/factory.py (+29/-0)
Reviewer Review Type Date Requested Status
Colin Watson (community) Approve
Review via email: mp+381927@code.launchpad.net

Commit message

[HOLD] Adding back the lp-signing integration, reverted due to the fact that we didn't deploy yet the database changes.

Description of the change

We should wait until https://code.launchpad.net/~pappacena/launchpad/+git/launchpad/+merge/379218 is deployed to production before merging this MP.

To post a comment you must log in.
Revision history for this message
Colin Watson (cjwatson) wrote :

The database patch has been deployed to production now, so it should be OK to try landing this again.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/configs/development/launchpad-lazr.conf b/configs/development/launchpad-lazr.conf
2index a0b8f0e..7b01979 100644
3--- a/configs/development/launchpad-lazr.conf
4+++ b/configs/development/launchpad-lazr.conf
5@@ -182,6 +182,11 @@ tools_source: deb http://ppa.launchpad.net/snappy-dev/snapcraft-daily/ubuntu %(s
6 global_suggestions_enabled: True
7 generate_templates: True
8
9+[signing]
10+signing_endpoint = http://signing.launchpad.test:8000
11+client_private_key = O73bJzd3hybyBxUKk0FaR6K9CbbmxBYkw6vCrIWZkSY=
12+client_public_key = xEtwSS7kdGmo0ElcN2fR/mcHS0A42zhYbo/+5KV4xRs=
13+
14 [profiling]
15 profiling_allowed: True
16
17diff --git a/lib/lp/archivepublisher/archivegpgsigningkey.py b/lib/lp/archivepublisher/archivegpgsigningkey.py
18index 2b14365..7439a32 100644
19--- a/lib/lp/archivepublisher/archivegpgsigningkey.py
20+++ b/lib/lp/archivepublisher/archivegpgsigningkey.py
21@@ -1,4 +1,4 @@
22-# Copyright 2009-2018 Canonical Ltd. This software is licensed under the
23+# Copyright 2009-2020 Canonical Ltd. This software is licensed under the
24 # GNU Affero General Public License version 3 (see the file LICENSE).
25
26 """ArchiveGPGSigningKey implementation."""
27@@ -8,17 +8,12 @@ __metaclass__ = type
28 __all__ = [
29 'ArchiveGPGSigningKey',
30 'SignableArchive',
31- 'SigningMode',
32 ]
33
34
35 import os
36
37 import gpgme
38-from lazr.enum import (
39- EnumeratedType,
40- Item,
41- )
42 from twisted.internet.threads import deferToThread
43 from zope.component import getUtility
44 from zope.interface import implementer
45@@ -43,13 +38,7 @@ from lp.services.config import config
46 from lp.services.gpg.interfaces import IGPGHandler
47 from lp.services.osutils import remove_if_exists
48 from lp.services.propertycache import get_property_cache
49-
50-
51-class SigningMode(EnumeratedType):
52- """Archive file signing mode."""
53-
54- DETACHED = Item("Detached signature")
55- CLEAR = Item("Cleartext signature")
56+from lp.services.signing.enums import SigningMode
57
58
59 @implementer(ISignableArchive)
60@@ -100,6 +89,8 @@ class SignableArchive:
61
62 output_paths = []
63 for input_path, output_path, mode, suite in signatures:
64+ if mode not in {SigningMode.DETACHED, SigningMode.CLEAR}:
65+ raise ValueError('Invalid signature mode for GPG: %s' % mode)
66 if self.archive.signing_key is not None:
67 with open(input_path) as input_file:
68 input_content = input_file.read()
69diff --git a/lib/lp/archivepublisher/signing.py b/lib/lp/archivepublisher/signing.py
70index b685a61..e59ddc6 100644
71--- a/lib/lp/archivepublisher/signing.py
72+++ b/lib/lp/archivepublisher/signing.py
73@@ -1,4 +1,4 @@
74-# Copyright 2012-2018 Canonical Ltd. This software is licensed under the
75+# Copyright 2012-2020 Canonical Ltd. This software is licensed under the
76 # GNU Affero General Public License version 3 (see the file LICENSE).
77
78 """The processing of Signing tarballs.
79@@ -18,6 +18,7 @@ __all__ = [
80 "UefiUpload",
81 ]
82
83+from functools import partial
84 import os
85 import shutil
86 import stat
87@@ -27,13 +28,22 @@ import tempfile
88 import textwrap
89
90 import scandir
91+from zope.component import getUtility
92
93 from lp.archivepublisher.config import getPubConfig
94 from lp.archivepublisher.customupload import CustomUpload
95+from lp.registry.interfaces.distroseries import IDistroSeriesSet
96+from lp.services.features import getFeatureFlag
97 from lp.services.osutils import remove_if_exists
98+from lp.services.signing.enums import SigningKeyType
99+from lp.services.signing.interfaces.signingkey import IArchiveSigningKeySet
100 from lp.soyuz.interfaces.queue import CustomUploadError
101
102
103+PUBLISHER_USES_SIGNING_SERVICE = (
104+ 'archivepublisher.signing_service.enabled')
105+
106+
107 class SigningUploadPackError(CustomUploadError):
108 def __init__(self, tarfile_path, exc):
109 message = "Problem building tarball '%s': %s" % (
110@@ -41,6 +51,14 @@ class SigningUploadPackError(CustomUploadError):
111 CustomUploadError.__init__(self, message)
112
113
114+class NoSigningKeyError(Exception):
115+ pass
116+
117+
118+class SigningServiceError(Exception):
119+ pass
120+
121+
122 class SigningUpload(CustomUpload):
123 """Signing custom upload.
124
125@@ -65,6 +83,16 @@ class SigningUpload(CustomUpload):
126 Signing keys may be installed in the "signingroot" directory specified in
127 publisher configuration. In this directory, the private key is
128 "uefi.key" and the certificate is "uefi.crt".
129+
130+ This class is already prepared to use signing service. There are
131+ basically two places interacting with it:
132+ - findSigningHandlers(), that provides a handler to call signing
133+ service to sign each file (together with a fallback handler,
134+ that signs the file locally).
135+
136+ - copyPublishedPublicKeys(), that accepts both ways of saving public
137+ keys: by copying from local file system (old way) or saving the
138+ public key stored at signing service (new way).
139 """
140 custom_type = "signing"
141
142@@ -105,6 +133,13 @@ class SigningUpload(CustomUpload):
143
144 def setTargetDirectory(self, archive, tarfile_path, suite):
145 self.archive = archive
146+
147+ if suite:
148+ self.distro_series, _ = getUtility(IDistroSeriesSet).fromSuite(
149+ self.archive.distribution, suite)
150+ else:
151+ self.distro_series = None
152+
153 pubconf = getPubConfig(archive)
154 if pubconf.signingroot is None:
155 if self.logger is not None:
156@@ -122,7 +157,7 @@ class SigningUpload(CustomUpload):
157 self.fit_cert = None
158 self.autokey = False
159 else:
160- signing_for = suite.split('-')[0]
161+ signing_for = self.distro_series.name if self.distro_series else ''
162 self.uefi_key = self.getSeriesPath(
163 pubconf, "uefi.key", archive, signing_for)
164 self.uefi_cert = self.getSeriesPath(
165@@ -165,26 +200,37 @@ class SigningUpload(CustomUpload):
166 self.archiveroot = pubconf.archiveroot
167 self.temproot = pubconf.temproot
168
169- self.public_keys = set()
170+ self.public_keys = {}
171
172- def publishPublicKey(self, key):
173- """Record this key as having been used in this upload."""
174- self.public_keys.add(key)
175+ def publishPublicKey(self, key, content=None):
176+ """Record this key as having been used in this upload.
177
178- def copyPublishedPublicKeys(self):
179- """Copy out published keys into the custom upload."""
180- keydir = os.path.join(self.tmpdir, self.version, "control")
181- if not os.path.exists(keydir):
182- os.makedirs(keydir)
183- for key in self.public_keys:
184- # Ensure we only emit files which are world readable.
185+ :param key: Key file name
186+ :param content: Key file content (if None, try to read it from local
187+ filesystem)
188+ """
189+ if content is not None:
190+ self.public_keys[key] = content
191+ elif key not in self.public_keys:
192+ # Ensure we only emit files which are world-readable.
193 if stat.S_IMODE(os.stat(key).st_mode) & stat.S_IROTH:
194- shutil.copy(key, os.path.join(keydir, os.path.basename(key)))
195+ with open(key, "rb") as f:
196+ self.public_keys[key] = f.read()
197 else:
198 if self.logger is not None:
199 self.logger.warning(
200 "%s: public key not world readable" % key)
201
202+ def copyPublishedPublicKeys(self):
203+ """Copy out published keys into the custom upload."""
204+ keydir = os.path.join(self.tmpdir, self.version, "control")
205+ if not os.path.exists(keydir):
206+ os.makedirs(keydir)
207+ for filename, content in self.public_keys.items():
208+ file_path = os.path.join(keydir, os.path.basename(filename))
209+ with open(file_path, 'wb') as fd:
210+ fd.write(content)
211+
212 def setSigningOptions(self):
213 """Find and extract raw-signing options from the tarball."""
214 self.signing_options = {}
215@@ -219,22 +265,145 @@ class SigningUpload(CustomUpload):
216
217 def findSigningHandlers(self):
218 """Find all the signable files in an extracted tarball."""
219+ use_signing_service = bool(
220+ getFeatureFlag(PUBLISHER_USES_SIGNING_SERVICE))
221+
222+ fallback_handlers = {
223+ SigningKeyType.UEFI: self.signUefi,
224+ SigningKeyType.KMOD: self.signKmod,
225+ SigningKeyType.OPAL: self.signOpal,
226+ SigningKeyType.SIPL: self.signSipl,
227+ SigningKeyType.FIT: self.signFit,
228+ }
229+
230 for dirpath, dirnames, filenames in scandir.walk(self.tmpdir):
231 for filename in filenames:
232+ file_path = os.path.join(dirpath, filename)
233 if filename.endswith(".efi"):
234- yield (os.path.join(dirpath, filename), self.signUefi)
235+ key_type = SigningKeyType.UEFI
236 elif filename.endswith(".ko"):
237- yield (os.path.join(dirpath, filename), self.signKmod)
238+ key_type = SigningKeyType.KMOD
239 elif filename.endswith(".opal"):
240- yield (os.path.join(dirpath, filename), self.signOpal)
241+ key_type = SigningKeyType.OPAL
242 elif filename.endswith(".sipl"):
243- yield (os.path.join(dirpath, filename), self.signSipl)
244+ key_type = SigningKeyType.SIPL
245 elif filename.endswith(".fit"):
246- yield (os.path.join(dirpath, filename), self.signFit)
247+ key_type = SigningKeyType.FIT
248+ else:
249+ continue
250+
251+ if use_signing_service:
252+ key = getUtility(IArchiveSigningKeySet).getSigningKey(
253+ key_type, self.archive, self.distro_series)
254+ handler = partial(
255+ self.signUsingSigningService, key_type, key)
256+ fallback_handler = partial(
257+ self.signUsingLocalKey, key_type,
258+ fallback_handlers.get(key_type))
259+ yield file_path, handler, fallback_handler
260+ else:
261+ yield file_path, fallback_handlers.get(key_type), None
262+
263+ def signUsingLocalKey(self, key_type, handler, filename):
264+ """Sign the given filename using using handler if the local
265+ key files exists. If the local key files does not exist, raises
266+ IOError.
267+
268+ Note that this method should only be used as a fallback to signing
269+ service, since it will not try to generate local keys.
270+
271+ :param key_type: One of the SigningKeyType items.
272+ :param handler: One of the local signing handlers (self.signUefi,
273+ self.signKmod, etc).
274+ :param filename: The filename to be signed.
275+ """
276+
277+ if not self.keyFilesExist(key_type):
278+ raise IOError(
279+ "Could not fallback to local signing keys: the key files "
280+ "were not found.")
281+ return handler(filename)
282+
283+ def keyFilesExist(self, key_type):
284+ """Checks if all needed key files exists in the local filesystem
285+ for the given key type.
286+ """
287+ fallback_keys = {
288+ SigningKeyType.UEFI: [self.uefi_cert, self.uefi_key],
289+ SigningKeyType.KMOD: [self.kmod_pem, self.kmod_x509],
290+ SigningKeyType.OPAL: [self.opal_pem, self.opal_x509],
291+ SigningKeyType.SIPL: [self.sipl_pem, self.sipl_x509],
292+ SigningKeyType.FIT: [self.fit_cert, self.fit_key],
293+ }
294+ # If we are missing local key files, do not proceed.
295+ key_files = [i for i in fallback_keys[key_type] if i]
296+ return all(os.path.exists(key_file) for key_file in key_files)
297+
298+ def signUsingSigningService(self, key_type, signing_key, filename):
299+ """Sign the given filename using a certain key hosted on signing
300+ service, writes the signed content back to the filesystem and
301+ publishes the public key to self.public_keys.
302+
303+ If the given key is None and self.autokey is set to True, this method
304+ generates a key on signing service and associates it with the current
305+ archive.
306+
307+ :param key_type: One of the SigningKeyType enum items
308+ :param signing_key: The SigningKey to be used (or None,
309+ to autogenerate a key if possible).
310+ :param filename: The filename to be signed.
311+ :return: Boolean. True if signed, or raises SigningServiceError
312+ on failure.
313+ """
314+ if signing_key is None:
315+ if not self.autokey:
316+ raise NoSigningKeyError("No signing key for %s" % filename)
317+ description = (
318+ u"%s key for %s" % (key_type.name, self.archive.reference))
319+ try:
320+ signing_key = getUtility(IArchiveSigningKeySet).generate(
321+ key_type, self.archive, description=description
322+ ).signing_key
323+ except Exception as e:
324+ if self.logger:
325+ self.logger.exception(
326+ "Error generating signing key for %s: %s %s" %
327+ (self.archive.reference, e.__class__.__name__, e))
328+ raise SigningServiceError(
329+ "Could not generate key %s: %s" % (key_type, e))
330+
331+ with open(filename, "rb") as fd:
332+ content = fd.read()
333+
334+ try:
335+ signed_content = signing_key.sign(
336+ content, message_name=os.path.basename(filename))
337+ except Exception as e:
338+ if self.logger:
339+ self.logger.exception(
340+ "Error signing %s on signing service: %s %s" %
341+ (filename, e.__class__.__name__, e))
342+ raise SigningServiceError(
343+ "Could not sign message with key %s: %s" % (signing_key, e))
344+
345+ if key_type in (SigningKeyType.UEFI, SigningKeyType.FIT):
346+ file_suffix = ".signed"
347+ public_key_suffix = ".crt"
348+ else:
349+ file_suffix = ".sig"
350+ public_key_suffix = ".x509"
351+
352+ signed_filename = filename + file_suffix
353+ public_key_filename = key_type.name.lower() + public_key_suffix
354+
355+ with open(signed_filename, 'wb') as fd:
356+ fd.write(signed_content)
357+
358+ self.publishPublicKey(public_key_filename, signing_key.public_key)
359+ return True
360
361 def getKeys(self, which, generate, *keynames):
362 """Validate and return the uefi key and cert for encryption."""
363-
364 if self.autokey:
365 for keyfile in keynames:
366 if keyfile and not os.path.exists(keyfile):
367@@ -299,7 +468,7 @@ class SigningUpload(CustomUpload):
368 return
369 self.publishPublicKey(cert)
370 cmdl = ["sbsign", "--key", key, "--cert", cert, image]
371- return self.callLog("UEFI signing", cmdl)
372+ return self.callLog("UEFI signing", cmdl) == 0
373
374 openssl_config_base = textwrap.dedent("""\
375 [ req ]
376@@ -387,7 +556,7 @@ class SigningUpload(CustomUpload):
377 return
378 self.publishPublicKey(cert)
379 cmdl = ["kmodsign", "-D", "sha512", pem, cert, image, image + ".sig"]
380- return self.callLog("Kmod signing", cmdl)
381+ return self.callLog("Kmod signing", cmdl) == 0
382
383 def generateOpalKeys(self):
384 """Generate new Opal Signing Keys for this archive."""
385@@ -403,7 +572,7 @@ class SigningUpload(CustomUpload):
386 return
387 self.publishPublicKey(cert)
388 cmdl = ["kmodsign", "-D", "sha512", pem, cert, image, image + ".sig"]
389- return self.callLog("Opal signing", cmdl)
390+ return self.callLog("Opal signing", cmdl) == 0
391
392 def generateSiplKeys(self):
393 """Generate new Sipl Signing Keys for this archive."""
394@@ -419,7 +588,7 @@ class SigningUpload(CustomUpload):
395 return
396 self.publishPublicKey(cert)
397 cmdl = ["kmodsign", "-D", "sha512", pem, cert, image, image + ".sig"]
398- return self.callLog("SIPL signing", cmdl)
399+ return self.callLog("SIPL signing", cmdl) == 0
400
401 def generateFitKeys(self):
402 """Generate new FIT Keys for this archive."""
403@@ -439,7 +608,7 @@ class SigningUpload(CustomUpload):
404 shutil.copy(image, image_signed)
405 cmdl = ["mkimage", "-F", "-k", os.path.dirname(key), "-r",
406 image_signed]
407- return self.callLog("FIT signing", cmdl)
408+ return self.callLog("FIT signing", cmdl) == 0
409
410 def convertToTarball(self):
411 """Convert unpacked output to signing tarball."""
412@@ -467,10 +636,18 @@ class SigningUpload(CustomUpload):
413 """
414 super(SigningUpload, self).extract()
415 self.setSigningOptions()
416- filehandlers = list(self.findSigningHandlers())
417- for (filename, handler) in filehandlers:
418- if (handler(filename) == 0 and
419- 'signed-only' in self.signing_options):
420+ for filename, handler, fallback_handler in self.findSigningHandlers():
421+ try:
422+ was_signed = handler(filename)
423+ except (NoSigningKeyError, SigningServiceError) as e:
424+ if fallback_handler is not None and self.logger:
425+ self.logger.warning(
426+ "Signing service will try to fallback to local key. "
427+ "Reason: %s (%s)" % (e.__class__.__name__, e))
428+ was_signed = False
429+ if not was_signed and fallback_handler is not None:
430+ was_signed = fallback_handler(filename)
431+ if was_signed and 'signed-only' in self.signing_options:
432 os.unlink(filename)
433
434 # Copy out the public keys where they were used.
435@@ -514,5 +691,4 @@ class UefiUpload(SigningUpload):
436 packages are converted to the new form and location.
437 """
438 custom_type = "uefi"
439-
440 dists_directory = "uefi"
441diff --git a/lib/lp/archivepublisher/tests/test_publisher.py b/lib/lp/archivepublisher/tests/test_publisher.py
442index d94fd34..1a2b664 100644
443--- a/lib/lp/archivepublisher/tests/test_publisher.py
444+++ b/lib/lp/archivepublisher/tests/test_publisher.py
445@@ -1,4 +1,4 @@
446-# Copyright 2009-2019 Canonical Ltd. This software is licensed under the
447+# Copyright 2009-2020 Canonical Ltd. This software is licensed under the
448 # GNU Affero General Public License version 3 (see the file LICENSE).
449
450 """Tests for publisher class."""
451@@ -32,11 +32,13 @@ import time
452
453 from debian.deb822 import Release
454 from fixtures import MonkeyPatch
455+
456+
457 try:
458 import lzma
459 except ImportError:
460 from backports import lzma
461-import mock
462+from lp.services.compat import mock
463 import pytz
464 import scandir
465 from testscenarios import (
466diff --git a/lib/lp/archivepublisher/tests/test_signing.py b/lib/lp/archivepublisher/tests/test_signing.py
467index efe4ae6..8295aef 100644
468--- a/lib/lp/archivepublisher/tests/test_signing.py
469+++ b/lib/lp/archivepublisher/tests/test_signing.py
470@@ -1,4 +1,4 @@
471-# Copyright 2012-2019 Canonical Ltd. This software is licensed under the
472+# Copyright 2012-2020 Canonical Ltd. This software is licensed under the
473 # GNU Affero General Public License version 3 (see the file LICENSE).
474
475 """Test UEFI custom uploads."""
476@@ -9,10 +9,12 @@ __metaclass__ = type
477
478 import os
479 import re
480+import shutil
481 import stat
482 import tarfile
483
484 from fixtures import MonkeyPatch
485+from mock import call
486 import scandir
487 from testtools.matchers import (
488 Contains,
489@@ -21,6 +23,7 @@ from testtools.matchers import (
490 Matcher,
491 MatchesAll,
492 MatchesDict,
493+ MatchesStructure,
494 Mismatch,
495 Not,
496 StartsWith,
497@@ -39,11 +42,16 @@ from lp.archivepublisher.interfaces.archivegpgsigningkey import (
498 )
499 from lp.archivepublisher.interfaces.publisherconfig import IPublisherConfigSet
500 from lp.archivepublisher.signing import (
501+ PUBLISHER_USES_SIGNING_SERVICE,
502 SigningUpload,
503 UefiUpload,
504 )
505 from lp.archivepublisher.tests.test_run_parts import RunPartsMixin
506+from lp.services.features.testing import FeatureFixture
507 from lp.services.osutils import write_file
508+from lp.services.signing.enums import SigningMode
509+from lp.services.signing.proxy import SigningKeyType
510+from lp.services.signing.tests.helpers import SigningServiceClientFixture
511 from lp.services.tarfile_helpers import LaunchpadWriteTarFile
512 from lp.soyuz.enums import ArchivePurpose
513 from lp.testing import TestCaseWithFactory
514@@ -184,7 +192,9 @@ class TestSigningHelpers(TestCaseWithFactory):
515 distribution=self.distro, purpose=ArchivePurpose.PRIMARY)
516 self.signing_dir = os.path.join(
517 self.temp_dir, self.distro.name + "-signing")
518- self.suite = "distroseries"
519+ self.distroseries = self.factory.makeDistroSeries(
520+ distribution=self.distro)
521+ self.suite = self.distroseries.name
522 pubconf = getPubConfig(self.archive)
523 if not os.path.exists(pubconf.temproot):
524 os.makedirs(pubconf.temproot)
525@@ -267,7 +277,7 @@ class TestSigningHelpers(TestCaseWithFactory):
526 return os.path.join(pubconf.archiveroot, "dists", self.suite, "main")
527
528
529-class TestSigning(RunPartsMixin, TestSigningHelpers):
530+class TestLocalSigningUpload(RunPartsMixin, TestSigningHelpers):
531
532 def getSignedPath(self, loader_type, arch):
533 return os.path.join(self.getDistsPath(), "signed",
534@@ -604,7 +614,7 @@ class TestSigning(RunPartsMixin, TestSigningHelpers):
535 upload = SigningUpload()
536 upload.generateUefiKeys = FakeMethod()
537 upload.setTargetDirectory(
538- self.archive, "test_1.0_amd64.tar.gz", "distroseries")
539+ self.archive, "test_1.0_amd64.tar.gz", self.suite)
540 upload.signUefi('t.efi')
541 self.assertEqual(1, fake_call.call_count)
542 # Assert command form.
543@@ -624,7 +634,7 @@ class TestSigning(RunPartsMixin, TestSigningHelpers):
544 upload = SigningUpload()
545 upload.generateUefiKeys = FakeMethod()
546 upload.setTargetDirectory(
547- self.archive, "test_1.0_amd64.tar.gz", "distroseries")
548+ self.archive, "test_1.0_amd64.tar.gz", self.suite)
549 upload.signUefi('t.efi')
550 self.assertEqual(0, fake_call.call_count)
551 self.assertEqual(0, upload.generateUefiKeys.call_count)
552@@ -638,7 +648,7 @@ class TestSigning(RunPartsMixin, TestSigningHelpers):
553 self.useFixture(MonkeyPatch("subprocess.call", fake_call))
554 upload = SigningUpload()
555 upload.setTargetDirectory(
556- self.archive, "test_1.0_amd64.tar.gz", "distroseries")
557+ self.archive, "test_1.0_amd64.tar.gz", self.suite)
558 upload.generateUefiKeys()
559 self.assertEqual(1, fake_call.call_count)
560 # Assert the actual command matches.
561@@ -662,7 +672,7 @@ class TestSigning(RunPartsMixin, TestSigningHelpers):
562 upload = SigningUpload()
563 upload.generateFitKeys = FakeMethod()
564 upload.setTargetDirectory(
565- self.archive, "test_1.0_amd64.tar.gz", "distroseries")
566+ self.archive, "test_1.0_amd64.tar.gz", self.suite)
567 upload.signFit('t.fit')
568 # Confirm the copy was performed.
569 self.assertEqual(1, fake_copy.call_count)
570@@ -687,7 +697,7 @@ class TestSigning(RunPartsMixin, TestSigningHelpers):
571 upload = SigningUpload()
572 upload.generateFitKeys = FakeMethod()
573 upload.setTargetDirectory(
574- self.archive, "test_1.0_amd64.tar.gz", "distroseries")
575+ self.archive, "test_1.0_amd64.tar.gz", self.suite)
576 upload.signUefi('t.fit')
577 self.assertEqual(0, fake_call.call_count)
578 self.assertEqual(0, upload.generateFitKeys.call_count)
579@@ -701,7 +711,7 @@ class TestSigning(RunPartsMixin, TestSigningHelpers):
580 self.useFixture(MonkeyPatch("subprocess.call", fake_call))
581 upload = SigningUpload()
582 upload.setTargetDirectory(
583- self.archive, "test_1.0_amd64.tar.gz", "distroseries")
584+ self.archive, "test_1.0_amd64.tar.gz", self.suite)
585 upload.generateFitKeys()
586 self.assertEqual(1, fake_call.call_count)
587 # Assert the actual command matches.
588@@ -720,7 +730,7 @@ class TestSigning(RunPartsMixin, TestSigningHelpers):
589 self.setUpPPA()
590 upload = SigningUpload()
591 upload.setTargetDirectory(
592- self.archive, "test_1.0_amd64.tar.gz", "distroseries")
593+ self.archive, "test_1.0_amd64.tar.gz", self.suite)
594 text = upload.generateOpensslConfig('Kmod', upload.openssl_config_kmod)
595
596 id_re = re.compile(r'^# KMOD OpenSSL config\n')
597@@ -743,7 +753,7 @@ class TestSigning(RunPartsMixin, TestSigningHelpers):
598 upload = SigningUpload()
599 upload.generateKmodKeys = FakeMethod()
600 upload.setTargetDirectory(
601- self.archive, "test_1.0_amd64.tar.gz", "distroseries")
602+ self.archive, "test_1.0_amd64.tar.gz", self.suite)
603 upload.signKmod('t.ko')
604 self.assertEqual(1, fake_call.call_count)
605 # Assert command form.
606@@ -764,7 +774,7 @@ class TestSigning(RunPartsMixin, TestSigningHelpers):
607 upload = SigningUpload()
608 upload.generateKmodKeys = FakeMethod()
609 upload.setTargetDirectory(
610- self.archive, "test_1.0_amd64.tar.gz", "distroseries")
611+ self.archive, "test_1.0_amd64.tar.gz", self.suite)
612 upload.signKmod('t.ko')
613 self.assertEqual(0, fake_call.call_count)
614 self.assertEqual(0, upload.generateKmodKeys.call_count)
615@@ -778,7 +788,7 @@ class TestSigning(RunPartsMixin, TestSigningHelpers):
616 self.useFixture(MonkeyPatch("subprocess.call", fake_call))
617 upload = SigningUpload()
618 upload.setTargetDirectory(
619- self.archive, "test_1.0_amd64.tar.gz", "distroseries")
620+ self.archive, "test_1.0_amd64.tar.gz", self.suite)
621 upload.generateKmodKeys()
622 self.assertEqual(2, fake_call.call_count)
623 # Assert the actual command matches.
624@@ -806,7 +816,7 @@ class TestSigning(RunPartsMixin, TestSigningHelpers):
625 self.setUpPPA()
626 upload = SigningUpload()
627 upload.setTargetDirectory(
628- self.archive, "test_1.0_amd64.tar.gz", "distroseries")
629+ self.archive, "test_1.0_amd64.tar.gz", self.suite)
630 text = upload.generateOpensslConfig('Opal', upload.openssl_config_opal)
631
632 id_re = re.compile(r'^# OPAL OpenSSL config\n')
633@@ -826,7 +836,7 @@ class TestSigning(RunPartsMixin, TestSigningHelpers):
634 upload = SigningUpload()
635 upload.generateOpalKeys = FakeMethod()
636 upload.setTargetDirectory(
637- self.archive, "test_1.0_amd64.tar.gz", "distroseries")
638+ self.archive, "test_1.0_amd64.tar.gz", self.suite)
639 upload.signOpal('t.opal')
640 self.assertEqual(1, fake_call.call_count)
641 # Assert command form.
642@@ -847,7 +857,7 @@ class TestSigning(RunPartsMixin, TestSigningHelpers):
643 upload = SigningUpload()
644 upload.generateOpalKeys = FakeMethod()
645 upload.setTargetDirectory(
646- self.archive, "test_1.0_amd64.tar.gz", "distroseries")
647+ self.archive, "test_1.0_amd64.tar.gz", self.suite)
648 upload.signOpal('t.opal')
649 self.assertEqual(0, fake_call.call_count)
650 self.assertEqual(0, upload.generateOpalKeys.call_count)
651@@ -861,7 +871,7 @@ class TestSigning(RunPartsMixin, TestSigningHelpers):
652 self.useFixture(MonkeyPatch("subprocess.call", fake_call))
653 upload = SigningUpload()
654 upload.setTargetDirectory(
655- self.archive, "test_1.0_amd64.tar.gz", "distroseries")
656+ self.archive, "test_1.0_amd64.tar.gz", self.suite)
657 upload.generateOpalKeys()
658 self.assertEqual(2, fake_call.call_count)
659 # Assert the actual command matches.
660@@ -889,7 +899,7 @@ class TestSigning(RunPartsMixin, TestSigningHelpers):
661 self.setUpPPA()
662 upload = SigningUpload()
663 upload.setTargetDirectory(
664- self.archive, "test_1.0_amd64.tar.gz", "distroseries")
665+ self.archive, "test_1.0_amd64.tar.gz", self.suite)
666 text = upload.generateOpensslConfig('SIPL', upload.openssl_config_sipl)
667
668 id_re = re.compile(r'^# SIPL OpenSSL config\n')
669@@ -909,7 +919,7 @@ class TestSigning(RunPartsMixin, TestSigningHelpers):
670 upload = SigningUpload()
671 upload.generateSiplKeys = FakeMethod()
672 upload.setTargetDirectory(
673- self.archive, "test_1.0_amd64.tar.gz", "distroseries")
674+ self.archive, "test_1.0_amd64.tar.gz", self.suite)
675 upload.signSipl('t.sipl')
676 self.assertEqual(1, fake_call.call_count)
677 # Assert command form.
678@@ -930,7 +940,7 @@ class TestSigning(RunPartsMixin, TestSigningHelpers):
679 upload = SigningUpload()
680 upload.generateSiplKeys = FakeMethod()
681 upload.setTargetDirectory(
682- self.archive, "test_1.0_amd64.tar.gz", "distroseries")
683+ self.archive, "test_1.0_amd64.tar.gz", self.suite)
684 upload.signOpal('t.sipl')
685 self.assertEqual(0, fake_call.call_count)
686 self.assertEqual(0, upload.generateSiplKeys.call_count)
687@@ -944,7 +954,7 @@ class TestSigning(RunPartsMixin, TestSigningHelpers):
688 self.useFixture(MonkeyPatch("subprocess.call", fake_call))
689 upload = SigningUpload()
690 upload.setTargetDirectory(
691- self.archive, "test_1.0_amd64.tar.gz", "distroseries")
692+ self.archive, "test_1.0_amd64.tar.gz", self.suite)
693 upload.generateSiplKeys()
694 self.assertEqual(2, fake_call.call_count)
695 # Assert the actual command matches.
696@@ -979,22 +989,20 @@ class TestSigning(RunPartsMixin, TestSigningHelpers):
697 This should fall through to the first series,
698 as the second does not have keys.
699 """
700- self.suite = "nokeys-distroseries"
701 first_series = self.factory.makeDistroSeries(
702 self.distro,
703 name="existingkeys"
704 )
705- self.factory.makeDistroSeries(
706- self.distro,
707- name="nokeys"
708- )
709+ self.distroseries = self.factory.makeDistroSeries(
710+ self.distro, name="nokeys")
711+ self.suite = self.distroseries.name
712 # Each image in the tarball is signed.
713 self.setUpUefiKeys()
714 self.setUpUefiKeys(series=first_series)
715 self.openArchive("test", "1.0", "amd64")
716 self.tarfile.add_file("1.0/empty.efi", b"")
717 upload = self.process_emulate()
718- expected_callers = [('UEFI signing', 1),]
719+ expected_callers = [('UEFI signing', 1)]
720 self.assertContentEqual(expected_callers, upload.callLog.caller_list())
721 # Check the correct series name appears in the call arguments
722 self.assertIn(
723@@ -1103,7 +1111,7 @@ class TestSigning(RunPartsMixin, TestSigningHelpers):
724 upload = SigningUpload()
725 upload.callLog = FakeMethodCallLog(upload=upload)
726 upload.setTargetDirectory(
727- self.archive, "test_1.0_amd64.tar.gz", "distroseries")
728+ self.archive, "test_1.0_amd64.tar.gz", "")
729 upload.signUefi(os.path.join(self.makeTemporaryDirectory(), 't.efi'))
730 self.assertEqual(0, upload.callLog.caller_count('UEFI keygen'))
731 self.assertFalse(os.path.exists(self.key))
732@@ -1120,7 +1128,7 @@ class TestSigning(RunPartsMixin, TestSigningHelpers):
733 upload = SigningUpload()
734 upload.callLog = FakeMethodCallLog(upload=upload)
735 upload.setTargetDirectory(
736- self.archive, "test_1.0_amd64.tar.gz", "distroseries")
737+ self.archive, "test_1.0_amd64.tar.gz", "")
738 upload.signUefi(os.path.join(self.makeTemporaryDirectory(), 't.efi'))
739 self.assertEqual(1, upload.callLog.caller_count('UEFI keygen'))
740 self.assertTrue(os.path.exists(self.key))
741@@ -1138,7 +1146,7 @@ class TestSigning(RunPartsMixin, TestSigningHelpers):
742 upload = SigningUpload()
743 upload.callLog = FakeMethodCallLog(upload=upload)
744 upload.setTargetDirectory(
745- self.archive, "test_1.0_amd64.tar.gz", "distroseries")
746+ self.archive, "test_1.0_amd64.tar.gz", "")
747 upload.signFit(os.path.join(self.makeTemporaryDirectory(), 'fit'))
748 self.assertEqual(0, upload.callLog.caller_count('FIT keygen'))
749 self.assertFalse(os.path.exists(self.fit_key))
750@@ -1157,7 +1165,7 @@ class TestSigning(RunPartsMixin, TestSigningHelpers):
751 upload = SigningUpload()
752 upload.callLog = FakeMethodCallLog(upload=upload)
753 upload.setTargetDirectory(
754- self.archive, "test_1.0_amd64.tar.gz", "distroseries")
755+ self.archive, "test_1.0_amd64.tar.gz", "")
756 upload.signFit(os.path.join(self.makeTemporaryDirectory(), 't.fit'))
757 self.assertEqual(1, upload.callLog.caller_count('FIT keygen'))
758 self.assertTrue(os.path.exists(self.fit_key))
759@@ -1175,7 +1183,7 @@ class TestSigning(RunPartsMixin, TestSigningHelpers):
760 upload = SigningUpload()
761 upload.callLog = FakeMethodCallLog(upload=upload)
762 upload.setTargetDirectory(
763- self.archive, "test_1.0_amd64.tar.gz", "distroseries")
764+ self.archive, "test_1.0_amd64.tar.gz", "")
765 upload.signKmod(os.path.join(self.makeTemporaryDirectory(), 't.ko'))
766 self.assertEqual(0, upload.callLog.caller_count('Kmod keygen key'))
767 self.assertEqual(0, upload.callLog.caller_count('Kmod keygen cert'))
768@@ -1193,7 +1201,7 @@ class TestSigning(RunPartsMixin, TestSigningHelpers):
769 upload = SigningUpload()
770 upload.callLog = FakeMethodCallLog(upload=upload)
771 upload.setTargetDirectory(
772- self.archive, "test_1.0_amd64.tar.gz", "distroseries")
773+ self.archive, "test_1.0_amd64.tar.gz", "")
774 upload.signKmod(os.path.join(self.makeTemporaryDirectory(), 't.ko'))
775 self.assertEqual(1, upload.callLog.caller_count('Kmod keygen key'))
776 self.assertEqual(1, upload.callLog.caller_count('Kmod keygen cert'))
777@@ -1212,7 +1220,7 @@ class TestSigning(RunPartsMixin, TestSigningHelpers):
778 upload = SigningUpload()
779 upload.callLog = FakeMethodCallLog(upload=upload)
780 upload.setTargetDirectory(
781- self.archive, "test_1.0_amd64.tar.gz", "distroseries")
782+ self.archive, "test_1.0_amd64.tar.gz", "")
783 upload.signOpal(os.path.join(self.makeTemporaryDirectory(), 't.opal'))
784 self.assertEqual(0, upload.callLog.caller_count('Opal keygen key'))
785 self.assertEqual(0, upload.callLog.caller_count('Opal keygen cert'))
786@@ -1230,7 +1238,7 @@ class TestSigning(RunPartsMixin, TestSigningHelpers):
787 upload = SigningUpload()
788 upload.callLog = FakeMethodCallLog(upload=upload)
789 upload.setTargetDirectory(
790- self.archive, "test_1.0_amd64.tar.gz", "distroseries")
791+ self.archive, "test_1.0_amd64.tar.gz", "")
792 upload.signOpal(os.path.join(self.makeTemporaryDirectory(), 't.opal'))
793 self.assertEqual(1, upload.callLog.caller_count('Opal keygen key'))
794 self.assertEqual(1, upload.callLog.caller_count('Opal keygen cert'))
795@@ -1249,7 +1257,7 @@ class TestSigning(RunPartsMixin, TestSigningHelpers):
796 upload = SigningUpload()
797 upload.callLog = FakeMethodCallLog(upload=upload)
798 upload.setTargetDirectory(
799- self.archive, "test_1.0_amd64.tar.gz", "distroseries")
800+ self.archive, "test_1.0_amd64.tar.gz", "")
801 upload.signOpal(os.path.join(self.makeTemporaryDirectory(), 't.sipl'))
802 self.assertEqual(0, upload.callLog.caller_count('SIPL keygen key'))
803 self.assertEqual(0, upload.callLog.caller_count('SIPL keygen cert'))
804@@ -1267,7 +1275,7 @@ class TestSigning(RunPartsMixin, TestSigningHelpers):
805 upload = SigningUpload()
806 upload.callLog = FakeMethodCallLog(upload=upload)
807 upload.setTargetDirectory(
808- self.archive, "test_1.0_amd64.tar.gz", "distroseries")
809+ self.archive, "test_1.0_amd64.tar.gz", "")
810 upload.signSipl(os.path.join(self.makeTemporaryDirectory(), 't.sipl'))
811 self.assertEqual(1, upload.callLog.caller_count('SIPL keygen key'))
812 self.assertEqual(1, upload.callLog.caller_count('SIPL keygen cert'))
813@@ -1290,7 +1298,7 @@ class TestSigning(RunPartsMixin, TestSigningHelpers):
814 self.tarfile.add_file("1.0/empty.sipl", b"")
815 self.process_emulate()
816 sha256file = os.path.join(self.getSignedPath("test", "amd64"),
817- "1.0", "SHA256SUMS")
818+ "1.0", "SHA256SUMS")
819 self.assertTrue(os.path.exists(sha256file))
820
821 @defer.inlineCallbacks
822@@ -1309,7 +1317,7 @@ class TestSigning(RunPartsMixin, TestSigningHelpers):
823 self.tarfile.add_file("1.0/empty.sipl", b"")
824 self.process_emulate()
825 sha256file = os.path.join(self.getSignedPath("test", "amd64"),
826- "1.0", "SHA256SUMS")
827+ "1.0", "SHA256SUMS")
828 self.assertTrue(os.path.exists(sha256file))
829 self.assertThat(
830 sha256file + '.gpg',
831@@ -1333,7 +1341,7 @@ class TestSigning(RunPartsMixin, TestSigningHelpers):
832 self.tarfile.add_file("1.0/empty.sipl", b"")
833 self.process_emulate()
834 sha256file = os.path.join(self.getSignedPath("test", "amd64"),
835- "1.0", "SHA256SUMS")
836+ "1.0", "SHA256SUMS")
837 self.assertTrue(os.path.exists(sha256file))
838 self.assertThat(
839 sha256file + '.gpg',
840@@ -1344,10 +1352,10 @@ class TestSigning(RunPartsMixin, TestSigningHelpers):
841 "1.0", "signed.tar.gz")
842 with tarfile.open(tarfilename) as tarball:
843 self.assertThat(tarball.getnames(), MatchesAll(*[
844- Not(Contains(name)) for name in [
845- "1.0/SHA256SUMS", "1.0/SHA256SUMS.gpg",
846- "1.0/signed.tar.gz",
847- ]]))
848+ Not(Contains(name)) for name in [
849+ "1.0/SHA256SUMS", "1.0/SHA256SUMS.gpg",
850+ "1.0/signed.tar.gz",
851+ ]]))
852
853 def test_checksumming_tree_signed_with_external_run_parts(self):
854 # Checksum files can be signed using an external run-parts helper.
855@@ -1368,7 +1376,7 @@ class TestSigning(RunPartsMixin, TestSigningHelpers):
856 self.tarfile.add_file("1.0/empty.sipl", "")
857 self.process_emulate()
858 sha256file = os.path.join(self.getSignedPath("test", "amd64"),
859- "1.0", "SHA256SUMS")
860+ "1.0", "SHA256SUMS")
861 self.assertTrue(os.path.exists(sha256file))
862 self.assertEqual(1, run_parts_fixture.new_value.call_count)
863 args, kwargs = run_parts_fixture.new_value.calls[-1]
864@@ -1499,3 +1507,500 @@ class TestUefi(TestSigningHelpers):
865 self.getDistsPath(), "uefi")))
866 self.assertTrue(os.path.exists(os.path.join(
867 self.getSignedPath("test", "amd64"), "1.0", "empty.efi")))
868+
869+
870+class TestSigningUploadWithSigningService(TestSigningHelpers):
871+ """Tests for SigningUpload using lp-signing service
872+ """
873+ layer = ZopelessDatabaseLayer
874+
875+ def setUp(self):
876+ super(TestSigningUploadWithSigningService, self).setUp()
877+ self.useFixture(FeatureFixture({PUBLISHER_USES_SIGNING_SERVICE: True}))
878+
879+ self.signing_service_client = self.useFixture(
880+ SigningServiceClientFixture(self.factory))
881+ self.signing_keys = {
882+ k: v.signing_key for k, v in self.setUpAllKeyTypes(
883+ self.archive).items()}
884+
885+ def setUpAllKeyTypes(self, archive):
886+ """Helper to create
887+
888+ :return: A dict like {key_type: signing_key} with all keys available.
889+ """
890+ keys_per_type = {}
891+ for key_type in SigningKeyType.items:
892+ signing_key = self.factory.makeSigningKey(key_type=key_type)
893+ arch_key = self.factory.makeArchiveSigningKey(
894+ archive=archive, signing_key=signing_key)
895+ keys_per_type[key_type] = arch_key
896+ return keys_per_type
897+
898+ def getArchiveSigningKey(self, key_type):
899+ signing_key = self.factory.makeSigningKey(key_type=key_type)
900+ arch_signing_key = self.factory.makeArchiveSigningKey(
901+ archive=self.archive, signing_key=signing_key)
902+ return arch_signing_key
903+
904+ @staticmethod
905+ def getFileListContent(basedir, filenames):
906+ contents = []
907+ for filename in filenames:
908+ with open(os.path.join(basedir, filename), 'rb') as fd:
909+ contents.append(fd.read())
910+ return contents
911+
912+ def getSignedPath(self, loader_type, arch):
913+ return os.path.join(self.getDistsPath(), "signed",
914+ "%s-%s" % (loader_type, arch))
915+
916+ def process_emulate(self):
917+ """Shortcut to close tarfile and run SigningUpload.process.
918+ """
919+ self.tarfile.close()
920+ self.buffer.close()
921+
922+ upload = SigningUpload()
923+ upload.process(self.archive, self.path, self.suite)
924+ return upload
925+
926+ def test_set_target_directory_with_distroseries(self):
927+ archive = self.factory.makeArchive()
928+ series_name = archive.distribution.series[1].name
929+
930+ upload = SigningUpload()
931+ upload.setTargetDirectory(
932+ archive, "test_1.0_amd64.tar.gz", series_name)
933+
934+ pubconfig = getPubConfig(archive)
935+ self.assertThat(upload, MatchesStructure.byEquality(
936+ distro_series=archive.distribution.series[1],
937+ archive=archive,
938+ autokey=pubconfig.signingautokey))
939+ self.assertEqual(0, self.signing_service_client.generate.call_count)
940+ self.assertEqual(0, self.signing_service_client.sign.call_count)
941+
942+ def test_options_handling_single(self):
943+ """If the configured key/cert are missing, processing succeeds but
944+ nothing is signed.
945+ """
946+ self.openArchive("test", "1.0", "amd64")
947+ self.tarfile.add_file("1.0/control/options", b"first\n")
948+
949+ upload = self.process_emulate()
950+
951+ self.assertContentEqual(['first'], upload.signing_options.keys())
952+
953+ self.assertEqual(0, self.signing_service_client.generate.call_count)
954+ self.assertEqual(0, self.signing_service_client.sign.call_count)
955+
956+ def test_options_handling_multiple(self):
957+ """If the configured key/cert are missing, processing succeeds but
958+ nothing is signed.
959+ """
960+ self.openArchive("test", "1.0", "amd64")
961+ self.tarfile.add_file("1.0/control/options", b"first\nsecond\n")
962+
963+ upload = self.process_emulate()
964+
965+ self.assertContentEqual(['first', 'second'],
966+ upload.signing_options.keys())
967+ self.assertEqual(0, self.signing_service_client.generate.call_count)
968+ self.assertEqual(0, self.signing_service_client.sign.call_count)
969+
970+ def test_options_tarball(self):
971+ """Specifying the "tarball" option should create an tarball in tmpdir.
972+ """
973+ self.openArchive("test", "1.0", "amd64")
974+ self.tarfile.add_file("1.0/control/options", b"tarball")
975+ self.tarfile.add_file("1.0/empty.efi", b"a")
976+ self.tarfile.add_file("1.0/empty.ko", b"b")
977+ self.tarfile.add_file("1.0/empty.opal", b"c")
978+ self.tarfile.add_file("1.0/empty.sipl", b"d")
979+ self.tarfile.add_file("1.0/empty.fit", b"e")
980+
981+ self.process_emulate()
982+
983+ self.assertThat(self.getSignedPath("test", "amd64"), SignedMatches([
984+ "1.0/SHA256SUMS",
985+ "1.0/signed.tar.gz",
986+ ]))
987+ tarfilename = os.path.join(self.getSignedPath("test", "amd64"),
988+ "1.0", "signed.tar.gz")
989+ with tarfile.open(tarfilename) as tarball:
990+ self.assertContentEqual([
991+ '1.0', '1.0/control', '1.0/control/options',
992+ '1.0/empty.efi', '1.0/empty.efi.signed',
993+ '1.0/control/uefi.crt',
994+ '1.0/empty.ko', '1.0/empty.ko.sig', '1.0/control/kmod.x509',
995+ '1.0/empty.opal', '1.0/empty.opal.sig',
996+ '1.0/control/opal.x509',
997+ '1.0/empty.sipl', '1.0/empty.sipl.sig',
998+ '1.0/control/sipl.x509',
999+ '1.0/empty.fit', '1.0/empty.fit.signed',
1000+ '1.0/control/fit.crt',
1001+ ], tarball.getnames())
1002+ self.assertEqual(0, self.signing_service_client.generate.call_count)
1003+ keys = self.signing_keys
1004+ self.assertItemsEqual([
1005+ call(
1006+ SigningKeyType.UEFI, keys[SigningKeyType.UEFI].fingerprint,
1007+ 'empty.efi', b'a', SigningMode.ATTACHED),
1008+ call(
1009+ SigningKeyType.KMOD, keys[SigningKeyType.KMOD].fingerprint,
1010+ 'empty.ko', b'b', SigningMode.DETACHED),
1011+ call(
1012+ SigningKeyType.OPAL, keys[SigningKeyType.OPAL].fingerprint,
1013+ 'empty.opal', b'c', SigningMode.DETACHED),
1014+ call(
1015+ SigningKeyType.SIPL, keys[SigningKeyType.SIPL].fingerprint,
1016+ 'empty.sipl', b'd', SigningMode.DETACHED),
1017+ call(
1018+ SigningKeyType.FIT, keys[SigningKeyType.FIT].fingerprint,
1019+ 'empty.fit', b'e', SigningMode.ATTACHED)],
1020+ self.signing_service_client.sign.call_args_list)
1021+
1022+ def test_options_signed_only(self):
1023+ """Specifying the "signed-only" option should trigger removal of
1024+ the source files leaving signatures only.
1025+ """
1026+ self.openArchive("test", "1.0", "amd64")
1027+ self.tarfile.add_file("1.0/control/options", b"signed-only")
1028+ self.tarfile.add_file("1.0/empty.efi", b"a")
1029+ self.tarfile.add_file("1.0/empty.ko", b"b")
1030+ self.tarfile.add_file("1.0/empty.opal", b"c")
1031+ self.tarfile.add_file("1.0/empty.sipl", b"d")
1032+ self.tarfile.add_file("1.0/empty.fit", b"e")
1033+
1034+ self.process_emulate()
1035+
1036+ self.assertThat(self.getSignedPath("test", "amd64"), SignedMatches([
1037+ "1.0/SHA256SUMS", "1.0/control/options",
1038+ "1.0/empty.efi.signed", "1.0/control/uefi.crt",
1039+ "1.0/empty.ko.sig", "1.0/control/kmod.x509",
1040+ "1.0/empty.opal.sig", "1.0/control/opal.x509",
1041+ "1.0/empty.sipl.sig", "1.0/control/sipl.x509",
1042+ "1.0/empty.fit.signed", "1.0/control/fit.crt",
1043+ ]))
1044+ self.assertEqual(0, self.signing_service_client.generate.call_count)
1045+ keys = self.signing_keys
1046+ self.assertItemsEqual([
1047+ call(
1048+ SigningKeyType.UEFI, keys[SigningKeyType.UEFI].fingerprint,
1049+ 'empty.efi', b'a', SigningMode.ATTACHED),
1050+ call(
1051+ SigningKeyType.KMOD, keys[SigningKeyType.KMOD].fingerprint,
1052+ 'empty.ko', b'b', SigningMode.DETACHED),
1053+ call(
1054+ SigningKeyType.OPAL, keys[SigningKeyType.OPAL].fingerprint,
1055+ 'empty.opal', b'c', SigningMode.DETACHED),
1056+ call(
1057+ SigningKeyType.SIPL, keys[SigningKeyType.SIPL].fingerprint,
1058+ 'empty.sipl', b'd', SigningMode.DETACHED),
1059+ call(
1060+ SigningKeyType.FIT, keys[SigningKeyType.FIT].fingerprint,
1061+ 'empty.fit', b'e', SigningMode.ATTACHED)],
1062+ self.signing_service_client.sign.call_args_list)
1063+
1064+ def test_options_tarball_signed_only(self):
1065+ """Specifying the "tarball" option should create an tarball in
1066+ the tmpdir. Adding signed-only should trigger removal of the
1067+ original files.
1068+ """
1069+ self.openArchive("test", "1.0", "amd64")
1070+ self.tarfile.add_file("1.0/control/options", b"tarball\nsigned-only")
1071+ self.tarfile.add_file("1.0/empty.efi", b"a")
1072+ self.tarfile.add_file("1.0/empty.ko", b"b")
1073+ self.tarfile.add_file("1.0/empty.opal", b"c")
1074+ self.tarfile.add_file("1.0/empty.sipl", b"d")
1075+ self.tarfile.add_file("1.0/empty.fit", b"e")
1076+ self.process_emulate()
1077+ self.assertThat(self.getSignedPath("test", "amd64"), SignedMatches([
1078+ "1.0/SHA256SUMS",
1079+ "1.0/signed.tar.gz",
1080+ ]))
1081+ tarfilename = os.path.join(self.getSignedPath("test", "amd64"),
1082+ "1.0", "signed.tar.gz")
1083+ with tarfile.open(tarfilename) as tarball:
1084+ self.assertContentEqual([
1085+ '1.0', '1.0/control', '1.0/control/options',
1086+ '1.0/empty.efi.signed', '1.0/control/uefi.crt',
1087+ '1.0/empty.ko.sig', '1.0/control/kmod.x509',
1088+ '1.0/empty.opal.sig', '1.0/control/opal.x509',
1089+ '1.0/empty.sipl.sig', '1.0/control/sipl.x509',
1090+ '1.0/empty.fit.signed', '1.0/control/fit.crt',
1091+ ], tarball.getnames())
1092+ self.assertEqual(0, self.signing_service_client.generate.call_count)
1093+ keys = self.signing_keys
1094+ self.assertItemsEqual([
1095+ call(
1096+ SigningKeyType.UEFI, keys[SigningKeyType.UEFI].fingerprint,
1097+ 'empty.efi', b'a', SigningMode.ATTACHED),
1098+ call(
1099+ SigningKeyType.KMOD, keys[SigningKeyType.KMOD].fingerprint,
1100+ 'empty.ko', b'b', SigningMode.DETACHED),
1101+ call(
1102+ SigningKeyType.OPAL, keys[SigningKeyType.OPAL].fingerprint,
1103+ 'empty.opal', b'c', SigningMode.DETACHED),
1104+ call(
1105+ SigningKeyType.SIPL, keys[SigningKeyType.SIPL].fingerprint,
1106+ 'empty.sipl', b'd', SigningMode.DETACHED),
1107+ call(
1108+ SigningKeyType.FIT, keys[SigningKeyType.FIT].fingerprint,
1109+ 'empty.fit', b'e', SigningMode.ATTACHED)],
1110+ self.signing_service_client.sign.call_args_list)
1111+
1112+ def test_archive_copy(self):
1113+ """If there is no key/cert configuration, processing succeeds but
1114+ nothing is signed.
1115+ """
1116+ self.archive = self.factory.makeArchive(
1117+ distribution=self.distro, purpose=ArchivePurpose.COPY)
1118+
1119+ pubconf = getPubConfig(self.archive)
1120+ if not os.path.exists(pubconf.temproot):
1121+ os.makedirs(pubconf.temproot)
1122+ self.openArchive("test", "1.0", "amd64")
1123+ self.tarfile.add_file("1.0/empty.efi", b"a")
1124+ self.tarfile.add_file("1.0/empty.ko", b"b")
1125+ self.tarfile.add_file("1.0/empty.opal", b"c")
1126+ self.tarfile.add_file("1.0/empty.sipl", b"d")
1127+ self.tarfile.add_file("1.0/empty.fit", b"e")
1128+ self.tarfile.close()
1129+ self.buffer.close()
1130+
1131+ upload = SigningUpload()
1132+ upload.process(self.archive, self.path, self.suite)
1133+
1134+ signed_path = self.getSignedPath("test", "amd64")
1135+ self.assertThat(signed_path, SignedMatches(
1136+ ["1.0/SHA256SUMS", "1.0/empty.efi", "1.0/empty.ko",
1137+ "1.0/empty.opal", "1.0/empty.sipl", "1.0/empty.fit", ]))
1138+
1139+ self.assertEqual(0, self.signing_service_client.generate.call_count)
1140+ self.assertEqual(0, self.signing_service_client.sign.call_count)
1141+
1142+ def test_sign_without_autokey_and_no_key_pre_set(self):
1143+ """This case should raise exception, since we don't have fallback
1144+ keys on the filesystem to cover for the missing signing service
1145+ keys.
1146+ """
1147+ self.distro = self.factory.makeDistribution()
1148+ self.distroseries = self.factory.makeDistroSeries(
1149+ distribution=self.distro)
1150+ self.suite = self.distroseries.name
1151+ self.archive = self.factory.makeArchive(
1152+ distribution=self.distro, purpose=ArchivePurpose.PRIMARY)
1153+
1154+ filenames = [
1155+ "1.0/empty.efi", "1.0/empty.ko", "1.0/empty.opal",
1156+ "1.0/empty.sipl", "1.0/empty.fit"]
1157+
1158+ # Write data on the archive
1159+ self.openArchive("test", "1.0", "amd64")
1160+ for filename in filenames:
1161+ self.tarfile.add_file(filename, b"somedata for %s" % filename)
1162+
1163+ self.assertRaises(IOError, self.process_emulate)
1164+
1165+ def test_sign_without_autokey_and_some_keys_pre_set(self):
1166+ """For no autokey archives, signing process should sign only for the
1167+ available keys, and skip signing the other files.
1168+ """
1169+ # Pre-generate KMOD and OPAL keys
1170+ self.getArchiveSigningKey(SigningKeyType.KMOD)
1171+ self.getArchiveSigningKey(SigningKeyType.OPAL)
1172+
1173+ filenames = ["1.0/empty.ko", "1.0/empty.opal"]
1174+
1175+ self.openArchive("test", "1.0", "amd64")
1176+ for filename in filenames:
1177+ self.tarfile.add_file(filename, b"some data for %s" % filename)
1178+
1179+ self.process_emulate()
1180+
1181+ signed_path = self.getSignedPath("test", "amd64")
1182+ self.assertThat(signed_path, SignedMatches(filenames + [
1183+ "1.0/SHA256SUMS", "1.0/empty.ko.sig", "1.0/empty.opal.sig",
1184+ "1.0/control/kmod.x509", "1.0/control/opal.x509"]))
1185+
1186+ self.assertEqual(0, self.signing_service_client.generate.call_count)
1187+ keys = self.signing_keys
1188+ self.assertItemsEqual([
1189+ call(
1190+ SigningKeyType.KMOD, keys[SigningKeyType.KMOD].fingerprint,
1191+ 'empty.ko', b'some data for 1.0/empty.ko',
1192+ SigningMode.DETACHED),
1193+ call(
1194+ SigningKeyType.OPAL, keys[SigningKeyType.OPAL].fingerprint,
1195+ 'empty.opal', b'some data for 1.0/empty.opal',
1196+ SigningMode.DETACHED)],
1197+ self.signing_service_client.sign.call_args_list)
1198+
1199+ def test_sign_with_autokey_ppa(self):
1200+ # PPAs should auto-generate keys. Let's use one for this test.
1201+ self.setUpPPA()
1202+
1203+ filenames = [
1204+ "1.0/empty.efi", "1.0/empty.ko", "1.0/empty.opal",
1205+ "1.0/empty.sipl", "1.0/empty.fit"]
1206+
1207+ self.openArchive("test", "1.0", "amd64")
1208+ for filename in filenames:
1209+ self.tarfile.add_file(filename, b"data - %s" % filename)
1210+
1211+ self.tarfile.close()
1212+ self.buffer.close()
1213+
1214+ upload = SigningUpload()
1215+ upload.process(self.archive, self.path, self.suite)
1216+
1217+ self.assertTrue(upload.autokey)
1218+
1219+ expected_signed_filenames = [
1220+ "1.0/empty.efi.signed", "1.0/empty.ko.sig",
1221+ "1.0/empty.opal.sig", "1.0/empty.sipl.sig",
1222+ "1.0/empty.fit.signed"]
1223+
1224+ expected_public_keys_filenames = [
1225+ "1.0/control/uefi.crt", "1.0/control/kmod.x509",
1226+ "1.0/control/opal.x509", "1.0/control/sipl.x509",
1227+ "1.0/control/fit.crt"]
1228+
1229+ signed_path = self.getSignedPath("test", "amd64")
1230+ self.assertThat(signed_path, SignedMatches(
1231+ ["1.0/SHA256SUMS"] + filenames + expected_public_keys_filenames +
1232+ expected_signed_filenames))
1233+
1234+ self.assertEqual(5, self.signing_service_client.generate.call_count)
1235+ self.assertEqual(5, self.signing_service_client.sign.call_count)
1236+
1237+ fingerprints = {
1238+ key_type: data['fingerprint'] for key_type, data in
1239+ self.signing_service_client.generate_returns}
1240+ self.assertItemsEqual([
1241+ call(
1242+ SigningKeyType.UEFI, fingerprints[SigningKeyType.UEFI],
1243+ 'empty.efi', b'data - 1.0/empty.efi', SigningMode.ATTACHED),
1244+ call(
1245+ SigningKeyType.KMOD, fingerprints[SigningKeyType.KMOD],
1246+ 'empty.ko', b'data - 1.0/empty.ko', SigningMode.DETACHED),
1247+ call(
1248+ SigningKeyType.OPAL, fingerprints[SigningKeyType.OPAL],
1249+ 'empty.opal', b'data - 1.0/empty.opal', SigningMode.DETACHED),
1250+ call(
1251+ SigningKeyType.SIPL, fingerprints[SigningKeyType.SIPL],
1252+ 'empty.sipl', b'data - 1.0/empty.sipl', SigningMode.DETACHED),
1253+ call(
1254+ SigningKeyType.FIT, fingerprints[SigningKeyType.FIT],
1255+ 'empty.fit', b'data - 1.0/empty.fit', SigningMode.ATTACHED)],
1256+ self.signing_service_client.sign.call_args_list)
1257+
1258+ # Checks that all files got signed
1259+ contents = self.getFileListContent(
1260+ signed_path, expected_signed_filenames)
1261+ key_types = (
1262+ SigningKeyType.UEFI, SigningKeyType.KMOD, SigningKeyType.OPAL,
1263+ SigningKeyType.SIPL, SigningKeyType.FIT)
1264+ expected_signed_contents = [
1265+ b"signed with key_type=%s" % k.name for k in key_types]
1266+ self.assertItemsEqual(expected_signed_contents, contents)
1267+
1268+ # Checks that all public keys ended up in the 1.0/control/xxx files
1269+ public_keys = {
1270+ key_type: data['public-key'] for key_type, data in
1271+ self.signing_service_client.generate_returns}
1272+ contents = self.getFileListContent(
1273+ signed_path, expected_public_keys_filenames)
1274+ expected_public_keys = [
1275+ public_keys[k] for k in key_types]
1276+ self.assertEqual(expected_public_keys, contents)
1277+
1278+ def test_fallback_handler(self):
1279+ upload = SigningUpload()
1280+
1281+ # Creating a new archive since our setUp method fills the self.archive
1282+ # with signing keys, and we don't want that here.
1283+ self.distro = self.factory.makeDistribution()
1284+ self.distroseries = self.factory.makeDistroSeries(
1285+ distribution=self.distro)
1286+ self.suite = self.distroseries.name
1287+ self.archive = self.factory.makeArchive(
1288+ distribution=self.distro,
1289+ purpose=ArchivePurpose.PRIMARY)
1290+ pubconf = getPubConfig(self.archive)
1291+ if not os.path.exists(pubconf.temproot):
1292+ os.makedirs(pubconf.temproot)
1293+ self.addCleanup(lambda: shutil.rmtree(pubconf.temproot, True))
1294+ old_umask = os.umask(0o022)
1295+ self.addCleanup(os.umask, old_umask)
1296+ self.addCleanup(lambda: shutil.rmtree(pubconf.distroroot, True))
1297+
1298+ # Make KMOD signing fail with an exception.
1299+ def mock_sign(key_type, *args, **kwargs):
1300+ if key_type == SigningKeyType.KMOD:
1301+ raise ValueError("!!")
1302+ return self.signing_service_client._sign(key_type, *args, **kwargs)
1303+
1304+ self.signing_service_client.sign.side_effect = mock_sign
1305+
1306+ # Pre-set KMOD fails on ".sign" method (should fallback to local
1307+ # signing method).
1308+ self.getArchiveSigningKey(SigningKeyType.KMOD)
1309+ upload.signKmod = FakeMethod(result=0)
1310+
1311+ # We don't have a signing service key for UEFI. Should fallback too.
1312+ upload.signUefi = FakeMethod(result=0)
1313+
1314+ # OPAL key works just fine.
1315+ self.getArchiveSigningKey(SigningKeyType.OPAL)
1316+ upload.signOpal = FakeMethod(result=0)
1317+
1318+ filenames = ["1.0/empty.efi", "1.0/empty.ko", "1.0/empty.opal"]
1319+
1320+ self.openArchive("test", "1.0", "amd64")
1321+ for filename in filenames:
1322+ self.tarfile.add_file(filename, b"data - %s" % filename)
1323+
1324+ self.tarfile.close()
1325+ self.buffer.close()
1326+
1327+ # Small hack to keep the tmpdir used during upload.process
1328+ # Without this hack, upload.tmpdir is set back to None at the end of
1329+ # process() method execution, during cleanup phase.
1330+ original_cleanup = upload.cleanup
1331+
1332+ def intercept_cleanup():
1333+ upload.tmpdir_used = upload.tmpdir
1334+ original_cleanup()
1335+
1336+ upload.cleanup = intercept_cleanup
1337+
1338+ # Pretend that all key files exists, so the fallback calls are not
1339+ # blocked.
1340+ upload.keyFilesExist = lambda _: True
1341+
1342+ upload.process(self.archive, self.path, self.suite)
1343+
1344+ # Make sure it only used the existing keys and fallbacks. No new key
1345+ # should be generated.
1346+ self.assertFalse(upload.autokey)
1347+
1348+ self.assertEqual(0, self.signing_service_client.generate.call_count)
1349+ self.assertEqual(2, self.signing_service_client.sign.call_count)
1350+
1351+ # Check kmod signing
1352+ self.assertEqual(1, upload.signKmod.call_count)
1353+ self.assertEqual(
1354+ [(os.path.join(upload.tmpdir_used, "1.0/empty.ko"), )],
1355+ upload.signKmod.extract_args())
1356+
1357+ # Check OPAL signing
1358+ self.assertEqual(0, upload.signOpal.call_count)
1359+
1360+ # Check UEFI signing
1361+ self.assertEqual(1, upload.signUefi.call_count)
1362+ self.assertEqual(
1363+ [(os.path.join(upload.tmpdir_used, "1.0/empty.efi"),)],
1364+ upload.signUefi.extract_args())
1365diff --git a/lib/lp/services/compat.py b/lib/lp/services/compat.py
1366index 942e690..8f3c813 100644
1367--- a/lib/lp/services/compat.py
1368+++ b/lib/lp/services/compat.py
1369@@ -11,9 +11,16 @@ from __future__ import absolute_import, print_function, unicode_literals
1370 __metaclass__ = type
1371 __all__ = [
1372 'SafeConfigParser',
1373+ 'mock',
1374 ]
1375
1376 try:
1377 from configparser import ConfigParser as SafeConfigParser
1378 except ImportError:
1379 from ConfigParser import SafeConfigParser
1380+
1381+
1382+try:
1383+ import mock
1384+except ImportError:
1385+ from unittest import mock
1386diff --git a/lib/lp/services/config/schema-lazr.conf b/lib/lp/services/config/schema-lazr.conf
1387index c43a908..18b452b 100644
1388--- a/lib/lp/services/config/schema-lazr.conf
1389+++ b/lib/lp/services/config/schema-lazr.conf
1390@@ -358,7 +358,7 @@ update_preview_diff_ready_timeout: 15
1391 # An HTTP service that will have status 200 OK when the service is available
1392 # for more connections, and 503 Service Unavailable when it is in the process
1393 # of shutting down and so should not receive any more connections.
1394-web_status_port = tcp:8022
1395+web_status_port: tcp:8022
1396
1397 # The URL of the internal Bazaar hosting API endpoint.
1398 internal_bzr_api_endpoint: none
1399@@ -1147,7 +1147,7 @@ dbname: session_prod
1400
1401
1402 [librarianlogparser]
1403-logs_root = /srv/launchpadlibrarian.net-logs
1404+logs_root: /srv/launchpadlibrarian.net-logs
1405
1406
1407 [librarian]
1408@@ -1203,7 +1203,7 @@ download_url: http://librarian.launchpad.net/
1409 # datatype: urlbase
1410 restricted_download_url: http://restricted-librarian.launchpad.net/
1411
1412-use_https = True
1413+use_https: True
1414
1415 # The URL of the XML-RPC endpoint that handles verifying macaroons. This
1416 # should implement IAuthServer.
1417@@ -1568,11 +1568,17 @@ generate_templates: True
1418 [rosetta_pofile_stats]
1419 # In daily runs of pofile statistics update, check for
1420 # POFiles that have been updated in the last how many days.
1421-days_considered_recent = 7
1422+days_considered_recent: 7
1423
1424 # Number of seconds each LoopTuner iteration should take.
1425-looptuner_iteration_duration = 4
1426-
1427+looptuner_iteration_duration: 4
1428+
1429+# lp-signing service connection info. See lp-signing's documentation on how to
1430+# get valid keys.
1431+[signing]
1432+signing_endpoint: none
1433+client_private_key: none
1434+client_public_key: none
1435
1436 # For the personal standing updater cron script.
1437 [standingupdater]
1438diff --git a/lib/lp/services/configure.zcml b/lib/lp/services/configure.zcml
1439index b85bfa5..f23faea 100644
1440--- a/lib/lp/services/configure.zcml
1441+++ b/lib/lp/services/configure.zcml
1442@@ -1,4 +1,4 @@
1443-<!-- Copyright 2010-2019 Canonical Ltd. This software is licensed under the
1444+<!-- Copyright 2010-2020 Canonical Ltd. This software is licensed under the
1445 GNU Affero General Public License version 3 (see the file LICENSE).
1446 -->
1447
1448@@ -26,6 +26,7 @@
1449 <include package=".profile" />
1450 <include package=".scripts" />
1451 <include package=".session" />
1452+ <include package=".signing" />
1453 <include package=".sitesearch" />
1454 <include package=".statistics" />
1455 <include package=".temporaryblobstorage" />
1456diff --git a/lib/lp/services/features/flags.py b/lib/lp/services/features/flags.py
1457index 9fdb4a0..4694cac 100644
1458--- a/lib/lp/services/features/flags.py
1459+++ b/lib/lp/services/features/flags.py
1460@@ -1,4 +1,4 @@
1461-# Copyright 2010-2019 Canonical Ltd. This software is licensed under the
1462+# Copyright 2010-2020 Canonical Ltd. This software is licensed under the
1463 # GNU Affero General Public License version 3 (see the file LICENSE).
1464
1465 __all__ = [
1466@@ -227,6 +227,12 @@ flag_info = sorted([
1467 'bing',
1468 'Site search engine',
1469 ''),
1470+ ('archivepublisher.signing_service.enabled',
1471+ 'boolean',
1472+ 'If true, sign packages using signing service instead of local files.',
1473+ '',
1474+ '',
1475+ ''),
1476 ])
1477
1478 # The set of all flag names that are documented.
1479diff --git a/lib/lp/services/signing/__init__.py b/lib/lp/services/signing/__init__.py
1480new file mode 100644
1481index 0000000..e69de29
1482--- /dev/null
1483+++ b/lib/lp/services/signing/__init__.py
1484diff --git a/lib/lp/services/signing/configure.zcml b/lib/lp/services/signing/configure.zcml
1485new file mode 100644
1486index 0000000..4b72d7d
1487--- /dev/null
1488+++ b/lib/lp/services/signing/configure.zcml
1489@@ -0,0 +1,27 @@
1490+<configure
1491+ xmlns="http://namespaces.zope.org/zope">
1492+
1493+ <class class="lp.services.signing.model.signingkey.ArchiveSigningKey">
1494+ <allow
1495+ interface="lp.services.signing.interfaces.signingkey.IArchiveSigningKey"/>
1496+ </class>
1497+
1498+ <class class="lp.services.signing.model.signingkey.SigningKey">
1499+ <allow
1500+ interface="lp.services.signing.interfaces.signingkey.ISigningKey"/>
1501+ </class>
1502+
1503+ <securedutility
1504+ class="lp.services.signing.model.signingkey.ArchiveSigningKeySet"
1505+ provides="lp.services.signing.interfaces.signingkey.IArchiveSigningKeySet">
1506+ <allow
1507+ interface="lp.services.signing.interfaces.signingkey.IArchiveSigningKeySet"/>
1508+ </securedutility>
1509+
1510+ <securedutility
1511+ class="lp.services.signing.proxy.SigningServiceClient"
1512+ provides="lp.services.signing.interfaces.signingserviceclient.ISigningServiceClient">
1513+ <allow
1514+ interface="lp.services.signing.interfaces.signingserviceclient.ISigningServiceClient" />
1515+ </securedutility>
1516+</configure>
1517diff --git a/lib/lp/services/signing/enums.py b/lib/lp/services/signing/enums.py
1518new file mode 100644
1519index 0000000..fdffe5a
1520--- /dev/null
1521+++ b/lib/lp/services/signing/enums.py
1522@@ -0,0 +1,65 @@
1523+# Copyright 2009-2020 Canonical Ltd. This software is licensed under the
1524+# GNU Affero General Public License version 3 (see the file LICENSE).
1525+
1526+"""Enums for signing keys management
1527+"""
1528+
1529+__metaclass__ = type
1530+
1531+__all__ = [
1532+ 'SigningKeyType',
1533+ 'SigningMode',
1534+ ]
1535+
1536+from lazr.enum import (
1537+ DBEnumeratedType,
1538+ DBItem,
1539+ EnumeratedType,
1540+ Item,
1541+ )
1542+
1543+
1544+class SigningKeyType(DBEnumeratedType):
1545+ """Available key types on lp-signing service.
1546+
1547+ These items should be kept in sync with
1548+ lp-signing:lp-signing/lp_signing/enums.py (specially the numbers) to
1549+ avoid confusion when reading values from different databases.
1550+ """
1551+ UEFI = DBItem(1, """
1552+ UEFI
1553+
1554+ A signing key for UEFI Secure Boot images.
1555+ """)
1556+
1557+ KMOD = DBItem(2, """
1558+ Kmod
1559+
1560+ A signing key for kernel modules.
1561+ """)
1562+
1563+ OPAL = DBItem(3, """
1564+ OPAL
1565+
1566+ A signing key for OPAL kernel images.
1567+ """)
1568+
1569+ SIPL = DBItem(4, """
1570+ SIPL
1571+
1572+ A signing key for Secure Initial Program Load kernel images.
1573+ """)
1574+
1575+ FIT = DBItem(5, """
1576+ FIT
1577+
1578+ A signing key for U-Boot Flat Image Tree images.
1579+ """)
1580+
1581+
1582+class SigningMode(EnumeratedType):
1583+ """Archive file signing mode."""
1584+
1585+ ATTACHED = Item("Attached signature")
1586+ DETACHED = Item("Detached signature")
1587+ CLEAR = Item("Cleartext signature")
1588diff --git a/lib/lp/services/signing/interfaces/__init__.py b/lib/lp/services/signing/interfaces/__init__.py
1589new file mode 100644
1590index 0000000..e69de29
1591--- /dev/null
1592+++ b/lib/lp/services/signing/interfaces/__init__.py
1593diff --git a/lib/lp/services/signing/interfaces/signingkey.py b/lib/lp/services/signing/interfaces/signingkey.py
1594new file mode 100644
1595index 0000000..594a053
1596--- /dev/null
1597+++ b/lib/lp/services/signing/interfaces/signingkey.py
1598@@ -0,0 +1,126 @@
1599+# Copyright 2020 Canonical Ltd. This software is licensed under the
1600+# GNU Affero General Public License version 3 (see the file LICENSE).
1601+
1602+"""Interfaces for signing keys stored at the signing service."""
1603+
1604+__metaclass__ = type
1605+
1606+__all__ = [
1607+ 'IArchiveSigningKey',
1608+ 'IArchiveSigningKeySet',
1609+ 'ISigningKey',
1610+ 'ISigningKeySet',
1611+]
1612+
1613+from lazr.restful.fields import Reference
1614+from zope.interface.interface import Interface
1615+from zope.schema import (
1616+ Bytes,
1617+ Choice,
1618+ Datetime,
1619+ Int,
1620+ Text,
1621+ )
1622+
1623+from lp import _
1624+from lp.registry.interfaces.distroseries import IDistroSeries
1625+from lp.services.signing.enums import SigningKeyType
1626+from lp.soyuz.interfaces.archive import IArchive
1627+
1628+
1629+class ISigningKey(Interface):
1630+ """A key registered to sign uploaded files"""
1631+
1632+ id = Int(title=_('ID'), required=True, readonly=True)
1633+
1634+ key_type = Choice(
1635+ title=_("The signing key type (UEFI, KMOD, etc)."),
1636+ required=True, readonly=True, vocabulary=SigningKeyType)
1637+
1638+ fingerprint = Text(
1639+ title=_("Fingerprint of the key"), required=True, readonly=True)
1640+
1641+ public_key = Bytes(
1642+ title=_("Public key binary content"), required=False,
1643+ readonly=True)
1644+
1645+ date_created = Datetime(
1646+ title=_('When this key was created'), required=True, readonly=True)
1647+
1648+ def sign(message, message_name):
1649+ """Sign the given message using this key
1650+
1651+ :param message: The message to be signed.
1652+ :param message_name: A name for the message being signed.
1653+ """
1654+
1655+
1656+class ISigningKeySet(Interface):
1657+ """Interface to deal with the collection of signing keys
1658+ """
1659+
1660+ def generate(key_type, description=None):
1661+ """Generates a new signing key on lp-signing and stores it in LP's
1662+ database.
1663+
1664+ :param key_type: One of the SigningKeyType enum's value
1665+ :param description: (optional) The description associated with this
1666+ key
1667+ :returns: The SigningKey object associated with the newly created
1668+ key at lp-signing"""
1669+
1670+
1671+class IArchiveSigningKey(Interface):
1672+ """Which signing key should be used by a specific archive"""
1673+
1674+ id = Int(title=_('ID'), required=True, readonly=True)
1675+
1676+ archive = Reference(
1677+ IArchive, title=_("Archive"), required=True, readonly=True,
1678+ description=_("The archive that owns this key."))
1679+
1680+ earliest_distro_series = Reference(
1681+ IDistroSeries, title=_("Distro series"), required=False, readonly=True,
1682+ description=_("The minimum series that uses this key, if any."))
1683+
1684+ key_type = Choice(
1685+ title=_("The signing key type (UEFI, KMOD, etc)."),
1686+ required=True, readonly=True, vocabulary=SigningKeyType)
1687+
1688+ signing_key = Reference(
1689+ ISigningKey, title=_("Signing key"), required=True, readonly=True,
1690+ description=_("Which signing key should be used by this archive"))
1691+
1692+
1693+class IArchiveSigningKeySet(Interface):
1694+ """Management class to deal with ArchiveSigningKey objects
1695+ """
1696+
1697+ def create(archive, earliest_distro_series, signing_key):
1698+ """Creates a new ArchiveSigningKey for archive/distro_series.
1699+
1700+ :return: A tuple like (db_object:ArchiveSigningKey, created:boolean)
1701+ with the ArchiveSigningKey and True if it was created (
1702+ False if it was updated).
1703+ """
1704+
1705+ def getSigningKey(key_type, archive, distro_series):
1706+ """Get the most suitable key for a given archive / distro series
1707+ pair.
1708+
1709+ :return: The most suitable key
1710+ """
1711+
1712+ def generate(key_type, archive, earliest_distro_series=None,
1713+ description=None):
1714+ """Generate a new key on signing service, and save it to db.
1715+
1716+ :param key_type: One of the SigningKeyType enum's value
1717+ :param archive: The package Archive that should be associated with
1718+ this key
1719+ :param earliest_distro_series: (optional) The minimum distro series
1720+ that should use the generated key.
1721+ :param description: (optional) The description associated with this
1722+ key
1723+ :returns: The generated ArchiveSigningKey
1724+ """
1725diff --git a/lib/lp/services/signing/interfaces/signingserviceclient.py b/lib/lp/services/signing/interfaces/signingserviceclient.py
1726new file mode 100644
1727index 0000000..0d2a242
1728--- /dev/null
1729+++ b/lib/lp/services/signing/interfaces/signingserviceclient.py
1730@@ -0,0 +1,47 @@
1731+# Copyright 2020 Canonical Ltd. This software is licensed under the
1732+# GNU Affero General Public License version 3 (see the file LICENSE).
1733+
1734+"""Interfaces for signing keys stored at the signing service."""
1735+
1736+__metaclass__ = type
1737+
1738+__all__ = [
1739+ 'ISigningServiceClient',
1740+ ]
1741+
1742+from zope.interface import (
1743+ Attribute,
1744+ Interface,
1745+ )
1746+
1747+from lp import _
1748+
1749+
1750+class ISigningServiceClient(Interface):
1751+ service_public_key = Attribute(_("The public key of signing service."))
1752+ private_key = Attribute(_("This client's private key."))
1753+
1754+ def getNonce():
1755+ """Get nonce, to be used when sending messages.
1756+ """
1757+
1758+ def generate(key_type, description):
1759+ """Generate a key to be used when signing.
1760+
1761+ :param key_type: One of available key types at SigningKeyType
1762+ :param description: String description of the generated key
1763+ :return: A dict with 'fingerprint' (str) and 'public-key' (bytes)
1764+ """
1765+
1766+ def sign(key_type, fingerprint, message_name, message, mode):
1767+ """Sign the given message using the specified key_type and a
1768+ pre-generated fingerprint (see `generate` method).
1769+
1770+ :param key_type: One of the key types from SigningKeyType enum
1771+ :param fingerprint: The fingerprint of the signing key, generated by
1772+ the `generate` method
1773+ :param message_name: A description of the message being signed
1774+ :param message: The message to be signed
1775+ :param mode: SigningMode.ATTACHED or SigningMode.DETACHED
1776+ :return: A dict with 'public-key' and 'signed-message'
1777+ """
1778diff --git a/lib/lp/services/signing/model/__init__.py b/lib/lp/services/signing/model/__init__.py
1779new file mode 100644
1780index 0000000..e69de29
1781--- /dev/null
1782+++ b/lib/lp/services/signing/model/__init__.py
1783diff --git a/lib/lp/services/signing/model/signingkey.py b/lib/lp/services/signing/model/signingkey.py
1784new file mode 100644
1785index 0000000..e77c200
1786--- /dev/null
1787+++ b/lib/lp/services/signing/model/signingkey.py
1788@@ -0,0 +1,196 @@
1789+# Copyright 2020 Canonical Ltd. This software is licensed under the
1790+# GNU Affero General Public License version 3 (see the file LICENSE).
1791+
1792+"""Database classes to manage signing keys stored at the signing service."""
1793+
1794+
1795+__metaclass__ = type
1796+
1797+__all__ = [
1798+ 'ArchiveSigningKey',
1799+ 'ArchiveSigningKeySet',
1800+ 'SigningKey',
1801+ ]
1802+
1803+from collections import defaultdict
1804+
1805+import pytz
1806+from storm.locals import (
1807+ Bytes,
1808+ DateTime,
1809+ Int,
1810+ Reference,
1811+ Unicode,
1812+ )
1813+from zope.component import getUtility
1814+from zope.interface import (
1815+ implementer,
1816+ provider,
1817+ )
1818+
1819+from lp.services.database.constants import (
1820+ DEFAULT,
1821+ UTC_NOW,
1822+ )
1823+from lp.services.database.enumcol import DBEnum
1824+from lp.services.database.interfaces import (
1825+ IMasterStore,
1826+ IStore,
1827+ )
1828+from lp.services.database.stormbase import StormBase
1829+from lp.services.signing.enums import (
1830+ SigningKeyType,
1831+ SigningMode,
1832+ )
1833+from lp.services.signing.interfaces.signingkey import (
1834+ IArchiveSigningKey,
1835+ IArchiveSigningKeySet,
1836+ ISigningKey,
1837+ ISigningKeySet,
1838+ )
1839+from lp.services.signing.interfaces.signingserviceclient import (
1840+ ISigningServiceClient,
1841+ )
1842+
1843+
1844+@implementer(ISigningKey)
1845+@provider(ISigningKeySet)
1846+class SigningKey(StormBase):
1847+ """A key stored at lp-signing, used to sign uploaded files and packages"""
1848+
1849+ __storm_table__ = 'SigningKey'
1850+
1851+ id = Int(primary=True)
1852+
1853+ key_type = DBEnum(enum=SigningKeyType, allow_none=False)
1854+
1855+ description = Unicode(allow_none=True)
1856+
1857+ fingerprint = Unicode(allow_none=False)
1858+
1859+ public_key = Bytes(allow_none=False)
1860+
1861+ date_created = DateTime(
1862+ allow_none=False, default=UTC_NOW, tzinfo=pytz.UTC)
1863+
1864+ def __init__(self, key_type, fingerprint, public_key,
1865+ description=None, date_created=DEFAULT):
1866+ """Builds the signing key
1867+
1868+ :param key_type: One of the SigningKeyType enum items
1869+ :param fingerprint: The key's fingerprint
1870+ :param public_key: The key's public key (raw; not base64-encoded)
1871+ """
1872+ super(SigningKey, self).__init__()
1873+ self.key_type = key_type
1874+ self.fingerprint = fingerprint
1875+ self.public_key = public_key
1876+ self.description = description
1877+ self.date_created = date_created
1878+
1879+ @classmethod
1880+ def generate(cls, key_type, description=None):
1881+ signing_service = getUtility(ISigningServiceClient)
1882+ generated_key = signing_service.generate(key_type, description)
1883+ signing_key = SigningKey(
1884+ key_type=key_type, fingerprint=generated_key['fingerprint'],
1885+ public_key=generated_key['public-key'],
1886+ description=description)
1887+ store = IMasterStore(SigningKey)
1888+ store.add(signing_key)
1889+ return signing_key
1890+
1891+ def sign(self, message, message_name):
1892+ if self.key_type in (SigningKeyType.UEFI, SigningKeyType.FIT):
1893+ mode = SigningMode.ATTACHED
1894+ else:
1895+ mode = SigningMode.DETACHED
1896+ signing_service = getUtility(ISigningServiceClient)
1897+ signed = signing_service.sign(
1898+ self.key_type, self.fingerprint, message_name, message, mode)
1899+ return signed['signed-message']
1900+
1901+
1902+@implementer(IArchiveSigningKey)
1903+class ArchiveSigningKey(StormBase):
1904+ """Which signing key should be used by a given archive / series.
1905+ """
1906+
1907+ __storm_table__ = 'ArchiveSigningKey'
1908+
1909+ id = Int(primary=True)
1910+
1911+ archive_id = Int(name="archive", allow_none=False)
1912+ archive = Reference(archive_id, 'Archive.id')
1913+
1914+ earliest_distro_series_id = Int(
1915+ name="earliest_distro_series", allow_none=True)
1916+ earliest_distro_series = Reference(
1917+ earliest_distro_series_id, 'DistroSeries.id')
1918+
1919+ key_type = DBEnum(enum=SigningKeyType, allow_none=False)
1920+
1921+ signing_key_id = Int(name="signing_key", allow_none=False)
1922+ signing_key = Reference(signing_key_id, SigningKey.id)
1923+
1924+ def __init__(self, archive=None, earliest_distro_series=None,
1925+ signing_key=None):
1926+ super(ArchiveSigningKey, self).__init__()
1927+ self.archive = archive
1928+ self.signing_key = signing_key
1929+ self.key_type = signing_key.key_type
1930+ self.earliest_distro_series = earliest_distro_series
1931+
1932+
1933+@implementer(IArchiveSigningKeySet)
1934+class ArchiveSigningKeySet:
1935+
1936+ @classmethod
1937+ def create(cls, archive, earliest_distro_series, signing_key):
1938+ store = IMasterStore(SigningKey)
1939+ obj = ArchiveSigningKey(archive, earliest_distro_series, signing_key)
1940+ store.add(obj)
1941+ return obj
1942+
1943+ @classmethod
1944+ def getSigningKey(cls, key_type, archive, distro_series):
1945+ store = IStore(ArchiveSigningKey)
1946+ # Gets all the keys of the given key_type available for the archive
1947+ rs = store.find(ArchiveSigningKey,
1948+ SigningKey.id == ArchiveSigningKey.signing_key_id,
1949+ SigningKey.key_type == key_type,
1950+ ArchiveSigningKey.key_type == key_type,
1951+ ArchiveSigningKey.archive == archive)
1952+
1953+ # prefetch related signing keys to avoid extra queries.
1954+ signing_keys = store.find(SigningKey, [
1955+ SigningKey.id.is_in([i.signing_key_id for i in rs])])
1956+ signing_keys_by_id = {i.id: i for i in signing_keys}
1957+
1958+ # Group keys per type, and per distro series
1959+ keys_per_series = defaultdict(dict)
1960+ for i in rs:
1961+ signing_key = signing_keys_by_id[i.signing_key_id]
1962+ keys_per_series[i.earliest_distro_series] = signing_key
1963+
1964+ # Let's search the most suitable per key type.
1965+ found_series = False
1966+ # Note that archive.distribution.series is, by default, sorted by
1967+ # "version", reversed.
1968+ for series in archive.distribution.series:
1969+ if series == distro_series:
1970+ found_series = True
1971+ if found_series and series in keys_per_series:
1972+ return keys_per_series[series]
1973+ # If no specific key for distro_series was found, returns
1974+ # the keys for the archive itself (or None if no key is
1975+ # available for the archive either).
1976+ return keys_per_series.get(None)
1977+
1978+ @classmethod
1979+ def generate(cls, key_type, archive, earliest_distro_series=None,
1980+ description=None):
1981+ signing_key = SigningKey.generate(key_type, description)
1982+ archive_signing = ArchiveSigningKeySet.create(
1983+ archive, earliest_distro_series, signing_key)
1984+ return archive_signing
1985diff --git a/lib/lp/services/signing/proxy.py b/lib/lp/services/signing/proxy.py
1986new file mode 100644
1987index 0000000..d8542e3
1988--- /dev/null
1989+++ b/lib/lp/services/signing/proxy.py
1990@@ -0,0 +1,182 @@
1991+# Copyright 2009-2020 Canonical Ltd. This software is licensed under the
1992+# GNU Affero General Public License version 3 (see the file LICENSE).
1993+
1994+"""Proxy calls to lp-signing service"""
1995+
1996+from __future__ import absolute_import, print_function, unicode_literals
1997+
1998+__metaclass__ = type
1999+
2000+import base64
2001+import json
2002+
2003+from lazr.restful.utils import get_current_browser_request
2004+from nacl.encoding import Base64Encoder
2005+from nacl.public import (
2006+ Box,
2007+ PrivateKey,
2008+ PublicKey,
2009+ )
2010+from nacl.utils import random
2011+from six.moves.urllib.parse import urljoin
2012+from zope.interface import implementer
2013+
2014+from lp.services.config import config
2015+from lp.services.propertycache import (
2016+ cachedproperty,
2017+ get_property_cache,
2018+ )
2019+from lp.services.signing.enums import (
2020+ SigningKeyType,
2021+ SigningMode,
2022+ )
2023+from lp.services.signing.interfaces.signingserviceclient import (
2024+ ISigningServiceClient,
2025+ )
2026+from lp.services.timeline.requesttimeline import get_request_timeline
2027+from lp.services.timeout import urlfetch
2028+
2029+
2030+@implementer(ISigningServiceClient)
2031+class SigningServiceClient:
2032+ """Representation of lp-signing service REST interface
2033+
2034+ To benefit from caching, use this class as a singleton through
2035+ getUtility(ISigningServiceClient).
2036+ """
2037+
2038+ def _cleanCaches(self):
2039+ """Cleanup cached properties"""
2040+ del get_property_cache(self).service_public_key
2041+
2042+ def getUrl(self, path):
2043+ """Shortcut to concatenate lp-signing address with the desired
2044+ endpoint path.
2045+
2046+ :param path: The REST endpoint to be joined.
2047+ """
2048+ base_url = config.signing.signing_endpoint
2049+ return urljoin(base_url, path)
2050+
2051+ def _makeResponseNonce(self):
2052+ return random(Box.NONCE_SIZE)
2053+
2054+ def _decryptResponseJson(self, response, response_nonce):
2055+ box = Box(self.private_key, self.service_public_key)
2056+ return json.loads(box.decrypt(
2057+ response.content, response_nonce, encoder=Base64Encoder))
2058+
2059+ def _requestJson(self, path, method="GET", **kwargs):
2060+ """Helper method to do an HTTP request and get back a json from the
2061+ signing service, raising exception if status code != 2xx.
2062+
2063+ :param path: The endpoint path
2064+ :param method: The HTTP method to be used (GET, POST, etc)
2065+ :param needs_resp_nonce: Indicates if the endpoint requires us to
2066+ include a X-Response-Nonce, and returns back an encrypted
2067+ response JSON.
2068+ """
2069+ timeline = get_request_timeline(get_current_browser_request())
2070+ action = timeline.start(
2071+ "services-signing-proxy-%s" % method, "%s %s" %
2072+ (path, json.dumps(kwargs)))
2073+
2074+ headers = kwargs.get("headers", {})
2075+ response_nonce = None
2076+ if "X-Response-Nonce" in headers:
2077+ response_nonce = base64.b64decode(headers["X-Response-Nonce"])
2078+
2079+ try:
2080+ url = self.getUrl(path)
2081+ response = urlfetch(url, method=method.lower(), **kwargs)
2082+ response.raise_for_status()
2083+ if response_nonce is None:
2084+ return response.json()
2085+ else:
2086+ return self._decryptResponseJson(response, response_nonce)
2087+ finally:
2088+ action.finish()
2089+
2090+ @cachedproperty
2091+ def service_public_key(self):
2092+ """Returns the lp-signing service's public key.
2093+ """
2094+ data = self._requestJson("/service-key")
2095+ return PublicKey(data["service-key"], encoder=Base64Encoder)
2096+
2097+ @property
2098+ def private_key(self):
2099+ return PrivateKey(
2100+ config.signing.client_private_key, encoder=Base64Encoder)
2101+
2102+ def getNonce(self):
2103+ data = self._requestJson("/nonce", "POST")
2104+ return base64.b64decode(data["nonce"].encode("UTF-8"))
2105+
2106+ def _getAuthHeaders(self, nonce, response_nonce):
2107+ """Get headers to call authenticated endpoints.
2108+
2109+ :param nonce: The nonce bytes to be used (not the base64 encoded one!)
2110+ :param response_nonce: The X-Response-Nonce bytes to be used to
2111+ decrypt the boxed response.
2112+ :return: Header dict, ready to be used by requests
2113+ """
2114+ return {
2115+ "Content-Type": "application/x-boxed-json",
2116+ "X-Client-Public-Key": config.signing.client_public_key,
2117+ "X-Nonce": base64.b64encode(nonce),
2118+ "X-Response-Nonce": base64.b64encode(response_nonce),
2119+ }
2120+
2121+ def _encryptPayload(self, nonce, message):
2122+ """Returns the encrypted version of message, base64 encoded and
2123+ ready to be sent on a HTTP request to lp-signing service.
2124+
2125+ :param nonce: The original (non-base64 encoded) nonce
2126+ :param message: The str message to be encrypted
2127+ """
2128+ box = Box(self.private_key, self.service_public_key)
2129+ encrypted_message = box.encrypt(message, nonce, encoder=Base64Encoder)
2130+ return encrypted_message.ciphertext
2131+
2132+ def generate(self, key_type, description):
2133+ if key_type not in SigningKeyType.items:
2134+ raise ValueError("%s is not a valid key type" % key_type)
2135+
2136+ nonce = self.getNonce()
2137+ response_nonce = self._makeResponseNonce()
2138+ data = json.dumps({
2139+ "key-type": key_type.name,
2140+ "description": description,
2141+ }).encode("UTF-8")
2142+ ret = self._requestJson(
2143+ "/generate", "POST",
2144+ headers=self._getAuthHeaders(nonce, response_nonce),
2145+ data=self._encryptPayload(nonce, data))
2146+ return {
2147+ "fingerprint": ret["fingerprint"],
2148+ "public-key": base64.b64decode(ret["public-key"])}
2149+
2150+ def sign(self, key_type, fingerprint, message_name, message, mode):
2151+ if mode not in {SigningMode.ATTACHED, SigningMode.DETACHED}:
2152+ raise ValueError("%s is not a valid mode" % mode)
2153+ if key_type not in SigningKeyType.items:
2154+ raise ValueError("%s is not a valid key type" % key_type)
2155+
2156+ nonce = self.getNonce()
2157+ response_nonce = self._makeResponseNonce()
2158+ data = json.dumps({
2159+ "key-type": key_type.name,
2160+ "fingerprint": fingerprint,
2161+ "message-name": message_name,
2162+ "message": base64.b64encode(message).decode("UTF-8"),
2163+ "mode": mode.name,
2164+ }).encode("UTF-8")
2165+ data = self._requestJson(
2166+ "/sign", "POST",
2167+ headers=self._getAuthHeaders(nonce, response_nonce),
2168+ data=self._encryptPayload(nonce, data))
2169+
2170+ return {
2171+ 'public-key': base64.b64decode(data['public-key']),
2172+ 'signed-message': base64.b64decode(data['signed-message'])}
2173diff --git a/lib/lp/services/signing/tests/__init__.py b/lib/lp/services/signing/tests/__init__.py
2174new file mode 100644
2175index 0000000..e69de29
2176--- /dev/null
2177+++ b/lib/lp/services/signing/tests/__init__.py
2178diff --git a/lib/lp/services/signing/tests/helpers.py b/lib/lp/services/signing/tests/helpers.py
2179new file mode 100644
2180index 0000000..e01730f
2181--- /dev/null
2182+++ b/lib/lp/services/signing/tests/helpers.py
2183@@ -0,0 +1,67 @@
2184+# Copyright 2009-2020 Canonical Ltd. This software is licensed under the
2185+# GNU Affero General Public License version 3 (see the file LICENSE).
2186+
2187+"""Helper functions for code testing live here."""
2188+
2189+from __future__ import absolute_import, print_function, unicode_literals
2190+
2191+__metaclass__ = type
2192+__all__ = [
2193+ 'SigningServiceClientFixture',
2194+ ]
2195+
2196+import fixtures
2197+from nacl.public import PrivateKey
2198+from six import text_type
2199+
2200+from lp.services.compat import mock
2201+from lp.services.signing.interfaces.signingserviceclient import (
2202+ ISigningServiceClient,
2203+ )
2204+from lp.testing.fixture import ZopeUtilityFixture
2205+
2206+
2207+class SigningServiceClientFixture(fixtures.Fixture):
2208+ """Mock for SigningServiceClient class.
2209+
2210+ This method fakes the API calls on generate and sign methods,
2211+ and provides a nice way of getting the fake returned values on
2212+ self.generate_returns and self.sign_returns attributes.
2213+
2214+ Both generate_returns and sign_returns format is the following:
2215+ [(key_type, api_return_dict), (key_type, api_return_dict), ...]"""
2216+ def __init__(self, factory):
2217+ self.factory = factory
2218+
2219+ self.generate = mock.Mock()
2220+ self.generate.side_effect = self._generate
2221+
2222+ self.sign = mock.Mock()
2223+ self.sign.side_effect = self._sign
2224+
2225+ self.generate_returns = []
2226+ self.sign_returns = []
2227+
2228+ def _generate(self, key_type, description):
2229+ key = bytes(PrivateKey.generate().public_key)
2230+ data = {
2231+ "fingerprint": text_type(self.factory.getUniqueHexString(40)),
2232+ "public-key": key}
2233+ self.generate_returns.append((key_type, data))
2234+ return data
2235+
2236+ def _sign(self, key_type, fingerprint, message_name, message, mode):
2237+ key = bytes(PrivateKey.generate().public_key)
2238+ signed_msg = "signed with key_type={}".format(key_type.name)
2239+ data = {
2240+ 'public-key': key,
2241+ 'signed-message': signed_msg}
2242+ self.sign_returns.append((key_type, data))
2243+ return data
2244+
2245+ def _setUp(self):
2246+ self.useFixture(ZopeUtilityFixture(self, ISigningServiceClient))
2247+
2248+ def _cleanup(self):
2249+ self.generate_returns = []
2250+ self.sign_returns = []
2251diff --git a/lib/lp/services/signing/tests/test_proxy.py b/lib/lp/services/signing/tests/test_proxy.py
2252new file mode 100644
2253index 0000000..a972ea3
2254--- /dev/null
2255+++ b/lib/lp/services/signing/tests/test_proxy.py
2256@@ -0,0 +1,318 @@
2257+# Copyright 2010-2020 Canonical Ltd. This software is licensed under the
2258+# GNU Affero General Public License version 3 (see the file LICENSE).
2259+
2260+__metaclass__ = type
2261+
2262+import base64
2263+import json
2264+
2265+from fixtures import MockPatch
2266+from fixtures.testcase import TestWithFixtures
2267+from nacl.encoding import Base64Encoder
2268+from nacl.public import (
2269+ Box,
2270+ PrivateKey,
2271+ PublicKey,
2272+ )
2273+from nacl.utils import random
2274+import responses
2275+from testtools.matchers import (
2276+ ContainsDict,
2277+ Equals,
2278+ )
2279+from zope.component import getUtility
2280+from zope.security.proxy import removeSecurityProxy
2281+
2282+from lp.services.config import config
2283+from lp.services.signing.enums import (
2284+ SigningKeyType,
2285+ SigningMode,
2286+ )
2287+from lp.services.signing.interfaces.signingserviceclient import (
2288+ ISigningServiceClient,
2289+ )
2290+from lp.services.signing.proxy import SigningServiceClient
2291+from lp.testing import TestCaseWithFactory
2292+from lp.testing.layers import ZopelessLayer
2293+
2294+
2295+class SigningServiceResponseFactory:
2296+ """Factory for fake responses from lp-signing service.
2297+
2298+ This class is a helper to pretend that lp-signing service is running by
2299+ mocking `requests` module, and returning fake responses from
2300+ response.get(url) and response.post(url). See `patch` method.
2301+ """
2302+ def __init__(self):
2303+ self.service_private_key = PrivateKey.generate()
2304+ self.service_public_key = self.service_private_key.public_key
2305+ self.b64_service_public_key = self.service_public_key.encode(
2306+ encoder=Base64Encoder).decode("UTF-8")
2307+
2308+ self.client_private_key = PrivateKey(
2309+ config.signing.client_private_key, encoder=Base64Encoder)
2310+ self.client_public_key = self.client_private_key.public_key
2311+
2312+ self.nonce = random(Box.NONCE_SIZE)
2313+ self.b64_nonce = base64.b64encode(self.nonce).decode("UTF-8")
2314+
2315+ self.generated_public_key = PrivateKey.generate().public_key
2316+ self.b64_generated_public_key = base64.b64encode(
2317+ bytes(self.generated_public_key))
2318+ self.generated_fingerprint = (
2319+ u'338D218488DFD597D8FCB9C328C3E9D9ADA16CEE')
2320+ self.b64_signed_msg = base64.b64encode(b"the-signed-msg")
2321+
2322+ self.signed_msg_template = "%s::signed!"
2323+
2324+ @classmethod
2325+ def getUrl(cls, path):
2326+ """Shortcut to get full path of an endpoint at lp-signing.
2327+ """
2328+ return SigningServiceClient().getUrl(path)
2329+
2330+ def _encryptPayload(self, data, nonce):
2331+ """Translated the given data dict as a boxed json, encrypted as
2332+ lp-signing would do."""
2333+ box = Box(self.service_private_key, self.client_public_key)
2334+ return box.encrypt(
2335+ json.dumps(data), nonce, encoder=Base64Encoder).ciphertext
2336+
2337+ def getAPISignedContent(self, call_index=0):
2338+ """Returns the signed message returned by the API.
2339+
2340+ This is a shortcut to avoid inspecting and decrypting API calls,
2341+ since we know that the content of /sign calls are hardcoded by this
2342+ fixture.
2343+ """
2344+ return self.signed_msg_template % (call_index + 1)
2345+
2346+ def addResponses(self, test_case):
2347+ """Patches all requests with default test values.
2348+
2349+ This method uses `responses` module to mock `requests`. You should use
2350+ @responses.activate decorator in your test method before
2351+ calling this method.
2352+
2353+ See https://github.com/getsentry/responses for details on how to
2354+ inspect the HTTP calls made.
2355+
2356+ Other helpful attributes are:
2357+ - self.b64_service_public_key
2358+ - self.b64_nonce
2359+ - self.generated_public_key
2360+ - self.generated_fingerprint
2361+ which holds the respective values used in the default fake responses.
2362+
2363+ The /sign endpoint will return, as signed message, "$n::signed!",
2364+ where $n is the call number (base64-encoded, as lp-signing would
2365+ return). This could be useful on black-box tests, where several
2366+ calls to /sign would be done and the response should be checked.
2367+ """
2368+ # Patch SigningServiceClient._makeResponseNonce to return always the
2369+ # same nonce, to simplify the tests.
2370+ response_nonce = random(Box.NONCE_SIZE)
2371+ test_case.useFixture(MockPatch(
2372+ 'lp.services.signing.proxy.SigningServiceClient.'
2373+ '_makeResponseNonce',
2374+ return_value=response_nonce))
2375+
2376+ responses.add(
2377+ responses.GET, self.getUrl("/service-key"),
2378+ json={"service-key": self.b64_service_public_key.decode('utf8')},
2379+ status=200)
2380+
2381+ responses.add(
2382+ responses.POST, self.getUrl("/nonce"),
2383+ json={"nonce": self.b64_nonce.decode('utf8')}, status=201)
2384+
2385+ responses.add(
2386+ responses.POST, self.getUrl("/generate"),
2387+ body=self._encryptPayload({
2388+ 'fingerprint': self.generated_fingerprint,
2389+ 'public-key': self.b64_generated_public_key.decode('utf8')
2390+ }, nonce=response_nonce),
2391+ status=201)
2392+ call_counts = {'/sign': 0}
2393+
2394+ def sign_callback(request):
2395+ call_counts['/sign'] += 1
2396+ signed = base64.b64encode(
2397+ self.signed_msg_template % call_counts['/sign'])
2398+ data = {'signed-message': signed.decode('utf8'),
2399+ 'public-key': self.b64_generated_public_key.decode('utf8')}
2400+ return 201, {}, self._encryptPayload(data, response_nonce)
2401+
2402+ responses.add_callback(
2403+ responses.POST, self.getUrl("/sign"),
2404+ callback=sign_callback)
2405+
2406+
2407+class SigningServiceProxyTest(TestCaseWithFactory, TestWithFixtures):
2408+ """Tests signing service without actually making calls to lp-signing.
2409+
2410+ Every REST call is mocked using self.response_factory, and most of this
2411+ class's work is actually calling those endpoints. So, many things are
2412+ mocked here, returning fake responses created at
2413+ SigningServiceResponseFactory.
2414+ """
2415+ layer = ZopelessLayer
2416+
2417+ def setUp(self, *args, **kwargs):
2418+ super(TestCaseWithFactory, self).setUp(*args, **kwargs)
2419+ self.response_factory = SigningServiceResponseFactory()
2420+
2421+ client = removeSecurityProxy(getUtility(ISigningServiceClient))
2422+ self.addCleanup(client._cleanCaches)
2423+
2424+ @responses.activate
2425+ def test_get_service_public_key(self):
2426+ self.response_factory.addResponses(self)
2427+
2428+ signing = getUtility(ISigningServiceClient)
2429+ key = removeSecurityProxy(signing.service_public_key)
2430+
2431+ # Asserts that the public key is correct.
2432+ self.assertIsInstance(key, PublicKey)
2433+ self.assertEqual(
2434+ key.encode(Base64Encoder),
2435+ self.response_factory.b64_service_public_key)
2436+
2437+ # Checks that the HTTP call was made
2438+ self.assertEqual(1, len(responses.calls))
2439+ call = responses.calls[0]
2440+ self.assertEqual("GET", call.request.method)
2441+ self.assertEqual(
2442+ self.response_factory.getUrl("/service-key"), call.request.url)
2443+
2444+ @responses.activate
2445+ def test_get_nonce(self):
2446+ self.response_factory.addResponses(self)
2447+
2448+ signing = getUtility(ISigningServiceClient)
2449+ nonce = signing.getNonce()
2450+
2451+ self.assertEqual(
2452+ base64.b64encode(nonce), self.response_factory.b64_nonce)
2453+
2454+ # Checks that the HTTP call was made
2455+ self.assertEqual(1, len(responses.calls))
2456+ call = responses.calls[0]
2457+ self.assertEqual("POST", call.request.method)
2458+ self.assertEqual(
2459+ self.response_factory.getUrl("/nonce"), call.request.url)
2460+
2461+ @responses.activate
2462+ def test_generate_unknown_key_type_raises_exception(self):
2463+ self.response_factory.addResponses(self)
2464+
2465+ signing = getUtility(ISigningServiceClient)
2466+ self.assertRaises(
2467+ ValueError, signing.generate, "banana", "Wrong key type")
2468+ self.assertEqual(0, len(responses.calls))
2469+
2470+ @responses.activate
2471+ def test_generate_key(self):
2472+ """Makes sure that the SigningService.generate method calls the
2473+ correct endpoints
2474+ """
2475+ self.response_factory.addResponses(self)
2476+ # Generate the key, and checks if we got back the correct dict.
2477+ signing = getUtility(ISigningServiceClient)
2478+ generated = signing.generate(SigningKeyType.UEFI, "my lp test key")
2479+
2480+ self.assertEqual(generated, {
2481+ 'public-key': bytes(self.response_factory.generated_public_key),
2482+ 'fingerprint': self.response_factory.generated_fingerprint})
2483+
2484+ self.assertEqual(3, len(responses.calls))
2485+
2486+ # expected order of HTTP calls
2487+ http_nonce, http_service_key, http_generate = responses.calls
2488+
2489+ self.assertEqual("POST", http_nonce.request.method)
2490+ self.assertEqual(
2491+ self.response_factory.getUrl("/nonce"), http_nonce.request.url)
2492+
2493+ self.assertEqual("GET", http_service_key.request.method)
2494+ self.assertEqual(
2495+ self.response_factory.getUrl("/service-key"),
2496+ http_service_key.request.url)
2497+
2498+ self.assertEqual("POST", http_generate.request.method)
2499+ self.assertEqual(
2500+ self.response_factory.getUrl("/generate"),
2501+ http_generate.request.url)
2502+ self.assertThat(http_generate.request.headers, ContainsDict({
2503+ "Content-Type": Equals("application/x-boxed-json"),
2504+ "X-Client-Public-Key": Equals(config.signing.client_public_key),
2505+ "X-Nonce": Equals(self.response_factory.b64_nonce)}))
2506+ self.assertIsNotNone(http_generate.request.body)
2507+
2508+ @responses.activate
2509+ def test_sign_invalid_mode(self):
2510+ signing = getUtility(ISigningServiceClient)
2511+ self.assertRaises(
2512+ ValueError, signing.sign,
2513+ SigningKeyType.UEFI, 'fingerprint', 'message_name', 'message',
2514+ 'NO-MODE')
2515+ self.assertEqual(0, len(responses.calls))
2516+
2517+ @responses.activate
2518+ def test_sign_invalid_key_type(self):
2519+ signing = getUtility(ISigningServiceClient)
2520+ self.assertRaises(
2521+ ValueError, signing.sign,
2522+ 'shrug', 'fingerprint', 'message_name', 'message',
2523+ SigningMode.ATTACHED)
2524+ self.assertEqual(0, len(responses.calls))
2525+
2526+ @responses.activate
2527+ def test_sign(self):
2528+ """Runs through SignService.sign() flow"""
2529+ # Replace GET /service-key response by our mock.
2530+ resp_factory = self.response_factory
2531+ resp_factory.addResponses(self)
2532+
2533+ fingerprint = self.factory.getUniqueHexString(40).upper()
2534+ key_type = SigningKeyType.KMOD
2535+ mode = SigningMode.DETACHED
2536+ message_name = 'my test msg'
2537+ message = 'this is the message content'
2538+
2539+ signing = getUtility(ISigningServiceClient)
2540+ data = signing.sign(
2541+ key_type, fingerprint, message_name, message, mode)
2542+
2543+ self.assertEqual(3, len(responses.calls))
2544+ # expected order of HTTP calls
2545+ http_nonce, http_service_key, http_sign = responses.calls
2546+
2547+ self.assertEqual("POST", http_nonce.request.method)
2548+ self.assertEqual(
2549+ self.response_factory.getUrl("/nonce"), http_nonce.request.url)
2550+
2551+ self.assertEqual("GET", http_service_key.request.method)
2552+ self.assertEqual(
2553+ self.response_factory.getUrl("/service-key"),
2554+ http_service_key.request.url)
2555+
2556+ self.assertEqual("POST", http_sign.request.method)
2557+ self.assertEqual(
2558+ self.response_factory.getUrl("/sign"),
2559+ http_sign.request.url)
2560+ self.assertThat(http_sign.request.headers, ContainsDict({
2561+ "Content-Type": Equals("application/x-boxed-json"),
2562+ "X-Client-Public-Key": Equals(config.signing.client_public_key),
2563+ "X-Nonce": Equals(self.response_factory.b64_nonce)}))
2564+ self.assertIsNotNone(http_sign.request.body)
2565+
2566+ # It should have returned the correct JSON content, with signed
2567+ # message from the API and the public-key.
2568+ self.assertEqual(2, len(data))
2569+ self.assertEqual(
2570+ self.response_factory.getAPISignedContent(),
2571+ data['signed-message'])
2572+ self.assertEqual(
2573+ bytes(self.response_factory.generated_public_key),
2574+ data['public-key'])
2575diff --git a/lib/lp/services/signing/tests/test_signingkey.py b/lib/lp/services/signing/tests/test_signingkey.py
2576new file mode 100644
2577index 0000000..2e0a5df
2578--- /dev/null
2579+++ b/lib/lp/services/signing/tests/test_signingkey.py
2580@@ -0,0 +1,244 @@
2581+# Copyright 2010-2020 Canonical Ltd. This software is licensed under the
2582+# GNU Affero General Public License version 3 (see the file LICENSE).
2583+
2584+__metaclass__ = type
2585+
2586+import base64
2587+
2588+from fixtures.testcase import TestWithFixtures
2589+import responses
2590+from storm.store import Store
2591+from testtools.matchers import MatchesStructure
2592+from zope.component import getUtility
2593+from zope.security.proxy import removeSecurityProxy
2594+
2595+from lp.services.database.interfaces import IMasterStore
2596+from lp.services.signing.enums import SigningKeyType
2597+from lp.services.signing.interfaces.signingkey import IArchiveSigningKeySet
2598+from lp.services.signing.interfaces.signingserviceclient import (
2599+ ISigningServiceClient,
2600+ )
2601+from lp.services.signing.model.signingkey import (
2602+ ArchiveSigningKey,
2603+ SigningKey,
2604+ )
2605+from lp.services.signing.tests.test_proxy import SigningServiceResponseFactory
2606+from lp.testing import TestCaseWithFactory
2607+from lp.testing.layers import ZopelessDatabaseLayer
2608+
2609+
2610+class TestSigningKey(TestCaseWithFactory, TestWithFixtures):
2611+
2612+ layer = ZopelessDatabaseLayer
2613+
2614+ def setUp(self, *args, **kwargs):
2615+ super(TestSigningKey, self).setUp(*args, **kwargs)
2616+ self.signing_service = SigningServiceResponseFactory()
2617+
2618+ client = removeSecurityProxy(getUtility(ISigningServiceClient))
2619+ self.addCleanup(client._cleanCaches)
2620+
2621+ @responses.activate
2622+ def test_generate_signing_key_saves_correctly(self):
2623+ self.signing_service.addResponses(self)
2624+
2625+ key = SigningKey.generate(SigningKeyType.UEFI, u"this is my key")
2626+ self.assertIsInstance(key, SigningKey)
2627+
2628+ store = IMasterStore(SigningKey)
2629+ store.invalidate()
2630+
2631+ rs = store.find(SigningKey)
2632+ self.assertEqual(1, rs.count())
2633+ db_key = rs.one()
2634+
2635+ self.assertEqual(SigningKeyType.UEFI, db_key.key_type)
2636+ self.assertEqual(
2637+ self.signing_service.generated_fingerprint, db_key.fingerprint)
2638+ self.assertEqual(
2639+ self.signing_service.b64_generated_public_key,
2640+ base64.b64encode(db_key.public_key))
2641+ self.assertEqual("this is my key", db_key.description)
2642+
2643+ @responses.activate
2644+ def test_sign_some_data(self):
2645+ self.signing_service.addResponses(self)
2646+
2647+ s = SigningKey(
2648+ SigningKeyType.UEFI, u"a fingerprint",
2649+ bytes(self.signing_service.generated_public_key),
2650+ description=u"This is my key!")
2651+ signed = s.sign("secure message", "message_name")
2652+
2653+ # Checks if the returned value is actually the returning value from
2654+ # HTTP POST /sign call to lp-signing service
2655+ self.assertEqual(3, len(responses.calls))
2656+ self.assertEqual(self.signing_service.getAPISignedContent(), signed)
2657+
2658+
2659+class TestArchiveSigningKey(TestCaseWithFactory):
2660+ layer = ZopelessDatabaseLayer
2661+
2662+ def setUp(self, *args, **kwargs):
2663+ super(TestArchiveSigningKey, self).setUp(*args, **kwargs)
2664+ self.signing_service = SigningServiceResponseFactory()
2665+
2666+ client = removeSecurityProxy(getUtility(ISigningServiceClient))
2667+ self.addCleanup(client._cleanCaches)
2668+
2669+ @responses.activate
2670+ def test_generate_saves_correctly(self):
2671+ self.signing_service.addResponses(self)
2672+
2673+ archive = self.factory.makeArchive()
2674+ distro_series = archive.distribution.series[0]
2675+
2676+ arch_key = getUtility(IArchiveSigningKeySet).generate(
2677+ SigningKeyType.UEFI, archive, earliest_distro_series=distro_series,
2678+ description=u"some description")
2679+
2680+ store = Store.of(arch_key)
2681+ store.invalidate()
2682+
2683+ rs = store.find(ArchiveSigningKey)
2684+ self.assertEqual(1, rs.count())
2685+
2686+ db_arch_key = rs.one()
2687+ self.assertThat(db_arch_key, MatchesStructure.byEquality(
2688+ key_type=SigningKeyType.UEFI, archive=archive,
2689+ earliest_distro_series=distro_series))
2690+
2691+ self.assertThat(db_arch_key.signing_key, MatchesStructure.byEquality(
2692+ key_type=SigningKeyType.UEFI, description=u"some description",
2693+ fingerprint=self.signing_service.generated_fingerprint,
2694+ public_key=bytes(self.signing_service.generated_public_key)))
2695+
2696+ def test_create(self):
2697+ archive = self.factory.makeArchive()
2698+ distro_series = archive.distribution.series[0]
2699+ signing_key = self.factory.makeSigningKey()
2700+
2701+ arch_signing_key_set = getUtility(IArchiveSigningKeySet)
2702+
2703+ arch_key = arch_signing_key_set.create(
2704+ archive, distro_series, signing_key)
2705+
2706+ store = Store.of(arch_key)
2707+ store.invalidate()
2708+ rs = store.find(ArchiveSigningKey)
2709+
2710+ self.assertEqual(1, rs.count())
2711+ db_arch_key = rs.one()
2712+ self.assertThat(db_arch_key, MatchesStructure.byEquality(
2713+ key_type=signing_key.key_type, archive=archive,
2714+ earliest_distro_series=distro_series,
2715+ signing_key=signing_key))
2716+
2717+ # Saving another type should create a new entry
2718+ signing_key_from_another_type = self.factory.makeSigningKey(
2719+ key_type=SigningKeyType.KMOD)
2720+ arch_signing_key_set.create(
2721+ archive, distro_series, signing_key_from_another_type)
2722+
2723+ self.assertEqual(2, store.find(ArchiveSigningKey).count())
2724+
2725+ def test_get_signing_keys_without_distro_series_configured(self):
2726+ UEFI = SigningKeyType.UEFI
2727+ KMOD = SigningKeyType.KMOD
2728+
2729+ archive = self.factory.makeArchive()
2730+ distro_series = archive.distribution.series[0]
2731+ uefi_key = self.factory.makeSigningKey(
2732+ key_type=SigningKeyType.UEFI)
2733+ kmod_key = self.factory.makeSigningKey(
2734+ key_type=SigningKeyType.KMOD)
2735+
2736+ # Fill the database with keys from other archives to make sure we
2737+ # are filtering it out
2738+ other_archive = self.factory.makeArchive()
2739+ arch_signing_key_set = getUtility(IArchiveSigningKeySet)
2740+ arch_signing_key_set.create(
2741+ other_archive, None, self.factory.makeSigningKey())
2742+
2743+ # Create a key for the archive (no specific series)
2744+ arch_uefi_key = arch_signing_key_set.create(
2745+ archive, None, uefi_key)
2746+ arch_kmod_key = arch_signing_key_set.create(
2747+ archive, None, kmod_key)
2748+
2749+ # Should find the keys if we ask for the archive key
2750+ self.assertEqual(
2751+ arch_uefi_key.signing_key,
2752+ arch_signing_key_set.getSigningKey(UEFI, archive, None))
2753+ self.assertEqual(
2754+ arch_kmod_key.signing_key,
2755+ arch_signing_key_set.getSigningKey(KMOD, archive, None))
2756+
2757+ # Should find the key if we ask for archive + distro_series key
2758+ self.assertEqual(
2759+ arch_uefi_key.signing_key,
2760+ arch_signing_key_set.getSigningKey(UEFI, archive, distro_series))
2761+ self.assertEqual(
2762+ arch_kmod_key.signing_key,
2763+ arch_signing_key_set.getSigningKey(KMOD, archive, distro_series))
2764+
2765+ def test_get_signing_keys_with_distro_series_configured(self):
2766+ UEFI = SigningKeyType.UEFI
2767+ KMOD = SigningKeyType.KMOD
2768+
2769+ archive = self.factory.makeArchive()
2770+ series = archive.distribution.series
2771+ uefi_key = self.factory.makeSigningKey(key_type=UEFI)
2772+ kmod_key = self.factory.makeSigningKey(key_type=KMOD)
2773+
2774+ # Fill the database with keys from other archives to make sure we
2775+ # are filtering it out
2776+ other_archive = self.factory.makeArchive()
2777+ arch_signing_key_set = getUtility(IArchiveSigningKeySet)
2778+ arch_signing_key_set.create(
2779+ other_archive, None, self.factory.makeSigningKey())
2780+
2781+ # Create a key for the archive (no specific series)
2782+ arch_uefi_key = arch_signing_key_set.create(
2783+ archive, None, uefi_key)
2784+
2785+ # for kmod, should give back this one if provided a
2786+ # newer distro series
2787+ arch_kmod_key = arch_signing_key_set.create(
2788+ archive, series[1], kmod_key)
2789+ old_arch_kmod_key = arch_signing_key_set.create(
2790+ archive, series[2], kmod_key)
2791+
2792+ # If no distroseries is specified, it should give back no KMOD key,
2793+ # since we don't have a default
2794+ self.assertEqual(
2795+ arch_uefi_key.signing_key,
2796+ arch_signing_key_set.getSigningKey(UEFI, archive, None))
2797+ self.assertEqual(
2798+ None,
2799+ arch_signing_key_set.getSigningKey(KMOD, archive, None))
2800+
2801+ # For the most recent series, use the KMOD key we've set for the
2802+ # previous one
2803+ self.assertEqual(
2804+ arch_uefi_key.signing_key,
2805+ arch_signing_key_set.getSigningKey(UEFI, archive, series[0]))
2806+ self.assertEqual(
2807+ arch_kmod_key.signing_key,
2808+ arch_signing_key_set.getSigningKey(KMOD, archive, series[0]))
2809+
2810+ # For the previous series, we have a KMOD key configured
2811+ self.assertEqual(
2812+ arch_uefi_key.signing_key,
2813+ arch_signing_key_set.getSigningKey(UEFI, archive, series[1]))
2814+ self.assertEqual(
2815+ arch_kmod_key.signing_key,
2816+ arch_signing_key_set.getSigningKey(KMOD, archive, series[1]))
2817+
2818+ # For the old series, we have an old KMOD key configured
2819+ self.assertEqual(
2820+ arch_uefi_key.signing_key,
2821+ arch_signing_key_set.getSigningKey(UEFI, archive, series[2]))
2822+ self.assertEqual(
2823+ old_arch_kmod_key.signing_key,
2824+ arch_signing_key_set.getSigningKey(KMOD, archive, series[2]))
2825diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py
2826index 8e0f1c1..a3b7736 100644
2827--- a/lib/lp/testing/factory.py
2828+++ b/lib/lp/testing/factory.py
2829@@ -283,6 +283,9 @@ from lp.services.propertycache import (
2830 clear_property_cache,
2831 get_property_cache,
2832 )
2833+from lp.services.signing.enums import SigningKeyType
2834+from lp.services.signing.interfaces.signingkey import IArchiveSigningKeySet
2835+from lp.services.signing.model.signingkey import SigningKey
2836 from lp.services.temporaryblobstorage.interfaces import (
2837 ITemporaryStorageManager,
2838 )
2839@@ -4190,6 +4193,32 @@ class BareLaunchpadObjectFactory(ObjectFactory):
2840 removeSecurityProxy(bpr).datecreated = date_created
2841 return bpr
2842
2843+ def makeSigningKey(self, key_type=None, fingerprint=None,
2844+ public_key=None, description=None):
2845+ """Makes a SigningKey (integration with lp-signing)
2846+ """
2847+ if key_type is None:
2848+ key_type = SigningKeyType.UEFI
2849+ if fingerprint is None:
2850+ fingerprint = self.getUniqueUnicode('fingerprint')
2851+ if public_key is None:
2852+ public_key = self.getUniqueHexString(64)
2853+ store = IMasterStore(SigningKey)
2854+ signing_key = SigningKey(
2855+ key_type=key_type, fingerprint=fingerprint, public_key=public_key,
2856+ description=description)
2857+ store.add(signing_key)
2858+ return signing_key
2859+
2860+ def makeArchiveSigningKey(self, archive=None, distro_series=None,
2861+ signing_key=None):
2862+ if archive is None:
2863+ archive = self.makeArchive()
2864+ if signing_key is None:
2865+ signing_key = self.makeSigningKey()
2866+ return getUtility(IArchiveSigningKeySet).create(
2867+ archive, distro_series, signing_key)
2868+
2869 def makeSection(self, name=None):
2870 """Make a `Section`."""
2871 if name is None: