Skip to content

Commit 6521c5b

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 6521c5b

16 files changed

+585
-0
lines changed

src/pyinfra/operations/gpg.py

Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
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, we use a simpler approach:
121+
# Check if any of the specified keys exist in the file, and if so, remove the entire file
122+
# This is appropriate for most APT use cases where each keyring file contains keys for one repository
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(f'gpg --batch --no-default-keyring --keyring "{dest}" --list-keys 2>/dev/null | grep -qi "{clean_key}"')
132+
133+
condition = ' || '.join(key_checks)
134+
135+
# If any of the keys exist in the file, remove the entire file
136+
yield f'if [ -f "{dest}" ] && ({condition}); then rm -f "{dest}"; fi'
137+
return
138+
139+
elif dest and not keyid:
140+
# Remove entire keyring file
141+
yield from files.file._inner(
142+
path=dest,
143+
present=False,
144+
)
145+
return
146+
147+
# For installation, validate required parameters
148+
if not src and not keyserver:
149+
raise OperationError("Either `src` or `keyserver` must be provided for installation")
150+
151+
if keyserver and not keyid:
152+
raise OperationError("`keyid` must be provided with `keyserver`")
153+
154+
if keyid and not keyserver and not src:
155+
raise OperationError(
156+
"When using `keyid` for installation, either `keyserver` or `src` must be provided"
157+
)
158+
159+
# For installation (present=True), ensure destination directory exists
160+
if dest is None:
161+
raise OperationError("dest is required for installation")
162+
163+
dest_dir = str(Path(dest).parent)
164+
yield from files.directory._inner(
165+
path=dest_dir,
166+
mode="0755",
167+
present=True,
168+
)
169+
170+
# --- src branch: install a key from URL or local file ---
171+
if src:
172+
if urlparse(src).scheme in ("http", "https"):
173+
# Remote source: download first, then process
174+
temp_file = host.get_temp_filename(src)
175+
176+
yield from files.download._inner(
177+
src=src,
178+
dest=temp_file,
179+
)
180+
181+
# Install the key and clean up temp file
182+
yield from _install_key_file(temp_file, dest, dearmor, mode)
183+
184+
# Clean up temp file using pyinfra
185+
yield from files.file._inner(
186+
path=temp_file,
187+
present=False,
188+
)
189+
else:
190+
# Local file: install directly
191+
yield from _install_key_file(src, dest, dearmor, mode)
192+
193+
# --- keyserver branch: fetch keys by ID ---
194+
if keyserver:
195+
if keyid is None:
196+
raise OperationError("`keyid` must be provided with `keyserver`")
197+
198+
if isinstance(keyid, str):
199+
keyid = [keyid]
200+
201+
joined = " ".join(keyid)
202+
203+
# Create temporary GPG home directory
204+
temp_dir = f"/tmp/pyinfra-gpg-{host.get_temp_filename('')[-8:]}"
205+
206+
yield from files.directory._inner(
207+
path=temp_dir,
208+
mode="0700", # GPG directories should be more restrictive
209+
present=True,
210+
)
211+
212+
# Export GNUPGHOME and fetch keys
213+
yield f'export GNUPGHOME="{temp_dir}" && gpg --batch --keyserver "{keyserver}" --recv-keys {joined}' # noqa: E501
214+
215+
# Export keys to destination
216+
if dearmor:
217+
# For binary output (dearmored), use --export without --armor
218+
yield f'export GNUPGHOME="{temp_dir}" && gpg --batch --export {joined} > "{dest}"'
219+
else:
220+
# For ASCII output, use --export --armor
221+
yield f'export GNUPGHOME="{temp_dir}" && gpg --batch --export --armor {joined} > "{dest}"'
222+
223+
# Clean up temporary directory
224+
yield from files.directory._inner(
225+
path=temp_dir,
226+
present=False,
227+
)
228+
229+
# Set proper permissions
230+
yield from files.file._inner(
231+
path=dest,
232+
mode=mode,
233+
present=True,
234+
)
235+
236+
237+
@operation()
238+
def dearmor(src: str, dest: str, mode: str = "0644"):
239+
"""
240+
Convert ASCII armored GPG key to binary format.
241+
242+
Args:
243+
src: source ASCII armored key file
244+
dest: destination binary key file
245+
mode: file permissions for the output file
246+
247+
Example:
248+
gpg.dearmor(
249+
name="Convert key to binary",
250+
src="/tmp/key.asc",
251+
dest="/etc/apt/keyrings/key.gpg",
252+
)
253+
"""
254+
255+
# Ensure destination directory exists
256+
dest_dir = str(Path(dest).parent)
257+
yield from files.directory._inner(
258+
path=dest_dir,
259+
mode="0755",
260+
present=True,
261+
)
262+
263+
yield f'gpg --batch --dearmor -o "{dest}" "{src}"'
264+
265+
# Set proper permissions
266+
yield from files.file._inner(
267+
path=dest,
268+
mode=mode,
269+
present=True,
270+
)
271+
272+
273+
def _install_key_file(src_file: str, dest_path: str, dearmor: bool, mode: str):
274+
"""
275+
Helper function to install a GPG key file, dearmoring if necessary.
276+
"""
277+
if dearmor:
278+
# Check if it's an ASCII armored key and handle accordingly
279+
# Note: Could be enhanced using GpgKey fact
280+
yield (
281+
f'if grep -q "BEGIN PGP PUBLIC KEY BLOCK" "{src_file}"; then '
282+
f'gpg --batch --dearmor -o "{dest_path}" "{src_file}"; '
283+
f'else cp "{src_file}" "{dest_path}"; fi'
284+
)
285+
else:
286+
# Simple copy for binary keys or when dearmoring is disabled
287+
yield f'cp "{src_file}" "{dest_path}"'
288+
289+
# Set proper permissions
290+
yield from files.file._inner(
291+
path=dest_path,
292+
mode=mode,
293+
present=True,
294+
)
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+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"args": [],
3+
"kwargs": {
4+
"keyserver": "hkps://keyserver.ubuntu.com",
5+
"keyid": ["0xD88E42B4", "0x7EA0A9C3"],
6+
"dest": "/etc/apt/keyrings/vendor.gpg"
7+
},
8+
"facts": {
9+
"files.Directory": {
10+
"path=/etc/apt/keyrings": null,
11+
"path=/tmp/pyinfra-gpg-empfile_": null
12+
},
13+
"files.File": {
14+
"path=/etc/apt/keyrings/vendor.gpg": null
15+
}
16+
},
17+
"commands": [
18+
"mkdir -p /etc/apt/keyrings",
19+
"chmod 755 /etc/apt/keyrings",
20+
"mkdir -p /tmp/pyinfra-gpg-empfile_",
21+
"chmod 700 /tmp/pyinfra-gpg-empfile_",
22+
"export GNUPGHOME=\"/tmp/pyinfra-gpg-empfile_\" && gpg --batch --keyserver \"hkps://keyserver.ubuntu.com\" --recv-keys 0xD88E42B4 0x7EA0A9C3",
23+
"export GNUPGHOME=\"/tmp/pyinfra-gpg-empfile_\" && gpg --batch --export 0xD88E42B4 0x7EA0A9C3 | gpg --batch --dearmor -o \"/etc/apt/keyrings/vendor.gpg\"",
24+
"mkdir -p /etc/apt/keyrings",
25+
"touch /etc/apt/keyrings/vendor.gpg",
26+
"chmod 644 /etc/apt/keyrings/vendor.gpg"
27+
]
28+
}

0 commit comments

Comments
 (0)