Skip to content

Commit 5585d21

Browse files
committed
operations: add GPG key management operations
Add new gpg.key and gpg.dearmor operations to manage GPG keys and keyrings. These operations provide a modern alternative to apt-key for managing APT repository keys. Features: - Install keys from URLs, local files, or keyservers - Remove keys by ID or entire keyring files - Convert ASCII armored keys to binary format - Manage keys in specific keyrings or across all APT keyrings This is part 1/3 of modernizing APT key management.
1 parent 4c1c782 commit 5585d21

16 files changed

+590
-0
lines changed

src/pyinfra/operations/gpg.py

Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
"""
2+
Manage GPG keys and keyrings.
3+
"""
4+
5+
from pathlib import Path
6+
from urllib.parse import urlparse
7+
8+
from pyinfra import host
9+
from pyinfra.api import OperationError, operation
10+
11+
from . import files
12+
13+
14+
@operation()
15+
def key(
16+
src: str | None = None,
17+
dest: str | None = None,
18+
keyserver: str | None = None,
19+
keyid: str | list[str] | None = None,
20+
dearmor: bool = True,
21+
mode: str = "0644",
22+
present: bool = True,
23+
):
24+
"""
25+
Install or remove GPG keys from various sources.
26+
27+
Args:
28+
src: filename or URL to a key (ASCII .asc or binary .gpg)
29+
dest: destination path for the key file (required for installation, optional for removal)
30+
keyserver: keyserver URL for fetching keys by ID
31+
keyid: key ID or list of key IDs (required with keyserver, optional for removal)
32+
dearmor: whether to convert ASCII armored keys to binary format
33+
mode: file permissions for the installed key
34+
present: whether the key should be present (True) or absent (False)
35+
When False: if dest is provided, removes from specific keyring;
36+
if dest is None, removes from all APT keyrings;
37+
if keyid is provided, removes specific key(s);
38+
if keyid is None, removes entire keyring file(s)
39+
40+
Examples:
41+
gpg.key(
42+
name="Install Docker GPG key",
43+
src="https://download.docker.com/linux/debian/gpg",
44+
dest="/etc/apt/keyrings/docker.gpg",
45+
)
46+
47+
gpg.key(
48+
name="Remove old GPG key file",
49+
dest="/etc/apt/keyrings/old-key.gpg",
50+
present=False,
51+
)
52+
53+
gpg.key(
54+
name="Remove specific key by ID",
55+
dest="/etc/apt/keyrings/vendor.gpg",
56+
keyid="0xABCDEF12",
57+
present=False,
58+
)
59+
60+
gpg.key(
61+
name="Remove key from all APT keyrings",
62+
keyid="0xCOMPROMISED123",
63+
present=False,
64+
# dest=None means search in all keyrings
65+
)
66+
67+
gpg.key(
68+
name="Fetch keys from keyserver",
69+
keyserver="hkps://keyserver.ubuntu.com",
70+
keyid=["0xD88E42B4", "0x7EA0A9C3"],
71+
dest="/etc/apt/keyrings/vendor.gpg",
72+
)
73+
"""
74+
75+
# Validate parameters based on operation type
76+
if present is True:
77+
# For installation, dest is required
78+
if not dest:
79+
raise OperationError("`dest` must be provided for installation")
80+
elif present is False:
81+
# For removal, either dest or keyid must be provided
82+
if not dest and not keyid:
83+
raise OperationError("For removal, either `dest` or `keyid` must be provided")
84+
85+
# For removal, handle different scenarios
86+
if present is False:
87+
if not dest and keyid:
88+
# Remove key(s) from all APT keyrings
89+
if isinstance(keyid, str):
90+
keyid = [keyid]
91+
92+
# Define all APT keyring locations
93+
# Not sure this is the best way to do this
94+
# Cannot find a more generic way to get gpg keyring locations
95+
keyring_patterns = [
96+
"/etc/apt/trusted.gpg.d/*.gpg",
97+
"/etc/apt/keyrings/*.gpg",
98+
"/usr/share/keyrings/*.gpg",
99+
]
100+
101+
for pattern in keyring_patterns:
102+
for kid in keyid:
103+
# Remove key from all matching keyrings
104+
yield (
105+
f'for keyring in {pattern}; do [ -e "$keyring" ] && '
106+
f'gpg --batch --no-default-keyring --keyring "$keyring" '
107+
f"--delete-keys {kid} 2>/dev/null || true; done"
108+
)
109+
110+
# Clean up empty keyrings
111+
yield (
112+
f'for keyring in {pattern}; do [ -e "$keyring" ] && '
113+
f'! gpg --batch --no-default-keyring --keyring "$keyring" '
114+
f'--list-keys 2>/dev/null | grep -q "pub" && rm -f "$keyring" || true; done'
115+
)
116+
117+
return
118+
119+
elif dest and keyid:
120+
# For APT keyring files, use a simpler approach:
121+
# Check if keys exist in file, and if so, remove the entire file
122+
# This is appropriate for most APT use cases
123+
if isinstance(keyid, str):
124+
keyid = [keyid]
125+
126+
# Build a condition to check if any of the keys exist in the file
127+
key_checks = []
128+
for kid in keyid:
129+
# Strip 0x prefix if present and handle both short and long key formats
130+
clean_key = kid.replace("0x", "").replace("0X", "")
131+
key_checks.append(
132+
f'gpg --batch --no-default-keyring --keyring "{dest}" '
133+
f'--list-keys 2>/dev/null | grep -qi "{clean_key}"'
134+
)
135+
136+
condition = " || ".join(key_checks)
137+
138+
# If any of the keys exist in the file, remove the entire file
139+
yield (f'if [ -f "{dest}" ] && ({condition}); then ' f'rm -f "{dest}"; fi')
140+
return
141+
142+
elif dest and not keyid:
143+
# Remove entire keyring file
144+
yield from files.file._inner(
145+
path=dest,
146+
present=False,
147+
)
148+
return
149+
150+
# For installation, validate required parameters
151+
if not src and not keyserver:
152+
raise OperationError("Either `src` or `keyserver` must be provided for installation")
153+
154+
if keyserver and not keyid:
155+
raise OperationError("`keyid` must be provided with `keyserver`")
156+
157+
if keyid and not keyserver and not src:
158+
raise OperationError(
159+
"When using `keyid` for installation, either `keyserver` or `src` must be provided"
160+
)
161+
162+
# For installation (present=True), ensure destination directory exists
163+
if dest is None:
164+
raise OperationError("dest is required for installation")
165+
166+
dest_dir = str(Path(dest).parent)
167+
yield from files.directory._inner(
168+
path=dest_dir,
169+
mode="0755",
170+
present=True,
171+
)
172+
173+
# --- src branch: install a key from URL or local file ---
174+
if src:
175+
if urlparse(src).scheme in ("http", "https"):
176+
# Remote source: download first, then process
177+
temp_file = host.get_temp_filename(src)
178+
179+
yield from files.download._inner(
180+
src=src,
181+
dest=temp_file,
182+
)
183+
184+
# Install the key and clean up temp file
185+
yield from _install_key_file(temp_file, dest, dearmor, mode)
186+
187+
# Clean up temp file using pyinfra
188+
yield from files.file._inner(
189+
path=temp_file,
190+
present=False,
191+
)
192+
else:
193+
# Local file: install directly
194+
yield from _install_key_file(src, dest, dearmor, mode)
195+
196+
# --- keyserver branch: fetch keys by ID ---
197+
if keyserver:
198+
if keyid is None:
199+
raise OperationError("`keyid` must be provided with `keyserver`")
200+
201+
if isinstance(keyid, str):
202+
keyid = [keyid]
203+
204+
joined = " ".join(keyid)
205+
206+
# Create temporary GPG home directory
207+
temp_dir = f"/tmp/pyinfra-gpg-{host.get_temp_filename('')[-8:]}"
208+
209+
yield from files.directory._inner(
210+
path=temp_dir,
211+
mode="0700", # GPG directories should be more restrictive
212+
present=True,
213+
)
214+
215+
# Export GNUPGHOME and fetch keys
216+
yield f'export GNUPGHOME="{temp_dir}" && gpg --batch --keyserver "{keyserver}" --recv-keys {joined}' # noqa: E501
217+
218+
# Export keys to destination
219+
if dearmor:
220+
# For binary output (dearmored), use --export without --armor
221+
yield (f'export GNUPGHOME="{temp_dir}" && ' f'gpg --batch --export {joined} > "{dest}"')
222+
else:
223+
# For ASCII output, use --export --armor
224+
yield (
225+
f'export GNUPGHOME="{temp_dir}" && '
226+
f'gpg --batch --export --armor {joined} > "{dest}"'
227+
)
228+
229+
# Clean up temporary directory
230+
yield from files.directory._inner(
231+
path=temp_dir,
232+
present=False,
233+
)
234+
235+
# Set proper permissions
236+
yield from files.file._inner(
237+
path=dest,
238+
mode=mode,
239+
present=True,
240+
)
241+
242+
243+
@operation()
244+
def dearmor(src: str, dest: str, mode: str = "0644"):
245+
"""
246+
Convert ASCII armored GPG key to binary format.
247+
248+
Args:
249+
src: source ASCII armored key file
250+
dest: destination binary key file
251+
mode: file permissions for the output file
252+
253+
Example:
254+
gpg.dearmor(
255+
name="Convert key to binary",
256+
src="/tmp/key.asc",
257+
dest="/etc/apt/keyrings/key.gpg",
258+
)
259+
"""
260+
261+
# Ensure destination directory exists
262+
dest_dir = str(Path(dest).parent)
263+
yield from files.directory._inner(
264+
path=dest_dir,
265+
mode="0755",
266+
present=True,
267+
)
268+
269+
yield f'gpg --batch --dearmor -o "{dest}" "{src}"'
270+
271+
# Set proper permissions
272+
yield from files.file._inner(
273+
path=dest,
274+
mode=mode,
275+
present=True,
276+
)
277+
278+
279+
def _install_key_file(src_file: str, dest_path: str, dearmor: bool, mode: str):
280+
"""
281+
Helper function to install a GPG key file, dearmoring if necessary.
282+
"""
283+
if dearmor:
284+
# Check if it's an ASCII armored key and handle accordingly
285+
# Note: Could be enhanced using GpgKey fact
286+
yield (
287+
f'if grep -q "BEGIN PGP PUBLIC KEY BLOCK" "{src_file}"; then '
288+
f'gpg --batch --dearmor -o "{dest_path}" "{src_file}"; '
289+
f'else cp "{src_file}" "{dest_path}"; fi'
290+
)
291+
else:
292+
# Simple copy for binary keys or when dearmoring is disabled
293+
yield f'cp "{src_file}" "{dest_path}"'
294+
295+
# Set proper permissions
296+
yield from files.file._inner(
297+
path=dest_path,
298+
mode=mode,
299+
present=True,
300+
)
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"args": [],
3+
"kwargs": {
4+
"src": "/tmp/test.asc",
5+
"dest": "/tmp/test.gpg"
6+
},
7+
"facts": {
8+
"files.Directory": {
9+
"path=/tmp": {
10+
"mode": 755
11+
}
12+
},
13+
"files.File": {
14+
"path=/tmp/test.gpg": null
15+
}
16+
},
17+
"commands": [
18+
"gpg --batch --dearmor -o \"/tmp/test.gpg\" \"/tmp/test.asc\"",
19+
"touch /tmp/test.gpg",
20+
"chmod 644 /tmp/test.gpg"
21+
]
22+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"args": [],
3+
"kwargs": {
4+
"src": "/tmp/key.asc",
5+
"dest": "/etc/apt/keyrings/key.gpg",
6+
"mode": "0600"
7+
},
8+
"facts": {
9+
"files.Directory": {
10+
"path=/etc/apt/keyrings": null
11+
},
12+
"files.File": {
13+
"path=/etc/apt/keyrings/key.gpg": null
14+
}
15+
},
16+
"commands": [
17+
"mkdir -p /etc/apt/keyrings",
18+
"chmod 755 /etc/apt/keyrings",
19+
"gpg --batch --dearmor -o \"/etc/apt/keyrings/key.gpg\" \"/tmp/key.asc\"",
20+
"mkdir -p /etc/apt/keyrings",
21+
"touch /etc/apt/keyrings/key.gpg",
22+
"chmod 600 /etc/apt/keyrings/key.gpg"
23+
]
24+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"args": [],
3+
"kwargs": {
4+
"src": "/tmp/local-key.asc",
5+
"dest": "/etc/apt/keyrings/test.gpg",
6+
"mode": "0600"
7+
},
8+
"facts": {
9+
"files.Directory": {
10+
"path=/etc/apt/keyrings": null
11+
},
12+
"files.File": {
13+
"path=/etc/apt/keyrings/test.gpg": null
14+
}
15+
},
16+
"commands": [
17+
"mkdir -p /etc/apt/keyrings",
18+
"chmod 755 /etc/apt/keyrings",
19+
"if grep -q \"BEGIN PGP PUBLIC KEY BLOCK\" \"/tmp/local-key.asc\"; then gpg --batch --dearmor -o \"/etc/apt/keyrings/test.gpg\" \"/tmp/local-key.asc\"; else cp \"/tmp/local-key.asc\" \"/etc/apt/keyrings/test.gpg\"; fi",
20+
"mkdir -p /etc/apt/keyrings",
21+
"touch /etc/apt/keyrings/test.gpg",
22+
"chmod 600 /etc/apt/keyrings/test.gpg"
23+
]
24+
}

0 commit comments

Comments
 (0)