launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #24596
[Merge] ~cjwatson/lp-signing:encrypted-private-keys into lp-signing:master
Colin Watson has proposed merging ~cjwatson/lp-signing:encrypted-private-keys into lp-signing:master.
Commit message:
Encrypt private keys at rest
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~cjwatson/lp-signing/+git/lp-signing/+merge/382413
The encrypted-container code is borrowed from Launchpad.
Changing the existing database migration is obviously a massive cheat, but as it happens there are no Key rows on staging right now and no production deployment at all yet; indeed, the charm doesn't actually run database migrations yet. We can thus get away with changing this in place and save some effort. Existing test deployments should probably run "make reset-db", noting that that will wipe any existing data.
--
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/lp-signing:encrypted-private-keys into lp-signing:master.
diff --git a/README.rst b/README.rst
index 4d990f7..7662378 100644
--- a/README.rst
+++ b/README.rst
@@ -35,6 +35,11 @@ Generate a key pair, and add the private half to `service_private_keys`
$ env/bin/lp-signing generate-key-pair
+Generate another key pair, and add the private half to `private_keys`
+(JSON-encoded) in the `[key_storage]` section of `service.conf`::
+
+ $ env/bin/lp-signing generate-key-pair
+
Start the server::
$ make run
diff --git a/charm/Makefile b/charm/Makefile
index a9218f2..28de120 100644
--- a/charm/Makefile
+++ b/charm/Makefile
@@ -63,11 +63,18 @@ tmp/service-private-key: | $(TMPDIR)
--private-key-path tmp/service-private-key \
--public-key-path tmp/service-public-key
-bundle.yaml: bundle.yaml.in tmp/signing.launchpad.test.crt tmp/service-private-key
+tmp/key-storage-private-key: | $(TMPDIR)
+ @$(MAKE) -C ..
+ ../env/bin/lp-signing generate-key-pair \
+ --private-key-path tmp/key-storage-private-key \
+ --public-key-path tmp/key-storage-public-key
+
+bundle.yaml: bundle.yaml.in tmp/signing.launchpad.test.crt tmp/service-private-key tmp/key-storage-private-key
sed -e 's/%BUILD_LABEL%/$(BUILD_LABEL)/g' \
-e "s/%SSL_KEY%/$$(base64 -w 0 <tmp/signing.launchpad.test.key)/g" \
-e "s/%SSL_CERT%/$$(base64 -w 0 <tmp/signing.launchpad.test.crt)/g" \
-e "s/%SERVICE_PRIVATE_KEY%/$$(cat tmp/service-private-key)/g" \
+ -e "s/%KEY_STORAGE_PRIVATE_KEY%/$$(cat tmp/key-storage-private-key)/g" \
bundle.yaml.in >bundle.yaml
deploy: build payload bundle.yaml
diff --git a/charm/bundle.yaml.in b/charm/bundle.yaml.in
index b99a248..cf34c11 100644
--- a/charm/bundle.yaml.in
+++ b/charm/bundle.yaml.in
@@ -16,6 +16,7 @@ applications:
num_units: 1
options:
build_label: "%BUILD_LABEL%"
+ key_storage_private_keys: '["%KEY_STORAGE_PRIVATE_KEY%"]'
service_private_keys: '["%SERVICE_PRIVATE_KEY%"]'
resources:
lp-signing: "../build/%BUILD_LABEL%/lp-signing.tar.gz"
diff --git a/charm/lp-signing/config.yaml b/charm/lp-signing/config.yaml
index 384473d..bbe6b25 100644
--- a/charm/lp-signing/config.yaml
+++ b/charm/lp-signing/config.yaml
@@ -6,6 +6,13 @@ options:
A JSON-encoded list of base64-encoded private service keys. The first
key in the list is the preferred one; older keys may follow,
permitting rollover.
+ key_storage_private_keys:
+ type: string
+ default: "[]"
+ description: >
+ A JSON-encoded list of base64-encoded NaCl private keys for decrypting
+ private keys held in the database. Currently only the first one is
+ used, but this may change in future to permit rollover.
log_hosts_allow:
type: string
default: ""
diff --git a/charm/lp-signing/templates/service.conf.j2 b/charm/lp-signing/templates/service.conf.j2
index 044fbca..82d8b6b 100644
--- a/charm/lp-signing/templates/service.conf.j2
+++ b/charm/lp-signing/templates/service.conf.j2
@@ -7,3 +7,9 @@ standby_urls: [{% if standby_urls %}"{{ standby_urls|join('", "') }}"{% endif %}
# follow, permitting rollover.
service_private_keys: {{ service_private_keys }}
+[key_storage]
+# Base64-encoded NaCl private keys for decrypting private keys held in the
+# database. Currently only the first one is used, but this may change in
+# future to permit rollover.
+private_keys: {{ key_storage_private_keys }}
+
diff --git a/lp_signing/config.py b/lp_signing/config.py
index e6d1706..2c4036b 100644
--- a/lp_signing/config.py
+++ b/lp_signing/config.py
@@ -30,6 +30,12 @@ DEFAULT_TEST_CONFIG = {
"UTF-8"),
]),
},
+ "key_storage": {
+ "private_keys": json.dumps([
+ PrivateKey.generate().encode(encoder=Base64Encoder).decode(
+ "UTF-8"),
+ ]),
+ },
}
diff --git a/lp_signing/crypto.py b/lp_signing/crypto.py
new file mode 100644
index 0000000..671338c
--- /dev/null
+++ b/lp_signing/crypto.py
@@ -0,0 +1,130 @@
+# Copyright 2019-2020 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""A container for data encrypted at rest using configured keys."""
+
+__all__ = [
+ 'NaClEncryptedContainerBase',
+ ]
+
+import base64
+
+from nacl.exceptions import CryptoError as NaClCryptoError
+from nacl.public import (
+ PrivateKey,
+ PublicKey,
+ SealedBox,
+ )
+
+
+class CryptoError(Exception):
+ pass
+
+
+class NaClEncryptedContainerBase:
+ """A container that can encrypt and decrypt data using NaCl.
+
+ See `IEncryptedContainer`.
+ """
+
+ @property
+ def public_key_bytes(self):
+ """The serialised public key as a byte string.
+
+ Concrete implementations must provide this.
+ """
+ raise NotImplementedError
+
+ @property
+ def public_key(self):
+ """The public key as a L{nacl.public.PublicKey}."""
+ if self.public_key_bytes is not None:
+ try:
+ return PublicKey(self.public_key_bytes)
+ except NaClCryptoError as e:
+ raise CryptoError(str(e)) from e
+ else:
+ return None
+
+ @property
+ def can_encrypt(self):
+ """Is this container configured to encrypt data?"""
+ try:
+ return self.public_key is not None
+ except CryptoError:
+ return False
+
+ def encrypt(self, data):
+ """Encrypt a blob of data to a JSON-serialisable form.
+
+ This includes the public key to ease future key rotation.
+
+ :param data: An unencrypted byte string to encrypt.
+ :return: A tuple of (base64-encoded public key, base64-encoded
+ encrypted text string).
+ :raises RuntimeError: if no public key is configured for this
+ container.
+ """
+ if self.public_key is None:
+ raise RuntimeError("No public key configured")
+ try:
+ data_encrypted = SealedBox(self.public_key).encrypt(data)
+ except NaClCryptoError as e:
+ raise CryptoError(str(e)) from e
+ return (
+ base64.b64encode(self.public_key_bytes).decode("UTF-8"),
+ base64.b64encode(data_encrypted).decode("UTF-8"))
+
+ @property
+ def private_key_bytes(self):
+ """The serialised private key as a byte string.
+
+ Concrete implementations must provide this.
+ """
+ raise NotImplementedError
+
+ @property
+ def private_key(self):
+ """The private key as a L{nacl.public.PrivateKey}."""
+ if self.private_key_bytes is not None:
+ try:
+ return PrivateKey(self.private_key_bytes)
+ except NaClCryptoError as e:
+ raise CryptoError(str(e)) from e
+ else:
+ return None
+
+ @property
+ def can_decrypt(self):
+ """Is this container configured to decrypt data?"""
+ try:
+ return self.private_key is not None
+ except CryptoError:
+ return False
+
+ def decrypt(self, data):
+ """Decrypt data that was encrypted by L{encrypt}.
+
+ :param data: A tuple of (base64-encoded public key, base64-encoded
+ encrypted text string) to decrypt.
+ :return: An unencrypted byte string.
+ :raises ValueError: if no private key is configured for this container
+ that corresponds to the requested public key.
+ :raises CryptoError: if decryption failed.
+ """
+ public_key, encrypted = data
+ try:
+ public_key_bytes = base64.b64decode(public_key.encode("UTF-8"))
+ encrypted_bytes = base64.b64decode(encrypted.encode("UTF-8"))
+ except TypeError as e:
+ raise CryptoError(str(e)) from e
+ if public_key_bytes != self.public_key_bytes:
+ raise ValueError(
+ "Public key %r does not match configured public key %r" %
+ (public_key_bytes, self.public_key_bytes))
+ if self.private_key is None:
+ raise ValueError("No private key configured")
+ try:
+ return SealedBox(self.private_key).decrypt(encrypted_bytes)
+ except NaClCryptoError as e:
+ raise CryptoError(str(e)) from e
diff --git a/lp_signing/model/key.py b/lp_signing/model/key.py
index 22f02bd..84ddc46 100644
--- a/lp_signing/model/key.py
+++ b/lp_signing/model/key.py
@@ -7,7 +7,9 @@ __all__ = [
"Key",
]
+import base64
from contextlib import contextmanager
+import json
import logging
from pathlib import Path
import shutil
@@ -17,6 +19,7 @@ from tempfile import TemporaryDirectory
from textwrap import dedent
from flask_storm import store
+from nacl.public import PrivateKey
import pytz
from storm.locals import (
DateTime,
@@ -26,7 +29,10 @@ from storm.locals import (
Storm,
Unicode,
)
+from storm.databases.postgres import JSON
+from lp_signing.config import read_config
+from lp_signing.crypto import NaClEncryptedContainerBase
from lp_signing.database.constants import DEFAULT
from lp_signing.database.enumcol import DBEnum
from lp_signing.enums import (
@@ -104,6 +110,36 @@ def _log_subprocess_run(args, **kwargs):
return process
+class KeyStorageEncryptedContainer(NaClEncryptedContainerBase):
+
+ @property
+ def public_key_bytes(self):
+ private_key_bytes = self.private_key_bytes
+ if private_key_bytes:
+ return bytes(PrivateKey(private_key_bytes).public_key)
+ else:
+ return None
+
+ @property
+ def private_key_bytes(self):
+ config = read_config()
+ try:
+ private_keys = [
+ base64.b64decode(key.encode("UTF-8"))
+ for key in json.loads(config["key_storage"]["private_keys"])
+ ]
+ except Exception: # pragma: no cover
+ # Yes, this is uninformative, but we don't want to accidentally
+ # dump private keys in exceptions.
+ raise ValueError("Failed to load key_storage.private_keys")
+ if private_keys:
+ # XXX cjwatson 2020-04-16: Work out some scheme to permit
+ # rollover.
+ return private_keys[0]
+ else:
+ return None
+
+
class Key(Storm):
"""A signing key."""
@@ -114,7 +150,9 @@ class Key(Storm):
key_type = DBEnum(enum=KeyType, allow_none=False)
fingerprint = Unicode(name="fingerprint", allow_none=False)
- private_key = RawStr(name="private_key", allow_none=False)
+ # The actual private key material is encrypted at rest.
+ _private_key = JSON(name="private_key", allow_none=False)
+
public_key = RawStr(name="public_key", allow_none=False)
created_at = DateTime(name="created_at", tzinfo=pytz.UTC, allow_none=False)
@@ -124,7 +162,7 @@ class Key(Storm):
created_at=DEFAULT, updated_at=DEFAULT):
self.key_type = key_type
self.fingerprint = fingerprint
- self.private_key = private_key
+ self.setPrivateKey(private_key)
self.public_key = public_key
self.created_at = created_at
self.updated_at = updated_at
@@ -156,6 +194,22 @@ class Key(Storm):
__repr__ = __str__
+ def getPrivateKey(self):
+ """Get the private key.
+
+ :return: The bytes of the private key.
+ """
+ container = KeyStorageEncryptedContainer()
+ return container.decrypt(self._private_key)
+
+ def setPrivateKey(self, private_key):
+ """Set the private key.
+
+ :param private_key: The bytes of the private key.
+ """
+ container = KeyStorageEncryptedContainer()
+ self._private_key = container.encrypt(private_key)
+
@classmethod
def getByTypeAndFingerprint(cls, key_type, fingerprint):
"""Get a key by key type and fingerprint.
@@ -300,7 +354,7 @@ class Key(Storm):
_log.info("Injected new key with fingerprint %s", fingerprint)
try:
key = Key.getByTypeAndFingerprint(key_type, fingerprint)
- if (key.private_key == private_key) and (
+ if (key.getPrivateKey() == private_key) and (
key.public_key == public_key):
if key.created_at > created_at:
key.created_at = created_at
@@ -357,7 +411,7 @@ class Key(Storm):
"""
with _temporary_path() as tmp:
key = tmp / f"{self.key_type.name.lower()}.key"
- key.write_bytes(self.private_key)
+ key.write_bytes(self.getPrivateKey())
cert = tmp / f"{self.key_type.name.lower()}.crt"
cert.write_bytes(self.public_key)
message_path = tmp / message_name
diff --git a/lp_signing/model/tests/test_key.py b/lp_signing/model/tests/test_key.py
index f6a4e65..48bc737 100644
--- a/lp_signing/model/tests/test_key.py
+++ b/lp_signing/model/tests/test_key.py
@@ -32,6 +32,7 @@ from lp_signing.model.key import Key
from lp_signing.tests import factory
from lp_signing.tests.matchers import RanCommand
from lp_signing.tests.testfixtures import (
+ ConfigOverrideFixture,
DatabaseFixture,
FakeKmodSign,
FakeMkimage,
@@ -45,6 +46,7 @@ class TestKey(TestCase):
def setUp(self):
super().setUp()
+ self.useFixture(ConfigOverrideFixture({}))
self.useFixture(DatabaseFixture())
self.processes_fixture = self.useFixture(FakeProcesses())
@@ -107,10 +109,10 @@ class TestKey(TestCase):
self.assertThat(key, MatchesStructure.byEquality(
key_type=KeyType.UEFI,
fingerprint=fingerprint,
- private_key=private_key,
public_key=public_key,
created_at=now,
updated_at=now))
+ self.assertEqual(private_key, key.getPrivateKey())
self.assertEqual(
key, Key.getByTypeAndFingerprint(KeyType.UEFI, fingerprint))
req_args = [
@@ -144,10 +146,10 @@ class TestKey(TestCase):
self.assertThat(key, MatchesStructure.byEquality(
key_type=KeyType.KMOD,
fingerprint=fingerprint,
- private_key=private_key,
public_key=public_key,
created_at=now,
updated_at=now))
+ self.assertEqual(private_key, key.getPrivateKey())
self.assertEqual(
key, Key.getByTypeAndFingerprint(KeyType.KMOD, fingerprint))
self.assertIn("[ req ]", fake_openssl.keygen_text)
@@ -193,10 +195,10 @@ class TestKey(TestCase):
self.assertThat(key, MatchesStructure.byEquality(
key_type=KeyType.OPAL,
fingerprint=fingerprint,
- private_key=private_key,
public_key=public_key,
created_at=now,
updated_at=now))
+ self.assertEqual(private_key, key.getPrivateKey())
self.assertEqual(
key, Key.getByTypeAndFingerprint(KeyType.OPAL, fingerprint))
self.assertIn("[ req ]", fake_openssl.keygen_text)
@@ -240,10 +242,10 @@ class TestKey(TestCase):
self.assertThat(key, MatchesStructure.byEquality(
key_type=KeyType.SIPL,
fingerprint=fingerprint,
- private_key=private_key,
public_key=public_key,
created_at=now,
updated_at=now))
+ self.assertEqual(private_key, key.getPrivateKey())
self.assertEqual(
key, Key.getByTypeAndFingerprint(KeyType.SIPL, fingerprint))
self.assertIn("[ req ]", fake_openssl.keygen_text)
@@ -287,10 +289,10 @@ class TestKey(TestCase):
self.assertThat(key, MatchesStructure.byEquality(
key_type=KeyType.FIT,
fingerprint=fingerprint,
- private_key=private_key,
public_key=public_key,
created_at=now,
updated_at=now))
+ self.assertEqual(private_key, key.getPrivateKey())
self.assertEqual(
key, Key.getByTypeAndFingerprint(KeyType.FIT, fingerprint))
req_args = [
@@ -465,10 +467,10 @@ class TestKey(TestCase):
self.assertThat(key, MatchesStructure.byEquality(
key_type=KeyType.UEFI,
fingerprint=fingerprint,
- private_key=private_key,
public_key=public_key,
created_at=created_at,
updated_at=now))
+ self.assertEqual(private_key, key.getPrivateKey())
self.assertEqual(
key, Key.getByTypeAndFingerprint(KeyType.UEFI, fingerprint))
fingerprint_args = [
@@ -496,10 +498,10 @@ class TestKey(TestCase):
self.assertThat(key, MatchesStructure.byEquality(
key_type=KeyType.KMOD,
fingerprint=fingerprint,
- private_key=private_key,
public_key=public_key,
created_at=created_at,
updated_at=now))
+ self.assertEqual(private_key, key.getPrivateKey())
self.assertEqual(
key, Key.getByTypeAndFingerprint(KeyType.KMOD, fingerprint))
fingerprint_args = [
@@ -527,10 +529,10 @@ class TestKey(TestCase):
self.assertThat(key, MatchesStructure.byEquality(
key_type=KeyType.OPAL,
fingerprint=fingerprint,
- private_key=private_key,
public_key=public_key,
created_at=created_at,
updated_at=now))
+ self.assertEqual(private_key, key.getPrivateKey())
self.assertEqual(
key, Key.getByTypeAndFingerprint(KeyType.OPAL, fingerprint))
fingerprint_args = [
@@ -558,10 +560,10 @@ class TestKey(TestCase):
self.assertThat(key, MatchesStructure.byEquality(
key_type=KeyType.SIPL,
fingerprint=fingerprint,
- private_key=private_key,
public_key=public_key,
created_at=created_at,
updated_at=now))
+ self.assertEqual(private_key, key.getPrivateKey())
self.assertEqual(
key, Key.getByTypeAndFingerprint(KeyType.SIPL, fingerprint))
fingerprint_args = [
@@ -589,10 +591,10 @@ class TestKey(TestCase):
self.assertThat(key, MatchesStructure.byEquality(
key_type=KeyType.FIT,
fingerprint=fingerprint,
- private_key=private_key,
public_key=public_key,
created_at=created_at,
updated_at=now))
+ self.assertEqual(private_key, key.getPrivateKey())
self.assertEqual(
key, Key.getByTypeAndFingerprint(KeyType.FIT, fingerprint))
fingerprint_args = [
diff --git a/lp_signing/tests/test_crypto.py b/lp_signing/tests/test_crypto.py
new file mode 100644
index 0000000..4c1987e
--- /dev/null
+++ b/lp_signing/tests/test_crypto.py
@@ -0,0 +1,78 @@
+# Copyright 2019-2020 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for encrypted data containers."""
+
+from nacl.public import PrivateKey
+from testtools import TestCase
+
+from lp_signing.crypto import (
+ CryptoError,
+ NaClEncryptedContainerBase,
+ )
+
+
+class FakeEncryptedContainer(NaClEncryptedContainerBase):
+
+ def __init__(self, public_key_bytes, private_key_bytes=None):
+ self._public_key_bytes = public_key_bytes
+ self._private_key_bytes = private_key_bytes
+
+ @property
+ def public_key_bytes(self):
+ return self._public_key_bytes
+
+ @property
+ def private_key_bytes(self):
+ return self._private_key_bytes
+
+
+class TestNaClEncryptedContainerBase(TestCase):
+
+ def test_public_key_valid(self):
+ public_key = PrivateKey.generate().public_key
+ container = FakeEncryptedContainer(bytes(public_key))
+ self.assertEqual(public_key, container.public_key)
+ self.assertTrue(container.can_encrypt)
+
+ def test_public_key_invalid(self):
+ container = FakeEncryptedContainer(b"nonsense")
+ self.assertRaises(CryptoError, getattr, container, "public_key")
+ self.assertFalse(container.can_encrypt)
+
+ def test_public_key_unset(self):
+ container = FakeEncryptedContainer(None)
+ self.assertIsNone(container.public_key)
+ self.assertFalse(container.can_encrypt)
+
+ def test_encrypt_without_private_key(self):
+ # Encryption only requires the public key, not the private key.
+ public_key = PrivateKey.generate().public_key
+ container = FakeEncryptedContainer(bytes(public_key))
+ self.assertIsNotNone(container.encrypt(b"plaintext"))
+
+ def test_private_key_valid(self):
+ private_key = PrivateKey.generate()
+ container = FakeEncryptedContainer(
+ bytes(private_key.public_key), bytes(private_key))
+ self.assertEqual(private_key, container.private_key)
+ self.assertTrue(container.can_decrypt)
+
+ def test_private_key_invalid(self):
+ public_key = PrivateKey.generate().public_key
+ container = FakeEncryptedContainer(bytes(public_key), b"nonsense")
+ self.assertRaises(CryptoError, getattr, container, "private_key")
+ self.assertFalse(container.can_decrypt)
+
+ def test_private_key_unset(self):
+ public_key = PrivateKey.generate().public_key
+ container = FakeEncryptedContainer(bytes(public_key), None)
+ self.assertIsNone(container.private_key)
+ self.assertFalse(container.can_decrypt)
+
+ def test_encrypt_decrypt(self):
+ private_key = PrivateKey.generate()
+ container = FakeEncryptedContainer(
+ bytes(private_key.public_key), bytes(private_key))
+ self.assertEqual(
+ b"plaintext", container.decrypt(container.encrypt(b"plaintext")))
diff --git a/migrations/patch-0-1-0-std.sql b/migrations/patch-0-1-0-std.sql
index e77bfa2..b81fea8 100644
--- a/migrations/patch-0-1-0-std.sql
+++ b/migrations/patch-0-1-0-std.sql
@@ -38,7 +38,7 @@ CREATE TABLE key (
id serial PRIMARY KEY,
key_type integer NOT NULL,
fingerprint text NOT NULL,
- private_key bytea NOT NULL,
+ private_key jsonb NOT NULL,
public_key bytea NOT NULL,
created_at timestamp without time zone DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') NOT NULL,
updated_at timestamp without time zone DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') NOT NULL
diff --git a/service.conf b/service.conf
index 727cf41..ea0de24 100644
--- a/service.conf
+++ b/service.conf
@@ -6,3 +6,9 @@ standby_urls: []
# The first key in this sequence is the preferred one. Older keys may
# follow, permitting rollover.
service_private_keys: []
+
+[key_storage]
+# Base64-encoded NaCl private keys for decrypting private keys held in the
+# database. Currently only the first one is used, but this may change in
+# future to permit rollover.
+private_keys: []