keyringctl: Implement export of ownertrust/ revoker status

keyringctl:
Add `temp_join_keys()` to generically join PGP packets in a directory
below a temporary directory.
Add `get_all_and_revoked_certs()` to retrieve a tuple containing a list
of all public key fingerprints and a list of all self-revoked public key
fingerprints in a list of paths.
Add `export_ownertrust()` to export a list of fingerprints of
non-revoked public keys to a file that can be imported using `gpg
--import-ownertrust`.
Add `export_revoked()` to export the fingerprints of all self-revoked
public keys and the fingerprints of public keys that have been revoked
by third party signing keys (the latter is still fairly naive).
Change `export_keyring()` to make use of `temp_join_keys()` for
preparing main signing keys and general keys for the export to file. Add
integration for exporting ownertrust and revoker status (using
`export_ownertrust()` and `export_revoked()`, respectively).
Change `__main__` by extending the export_parser by a `-m`/ `--main`
argument to provide one or multiple files or directories, that serve as
the signing authority for key material located below `-s`/ `--source`.
Add a `-p`/ `--pacman-integration` to provide the means to export
ownertrust and revoker status on demand.
This commit is contained in:
David Runge 2021-10-10 18:28:30 +02:00 committed by Levente Polyak
parent 3c31230eb2
commit 5320f2491e
No known key found for this signature in database
GPG Key ID: FC1B547C8D8172C8

View File

@ -33,6 +33,7 @@ from typing import Dict
from typing import List from typing import List
from typing import Optional from typing import Optional
from typing import Iterable from typing import Iterable
from typing import Tuple
from contextlib import contextmanager from contextlib import contextmanager
@ -626,30 +627,21 @@ def keyring_import(working_dir: Path, source: Path, target_dir: Optional[Path] =
pass pass
def export_keyring(working_dir: Path, sources: List[Path], output: Path, force: bool) -> None: def temp_join_keys(sources: List[Path], temp_dir: Path, force: bool) -> List[Path]:
"""Export all provided PGP packet files to a single output file """Temporarily join the key material of a given set of keys in a temporary location and return their paths
If sources contains directories, any .asc files below them are considered.
Parameters Parameters
---------- ----------
working_dir: Path
A directory to use for temporary files
sources: List[Path] sources: List[Path]
A list of directories or files from which to read PGP packet information A list of paths below which PGP packets are found
output: Path temp_dir: Path
An output file that all PGP packet data is written to The temporary directory below which to join PGP keys
force: bool force: bool
Whether to force the execution of packet_join() Whether to force the joining of files
""" """
sources = [source.absolute() for source in sources]
cert_dir = Path(mkdtemp(dir=working_dir)).absolute()
output = output.absolute()
certs: List[Path] = [] certs: List[Path] = []
debug(f"Creating keyring {output} from {[str(source_dir) for source_dir in sources]}.")
for source_number, source in enumerate(sources): for source_number, source in enumerate(sources):
if source.is_dir(): if source.is_dir():
for user_number, user_dir in enumerate(sorted(source.iterdir())): for user_number, user_dir in enumerate(sorted(source.iterdir())):
@ -657,7 +649,7 @@ def export_keyring(working_dir: Path, sources: List[Path], output: Path, force:
for user_cert_number, user_cert_dir in enumerate(sorted(user_dir.iterdir())): for user_cert_number, user_cert_dir in enumerate(sorted(user_dir.iterdir())):
if user_cert_dir.is_dir(): if user_cert_dir.is_dir():
cert_path = ( cert_path = (
cert_dir temp_dir
/ ( / (
f"{str(source_number).zfill(4)}" f"{str(source_number).zfill(4)}"
f"-{str(user_number).zfill(4)}" f"-{str(user_number).zfill(4)}"
@ -674,12 +666,178 @@ def export_keyring(working_dir: Path, sources: List[Path], output: Path, force:
elif source.is_file() and not source.is_symlink(): elif source.is_file() and not source.is_symlink():
certs.append(source) certs.append(source)
return certs
def get_all_and_revoked_certs(certs: List[Path]) -> Tuple[List[str], List[str]]:
"""Get the fingerprints of all public keys and all fingerprints of all (self) revoked public keys in a directory
Parameters
----------
certs: List[Path]
The certificates to trust
Returns
-------
Tuple[List[str], List[str]]
A tuple with the first item containing the fingerprints of all public keys and the second item containing the
fingerprints of all self-revoked public keys
"""
all_fingerprints: List[str] = []
revoked_fingerprints: List[str] = []
debug(f"Retrieving all and self-revoked certificates from {[str(cert_dir) for cert_dir in certs]}")
for cert_collection in certs:
if cert_collection.is_dir():
for user_dir in cert_collection.iterdir():
if user_dir.is_dir():
for cert_dir in user_dir.iterdir():
all_fingerprints.append(cert_dir.stem)
for revocation_cert in cert_dir.glob("revocation/*.asc"):
if cert_dir.stem.endswith(revocation_cert.stem):
debug(f"Revoking {cert_dir.stem} due to self-revocation")
revoked_fingerprints.append(cert_dir.stem)
return (all_fingerprints, revoked_fingerprints)
def export_ownertrust(certs: List[Path], output: Path) -> Tuple[List[str], List[str]]:
"""Export ownertrust from a set of keys
The output file format is compatible with `gpg --import-ownertrust` and lists the main fingerprint ID of all
non-revoked keys as fully trusted.
The exported file is used by pacman-key when importing a keyring (see
https://man.archlinux.org/man/pacman-key.8#PROVIDING_A_KEYRING_FOR_IMPORT).
Parameters
----------
certs: List[Path]
The certificates to trust
output: Path
The file path to write to
"""
(all_certs, revoked_certs) = get_all_and_revoked_certs(certs=certs)
trusted_certs = [cert for cert in all_certs if cert not in revoked_certs]
with open(file=output, mode="w") as trusted_certs_file:
for cert in trusted_certs:
debug(f"Writing {cert} to {output}")
trusted_certs_file.write(f"{cert}:4:\n")
return (trusted_certs, all_certs)
def export_revoked(certs: List[Path], main_keys: List[str], output: Path, min_revoker: int = 2) -> None:
"""Export the PGP revoked status from a set of keys
The output file contains the fingerprints of all self-revoked keys and all keys for which at least two revocations
by any main key exist.
The exported file is used by pacman-key when importing a keyring (see
https://man.archlinux.org/man/pacman-key.8#PROVIDING_A_KEYRING_FOR_IMPORT).
Parameters
----------
certs: List[Path]
A list of directories with keys to check for their revocation status
main_keys: List[str]
A list of strings representing the fingerprints of (current and/or revoked) main keys
output: Path
The file path to write to
min_revoker: int
The minimum amount of revocation certificates on a User ID from any main key to deem a public key as revoked
(defaults to 2)
"""
(all_certs, revoked_certs) = get_all_and_revoked_certs(certs=certs)
debug(f"Retrieving certificates revoked by main keys from {[str(cert_dir) for cert_dir in certs]}")
foreign_revocations: Dict[str, List[str]] = {}
for cert_collection in certs:
if cert_collection.is_dir():
for user_dir in cert_collection.iterdir():
if user_dir.is_dir():
for cert_dir in user_dir.iterdir():
debug(f"Inspecting public key {cert_dir.name}")
foreign_revocations[cert_dir.stem] = []
for revocation_cert in cert_dir.glob("uids/*/revocation/*.asc"):
foreign_revocations[cert_dir.stem] += [
revocation_cert.stem for main_key in main_keys
if main_key.endswith(revocation_cert.stem)
]
# TODO: find a better (less naive) approach, as this would also match on public certificates,
# where some UIDs are signed and others are revoked
if len(set(foreign_revocations[cert_dir.stem])) >= min_revoker:
debug(
f"Revoking {cert_dir.name} due to {set(foreign_revocations[cert_dir.stem])} "
"being main key revocations"
)
revoked_certs.append(cert_dir.stem)
with open(file=output, mode="w") as trusted_certs_file:
for cert in set(revoked_certs):
debug(f"Writing {cert} to {output}")
trusted_certs_file.write(f"{cert}\n")
def export_keyring(
working_dir: Path,
main: List[Path],
sources: List[Path],
output: Path,
force: bool,
pacman_integration: bool,
) -> None:
"""Export all provided PGP packet files to a single output file
If sources contains directories, any .asc files below them are considered.
Parameters
----------
working_dir: Path
A directory to use for temporary files
main: List[Path]
A list of directories or files from which to read PGP packet information, that is considered as public keys
that are used to sign those found in sources
sources: List[Path]
A list of directories or files from which to read PGP packet information
output: Path
An output file that all PGP packet data is written to
force: bool
Whether to force the execution of packet_join()
"""
main = [source.absolute() for source in main]
sources = [source.absolute() for source in sources]
output = output.absolute()
main_certs = temp_join_keys(sources=main, temp_dir=Path(mkdtemp(dir=working_dir)).absolute(), force=force)
sources_certs = temp_join_keys(sources=sources, temp_dir=Path(mkdtemp(dir=working_dir)).absolute(), force=force)
debug(
f"Creating keyring {output} from {[str(source_dir) for source_dir in main]} "
f"and {[str(source_dir) for source_dir in sources]}."
)
cmd = ['sq', 'keyring', 'merge', '-o', str(output)] cmd = ['sq', 'keyring', 'merge', '-o', str(output)]
if force: if force:
cmd.insert(1, '--force') cmd.insert(1, '--force')
cmd += [str(cert) for cert in sorted(certs)] cmd += [str(cert) for cert in sorted(main_certs)]
cmd += [str(cert) for cert in sorted(sources_certs)]
system(cmd, exit_on_error=False) system(cmd, exit_on_error=False)
if pacman_integration:
[trusted_main_keys, all_main_keys] = export_ownertrust(
certs=main,
output=Path(f"{str(output).split('.gpg')[0]}-trusted"),
)
export_revoked(
certs=main + sources,
main_keys=all_main_keys,
output=Path(f"{str(output).split('.gpg')[0]}-revoked"),
)
def absolute_path(path: str) -> Path: def absolute_path(path: str) -> Path:
return Path(path).absolute() return Path(path).absolute()
@ -721,6 +879,14 @@ if __name__ == '__main__':
help="export a directory structure of PGP packet data to a combined file", help="export a directory structure of PGP packet data to a combined file",
) )
export_parser.add_argument('output', type=absolute_path, help='file to write PGP packet data to') export_parser.add_argument('output', type=absolute_path, help='file to write PGP packet data to')
export_parser.add_argument(
'-m',
'--main',
action="append",
help='files or directories containing PGP packet data that is trusted (can be provided multiple times)',
required=True,
type=absolute_path,
)
export_parser.add_argument( export_parser.add_argument(
'-s', '-s',
'--source', '--source',
@ -729,6 +895,13 @@ if __name__ == '__main__':
required=True, required=True,
type=absolute_path, type=absolute_path,
) )
export_parser.add_argument(
'-p',
'--pacman-integration',
action='store_true',
default=False,
help='export trusted and revoked files (used by pacman) alongside the keyring',
)
args = parser.parse_args() args = parser.parse_args()
@ -754,7 +927,14 @@ if __name__ == '__main__':
elif 'import' == args.subcommand: elif 'import' == args.subcommand:
keyring_import(working_dir, args.source, target_dir) keyring_import(working_dir, args.source, target_dir)
elif 'export' == args.subcommand: elif 'export' == args.subcommand:
export_keyring(working_dir=working_dir, sources=args.source, output=args.output, force=args.force) export_keyring(
working_dir=working_dir,
main=args.main,
sources=args.source,
output=args.output,
force=args.force,
pacman_integration=args.pacman_integration,
)
if args.wait: if args.wait:
print('Press [ENTER] to continue') print('Press [ENTER] to continue')