Skip to content

Commit 1616478

Browse files
committed
WIP: Migrate public keys from GPG to database-backed storage
Add an alembic migration that iterates over the GPG keyring, identifies source keys, exports them from GPG and saves them into the database. TODO: * needs more hardening, if it fails then the DB seems screwed ("sources" will be missing, it gets renamed to "sources_tmp") ** would be nice if we could avoid alembic even doing the rename? * after this point should EncryptionManager throw if the public key is missing? I think so Fixes #6800.
1 parent c7b58b2 commit 1616478

3 files changed

Lines changed: 195 additions & 1 deletion

File tree

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""PGP public keys
2+
3+
Revision ID: 17c559a7a685
4+
Revises: 811334d7105f
5+
Create Date: 2023-09-21 12:33:56.550634
6+
7+
"""
8+
9+
import pretty_bad_protocol as gnupg
10+
import sqlalchemy as sa
11+
from alembic import op
12+
from encryption import EncryptionManager
13+
from sdconfig import SecureDropConfig
14+
15+
# revision identifiers, used by Alembic.
16+
revision = "17c559a7a685"
17+
down_revision = "811334d7105f"
18+
branch_labels = None
19+
depends_on = None
20+
21+
22+
def upgrade() -> None:
23+
config = SecureDropConfig.get_current()
24+
gpg = gnupg.GPG(
25+
binary="gpg2",
26+
homedir=str(config.GPG_KEY_DIR),
27+
options=["--pinentry-mode loopback", "--trust-model direct"],
28+
)
29+
# Source keys all have a secret key, so we can filter on that
30+
for keyinfo in gpg.list_keys(secret=True):
31+
if len(keyinfo["uids"]) > 1:
32+
# Source keys should only have one UID
33+
continue
34+
uid = keyinfo["uids"][0]
35+
search = EncryptionManager.SOURCE_KEY_UID_RE.search(uid)
36+
if not search:
37+
# Didn't match at all
38+
continue
39+
filesystem_id = search.group(2)
40+
# Check that it's a valid ID
41+
conn = op.get_bind()
42+
result = conn.execute(
43+
sa.text(
44+
"""
45+
SELECT pgp_public_key, pgp_fingerprint
46+
FROM sources
47+
WHERE filesystem_id=:filesystem_id
48+
"""
49+
).bindparams(filesystem_id=filesystem_id)
50+
).first()
51+
if result != (None, None):
52+
# Either not in the database or there's already some data in the DB.
53+
# In any case, skip.
54+
continue
55+
fingerprint = keyinfo["fingerprint"]
56+
public_key = gpg.export_keys(fingerprint)
57+
if not public_key.startswith("-----BEGIN PGP PUBLIC KEY BLOCK-----"):
58+
# FIXME: can we have a stronger well-formedness check here?
59+
continue
60+
# Save to database
61+
op.execute(
62+
sa.text(
63+
"""
64+
UPDATE sources
65+
SET pgp_public_key=:pgp_public_key, pgp_fingerprint=:pgp_fingerprint
66+
WHERE filesystem_id=:filesystem_id
67+
"""
68+
).bindparams(
69+
pgp_public_key=public_key,
70+
pgp_fingerprint=fingerprint,
71+
filesystem_id=filesystem_id,
72+
)
73+
)
74+
75+
76+
def downgrade() -> None:
77+
"""
78+
This is a non-destructive operation, so it's not worth implementing a
79+
migration from database storage to GPG.
80+
"""

securedrop/encryption.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ class EncryptionManager:
4040
REDIS_FINGERPRINT_HASH = "sd/crypto-util/fingerprints"
4141
REDIS_KEY_HASH = "sd/crypto-util/keys"
4242

43-
SOURCE_KEY_UID_RE = re.compile(r"(Source|Autogenerated) Key <[-A-Za-z0-9+/=_]+>")
43+
SOURCE_KEY_UID_RE = re.compile(r"(Source|Autogenerated) Key <([-A-Za-z0-9+/=_]+)>")
4444

4545
def __init__(self, gpg_key_dir: Path, journalist_pub_key: Path) -> None:
4646
self._gpg_key_dir = gpg_key_dir
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import uuid
2+
3+
import pretty_bad_protocol as gnupg
4+
from db import db
5+
from journalist_app import create_app
6+
from sqlalchemy import text
7+
8+
9+
class UpgradeTester:
10+
def __init__(self, config):
11+
"""This function MUST accept an argument named `config`.
12+
You will likely want to save a reference to the config in your
13+
class so you can access the database later.
14+
"""
15+
self.config = config
16+
self.app = create_app(self.config)
17+
self.gpg = gnupg.GPG(
18+
binary="gpg2",
19+
homedir=str(config.GPG_KEY_DIR),
20+
options=["--pinentry-mode loopback", "--trust-model direct"],
21+
)
22+
self.fingerprint = None
23+
# random, chosen by fair dice roll
24+
self.filesystem_id = (
25+
"HAR5WIY3C4K3MMIVLYXER7DMTYCL5PWZEPNOCR2AIBCVWXDZQDMDFUHEFJM"
26+
"Z3JW5D6SLED3YKCBDAKNMSIYOKWEJK3ZRJT3WEFT3S5Q="
27+
)
28+
29+
def load_data(self):
30+
"""Create a source and GPG key pair"""
31+
with self.app.app_context():
32+
source = {
33+
"uuid": str(uuid.uuid4()),
34+
"filesystem_id": self.filesystem_id,
35+
"journalist_designation": "psychic webcam",
36+
"interaction_count": 0,
37+
}
38+
sql = """\
39+
INSERT INTO sources (uuid, filesystem_id, journalist_designation,
40+
interaction_count)
41+
VALUES (:uuid, :filesystem_id, :journalist_designation,
42+
:interaction_count)"""
43+
db.engine.execute(text(sql), **source)
44+
# Generate the GPG key pair
45+
gen_key_input = self.gpg.gen_key_input(
46+
passphrase="correct horse battery staple",
47+
name_email=self.filesystem_id,
48+
key_type="RSA",
49+
key_length=4096,
50+
name_real="Source Key",
51+
creation_date="2013-05-14",
52+
expire_date="0",
53+
)
54+
key = self.gpg.gen_key(gen_key_input)
55+
self.fingerprint = str(key.fingerprint)
56+
57+
def check_upgrade(self):
58+
"""Verify PGP fields have been populated"""
59+
with self.app.app_context():
60+
query_sql = """\
61+
SELECT pgp_fingerprint, pgp_public_key, pgp_secret_key
62+
FROM sources
63+
WHERE filesystem_id = :filesystem_id"""
64+
source = db.engine.execute(
65+
text(query_sql),
66+
filesystem_id=self.filesystem_id,
67+
).fetchone()
68+
assert source[0] == self.fingerprint
69+
assert source[1].startswith("-----BEGIN PGP PUBLIC KEY BLOCK-----")
70+
assert source[2] is None
71+
72+
73+
class DowngradeTester:
74+
def __init__(self, config):
75+
self.config = config
76+
self.app = create_app(self.config)
77+
self.uuid = str(uuid.uuid4())
78+
79+
def load_data(self):
80+
"""Create a source with a PGP key pair already migrated"""
81+
with self.app.app_context():
82+
source = {
83+
"uuid": self.uuid,
84+
"filesystem_id": "1234",
85+
"journalist_designation": "mucky pine",
86+
"interaction_count": 0,
87+
"pgp_fingerprint": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
88+
"pgp_public_key": "very public",
89+
"pgp_secret_key": None,
90+
}
91+
sql = """\
92+
INSERT INTO sources (uuid, filesystem_id, journalist_designation,
93+
interaction_count, pgp_fingerprint, pgp_public_key, pgp_secret_key)
94+
VALUES (:uuid, :filesystem_id, :journalist_designation,
95+
:interaction_count, :pgp_fingerprint, :pgp_public_key, :pgp_secret_key)"""
96+
db.engine.execute(text(sql), **source)
97+
98+
def check_downgrade(self):
99+
"""Verify the downgrade does nothing, i.e. the two PGP fields are still populated"""
100+
with self.app.app_context():
101+
sql = """\
102+
SELECT pgp_fingerprint, pgp_public_key, pgp_secret_key
103+
FROM sources
104+
WHERE uuid = :uuid"""
105+
source = db.engine.execute(
106+
text(sql),
107+
uuid=self.uuid,
108+
).fetchone()
109+
print(source)
110+
assert source == (
111+
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
112+
"very public",
113+
None,
114+
)

0 commit comments

Comments
 (0)