Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES/+post-quantum-crypto.feature
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
Pulpcore is now considered provisionally "post quantum cryptography" (PQC) compatible. PQC certificates are now supported for TLS, PGP operations, and signing services.
1 change: 1 addition & 0 deletions CHANGES/plugin_api/+pqc-verify.feature
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
`gpg_verify()` now supports post-quantum cryptography (PQC) algorithms.
23 changes: 23 additions & 0 deletions pulpcore/tests/functional/api/test_signing_service.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@

from pulpcore.pytest_plugin import (
KEY_V4_RSA4K_PRIVATE,
KEY_V6_MLDSA65_ED25519_PRIVATE,

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, OK, we still download the fixture keys here.

create_signing_service,
import_signing_key,
make_signing_script,
Expand DownExpand Up@@ -41,3 +42,25 @@ def test_add_signing_service_key_with_subkeys(backend, tmp_path_factory):
assert len(fingerprint) in (40, 64)

remove_signing_service(service_name)


def test_add_signing_service_pqc_key(tmp_path_factory):
"""Verify that add-signing-service works with PQC (ML-DSA) keys.

Post-quantum cryptographic keys using ML-DSA should be supported
for creating signing services using the Sequoia backend.
"""
home = tmp_path_factory.mktemp("pqc_mldsa_test")
script_dir = tmp_path_factory.mktemp("pqc_mldsa_script")

# PQC keys require Sequoia backend
_sq, fingerprint, _keyid = import_signing_key(
KEY_V6_MLDSA65_ED25519_PRIVATE, home, backend="sq"
)
script_path = make_signing_script(home, fingerprint, script_dir, backend="sq")
service_name = create_signing_service(home, fingerprint, script_path, backend="sq")

# v6 keys use 64-character fingerprints
assert len(fingerprint) == 64

remove_signing_service(service_name)
244 changes: 244 additions & 0 deletions pulpcore/tests/unit/test_gpg_verify.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
"""Unit tests for gpg_verify covering OpenPGP v4, v6, classical, and PQC algorithms."""

import pytest
from pysequoia import CipherSuite, Profile, SignatureMode, Tsk, sign

from pulpcore.app.util import VerifyResult, gpg_verify
from pulpcore.exceptions.validation import InvalidSignatureError

# Test key configurations: (name, key_generator, description)
TEST_KEYS = [
("v4_ed25519", (Profile.RFC4880, None), "OpenPGP v4 Ed25519"),
("v6_ed25519", (Profile.RFC9580, None), "OpenPGP v6 Ed25519"),
(
"v6_mldsa65_ed25519",
(Profile.RFC9580, CipherSuite.MLDSA65_Ed25519),
"OpenPGP v6 ML-DSA-65 + Ed25519 (PQC)",
),
]


@pytest.fixture(params=TEST_KEYS, ids=[k[0] for k in TEST_KEYS], scope="module")

@dralleydralleyAug 30, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think these tests have some value, because they do test our own API wrapper around pysequoia, but if you think they (or any individual test(s) are) more of a glorified pysequoia unit test, I can drop it.

@dralleydralleyAug 30, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternatively we can deprecate the gpg_verify function completely and remove it in the next breaking change release, because it's only a thin wrapper around pysequoia anyway, which plugins could just use directly if they wanted to.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, didn't we keep the interface of gpg_verify stable with this change? Then it already won. Why would we remove it?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It predated having any kind of usable library for this purpose - python-gnupg was kind of a pain to use because you had to set up ephemeral directories all the time - at this point the wrapper is probably not needed.

Of course we did keep the API stable anyway, but that doesn't mean we can't move away from it over the longer term. Question is just whether we ought to mark it deprecated or not.

def key_pair(request):
"""Generate an ephemeral keypair once per test configuration."""
name, key_generator, description = request.param
profile, cipher_suite = key_generator
tsk = Tsk.generate(
f"Test {name} <test-{name}@example.com>",
profile=profile,
cipher_suite=cipher_suite,
)
cert = tsk.extract_certificate()

return {
"name": name,
"description": description,
"tsk": tsk,
"pubkey": str(cert),
"fingerprint": cert.fingerprint,
}


@pytest.fixture
def detached_sig_fixture(key_pair, tmp_path):
"""Create a detached signature for a given key configuration."""
data = f"test data for {key_pair['description']}".encode()

# Sign with pysequoia
sig_bytes = sign(key_pair["tsk"].signer(), data, mode=SignatureMode.DETACHED)
sig_file = tmp_path / "sig.asc"
sig_file.write_bytes(sig_bytes)

data_file = tmp_path / "data.txt"
data_file.write_bytes(data)

return {
"pubkey": key_pair["pubkey"],
"sig_path": str(sig_file),
"data_path": str(data_file),
"fingerprint": key_pair["fingerprint"],
"data": data,
"description": key_pair["description"],
}


@pytest.fixture
def inline_sig_fixture(key_pair, tmp_path):
"""Create an inline signature for a given key configuration."""
data = f"inline signed data for {key_pair['description']}".encode()

# Sign with pysequoia
signed = sign(key_pair["tsk"].signer(), data)
sig_file = tmp_path / "inline_sig.pgp"
sig_file.write_bytes(signed)

return {
"pubkey": key_pair["pubkey"],
"sig_path": str(sig_file),
"fingerprint": key_pair["fingerprint"],
"data": data,
"description": key_pair["description"],
}


class TestGpgVerify:
"""Test gpg_verify across all key types."""

def test_detached_signature_valid(self, detached_sig_fixture):
"""Verify a valid detached signature for all key configurations."""
fixture = detached_sig_fixture

result = gpg_verify(
fixture["pubkey"],
fixture["sig_path"],
detached_data=fixture["data_path"],
)

assert isinstance(result, VerifyResult)
assert result.valid is True
# pubkey_fingerprint is the primary key, fingerprint is the signing subkey
assert result.pubkey_fingerprint.upper() == fixture["fingerprint"].upper()
assert result.key_id == result.fingerprint[-16:].upper()
assert result.data is None # detached signatures return None for data

def test_inline_signature_valid(self, inline_sig_fixture):
"""Verify inline signatures for all key configurations."""
fixture = inline_sig_fixture

result = gpg_verify(fixture["pubkey"], fixture["sig_path"])

assert isinstance(result, VerifyResult)
assert result.valid is True
assert result.pubkey_fingerprint.upper() == fixture["fingerprint"].upper()
assert result.key_id == result.fingerprint[-16:].upper()
assert result.data == fixture["data"]


class TestVerifyResultAPI:
"""Test VerifyResult API completeness."""

def test_verify_result_detached(self, tmp_path):
"""Test VerifyResult attributes for detached signatures."""
tsk = Tsk.generate("Test <test@example.com>", profile=Profile.RFC9580)
pubkey = str(tsk.extract_certificate())
fingerprint = tsk.extract_certificate().fingerprint

data = b"test data"
sig_bytes = sign(tsk.signer(), data, mode=SignatureMode.DETACHED)

sig_file = tmp_path / "sig.asc"
sig_file.write_bytes(sig_bytes)

data_file = tmp_path / "data.txt"
data_file.write_bytes(data)

result = gpg_verify(pubkey, str(sig_file), detached_data=str(data_file))

# Test all attributes
assert result.valid is True
assert isinstance(result.fingerprint, str)
assert len(result.fingerprint) in (40, 64)
assert isinstance(result.pubkey_fingerprint, str)
assert result.pubkey_fingerprint.upper() == fingerprint.upper()
assert isinstance(result.key_id, str)
assert result.key_id == result.fingerprint[-16:].upper()
assert result.data is None # detached signature

# Test repr
repr_str = repr(result)
assert "VerifyResult" in repr_str
assert "valid=True" in repr_str
assert "fingerprint=" in repr_str
assert "key_id=" in repr_str

def test_verify_result_inline(self, tmp_path):
"""Test that VerifyResult.data contains plaintext for inline signatures."""
tsk = Tsk.generate("Test <test@example.com>", profile=Profile.RFC9580)
pubkey = str(tsk.extract_certificate())

data = b"test data"
signed = sign(tsk.signer(), data)

sig_file = tmp_path / "inline_sig.pgp"
sig_file.write_bytes(signed)

result = gpg_verify(pubkey, str(sig_file))

# For inline signatures, data should contain the plaintext
assert result.valid is True
assert result.data is not None
assert isinstance(result.data, bytes)
assert result.data == data


class TestGpgVerifyErrorHandling:
"""Test gpg_verify error handling."""

def test_wrong_data(self, tmp_path):
"""Test that tampered data fails validation."""
tsk = Tsk.generate("Test <test@example.com>", profile=Profile.RFC9580)
pubkey = str(tsk.extract_certificate())

data = b"original data"
sig_bytes = sign(tsk.signer(), data, mode=SignatureMode.DETACHED)

sig_file = tmp_path / "sig.asc"
sig_file.write_bytes(sig_bytes)

tampered_file = tmp_path / "tampered.txt"
tampered_file.write_bytes(b"tampered data")

with pytest.raises(InvalidSignatureError):
gpg_verify(pubkey, str(sig_file), detached_data=str(tampered_file))

def test_wrong_key(self, tmp_path):
"""Test that wrong public key fails validation."""
tsk = Tsk.generate("Signer <signer@example.com>", profile=Profile.RFC9580)
wrong_tsk = Tsk.generate("Wrong <wrong@example.com>", profile=Profile.RFC9580)
wrong_pubkey = str(wrong_tsk.extract_certificate())

data = b"test data"
sig_bytes = sign(tsk.signer(), data, mode=SignatureMode.DETACHED)

sig_file = tmp_path / "sig.asc"
sig_file.write_bytes(sig_bytes)

data_file = tmp_path / "data.txt"
data_file.write_bytes(data)

with pytest.raises(InvalidSignatureError):
gpg_verify(wrong_pubkey, str(sig_file), detached_data=str(data_file))

def test_invalid_signature_data(self, tmp_path):
"""Test that invalid signature data raises InvalidSignatureError."""
tsk = Tsk.generate("Test <test@example.com>", profile=Profile.RFC9580)
pubkey = str(tsk.extract_certificate())

bad_sig_file = tmp_path / "bad_sig.asc"
bad_sig_file.write_bytes(b"not a valid signature")

data_file = tmp_path / "data.txt"
data_file.write_bytes(b"some data")

with pytest.raises(InvalidSignatureError):
gpg_verify(pubkey, str(bad_sig_file), detached_data=str(data_file))

def test_corrupted_signature(self, tmp_path):
"""Test that corrupted signature raises InvalidSignatureError."""
tsk = Tsk.generate("Test <test@example.com>", profile=Profile.RFC9580)
pubkey = str(tsk.extract_certificate())

data = b"test data"
sig_bytes = sign(tsk.signer(), data, mode=SignatureMode.DETACHED)

# Corrupt the signature
corrupted_sig = sig_bytes[:-20] + b"X" * 20

sig_file = tmp_path / "corrupt_sig.asc"
sig_file.write_bytes(corrupted_sig)

data_file = tmp_path / "data.txt"
data_file.write_bytes(data)

with pytest.raises(InvalidSignatureError):
gpg_verify(pubkey, str(sig_file), detached_data=str(data_file))
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -56,7 +56,7 @@ dependencies = [
"pygtrie>=2.5,<=2.5.0",
"psycopg[binary]>=3.3.4,<3.4", # SemVer, not explicitely stated, but mentioned on multiple changes.
"pyparsing>=3.1.0,<3.4", # Looks like only bugfixes in z-Stream.
"pysequoia>=0.1.33,<0.2",
"pysequoia>=0.1.35,<0.2.0",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we know about the specific versioning policy here?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It hasn't been the most consistent. Personally I think it ought to have been a 0.2.0 release given the major changes, but there were no breaking changes at least.

"PyYAML>=5.1.1,<6.1", # Looks like only bugfixes in z-Stream.
"redis>=4.3.0,<8.2", # Looks like only bugfixes in z-Stream.
"tablib>=3.5.0,<4.0, !=3.6", # 3.6.0 breaks with import export. Not sure about semver.
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
PQC support via new pysequoia version by dralley · Pull Request #8011 · pulp/pulpcore · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES/+post-quantum-crypto.feature
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
Pulpcore is now considered provisionally "post quantum cryptography" (PQC) compatible. PQC certificates are now supported for TLS, PGP operations, and signing services.
1 change: 1 addition & 0 deletions CHANGES/plugin_api/+pqc-verify.feature
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
`gpg_verify()` now supports post-quantum cryptography (PQC) algorithms.
23 changes: 23 additions & 0 deletions pulpcore/tests/functional/api/test_signing_service.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@

from pulpcore.pytest_plugin import (
KEY_V4_RSA4K_PRIVATE,
KEY_V6_MLDSA65_ED25519_PRIVATE,

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, OK, we still download the fixture keys here.

create_signing_service,
import_signing_key,
make_signing_script,
Expand DownExpand Up@@ -41,3 +42,25 @@ def test_add_signing_service_key_with_subkeys(backend, tmp_path_factory):
assert len(fingerprint) in (40, 64)

remove_signing_service(service_name)


def test_add_signing_service_pqc_key(tmp_path_factory):
"""Verify that add-signing-service works with PQC (ML-DSA) keys.

Post-quantum cryptographic keys using ML-DSA should be supported
for creating signing services using the Sequoia backend.
"""
home = tmp_path_factory.mktemp("pqc_mldsa_test")
script_dir = tmp_path_factory.mktemp("pqc_mldsa_script")

# PQC keys require Sequoia backend
_sq, fingerprint, _keyid = import_signing_key(
KEY_V6_MLDSA65_ED25519_PRIVATE, home, backend="sq"
)
script_path = make_signing_script(home, fingerprint, script_dir, backend="sq")
service_name = create_signing_service(home, fingerprint, script_path, backend="sq")

# v6 keys use 64-character fingerprints
assert len(fingerprint) == 64

remove_signing_service(service_name)
244 changes: 244 additions & 0 deletions pulpcore/tests/unit/test_gpg_verify.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
"""Unit tests for gpg_verify covering OpenPGP v4, v6, classical, and PQC algorithms."""

import pytest
from pysequoia import CipherSuite, Profile, SignatureMode, Tsk, sign

from pulpcore.app.util import VerifyResult, gpg_verify
from pulpcore.exceptions.validation import InvalidSignatureError

# Test key configurations: (name, key_generator, description)
TEST_KEYS = [
("v4_ed25519", (Profile.RFC4880, None), "OpenPGP v4 Ed25519"),
("v6_ed25519", (Profile.RFC9580, None), "OpenPGP v6 Ed25519"),
(
"v6_mldsa65_ed25519",
(Profile.RFC9580, CipherSuite.MLDSA65_Ed25519),
"OpenPGP v6 ML-DSA-65 + Ed25519 (PQC)",
),
]


@pytest.fixture(params=TEST_KEYS, ids=[k[0] for k in TEST_KEYS], scope="module")

@dralleydralleyAug 30, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think these tests have some value, because they do test our own API wrapper around pysequoia, but if you think they (or any individual test(s) are) more of a glorified pysequoia unit test, I can drop it.

@dralleydralleyAug 30, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternatively we can deprecate the gpg_verify function completely and remove it in the next breaking change release, because it's only a thin wrapper around pysequoia anyway, which plugins could just use directly if they wanted to.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, didn't we keep the interface of gpg_verify stable with this change? Then it already won. Why would we remove it?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It predated having any kind of usable library for this purpose - python-gnupg was kind of a pain to use because you had to set up ephemeral directories all the time - at this point the wrapper is probably not needed.

Of course we did keep the API stable anyway, but that doesn't mean we can't move away from it over the longer term. Question is just whether we ought to mark it deprecated or not.

def key_pair(request):
"""Generate an ephemeral keypair once per test configuration."""
name, key_generator, description = request.param
profile, cipher_suite = key_generator
tsk = Tsk.generate(
f"Test {name} <test-{name}@example.com>",
profile=profile,
cipher_suite=cipher_suite,
)
cert = tsk.extract_certificate()

return {
"name": name,
"description": description,
"tsk": tsk,
"pubkey": str(cert),
"fingerprint": cert.fingerprint,
}


@pytest.fixture
def detached_sig_fixture(key_pair, tmp_path):
"""Create a detached signature for a given key configuration."""
data = f"test data for {key_pair['description']}".encode()

# Sign with pysequoia
sig_bytes = sign(key_pair["tsk"].signer(), data, mode=SignatureMode.DETACHED)
sig_file = tmp_path / "sig.asc"
sig_file.write_bytes(sig_bytes)

data_file = tmp_path / "data.txt"
data_file.write_bytes(data)

return {
"pubkey": key_pair["pubkey"],
"sig_path": str(sig_file),
"data_path": str(data_file),
"fingerprint": key_pair["fingerprint"],
"data": data,
"description": key_pair["description"],
}


@pytest.fixture
def inline_sig_fixture(key_pair, tmp_path):
"""Create an inline signature for a given key configuration."""
data = f"inline signed data for {key_pair['description']}".encode()

# Sign with pysequoia
signed = sign(key_pair["tsk"].signer(), data)
sig_file = tmp_path / "inline_sig.pgp"
sig_file.write_bytes(signed)

return {
"pubkey": key_pair["pubkey"],
"sig_path": str(sig_file),
"fingerprint": key_pair["fingerprint"],
"data": data,
"description": key_pair["description"],
}


class TestGpgVerify:
"""Test gpg_verify across all key types."""

def test_detached_signature_valid(self, detached_sig_fixture):
"""Verify a valid detached signature for all key configurations."""
fixture = detached_sig_fixture

result = gpg_verify(
fixture["pubkey"],
fixture["sig_path"],
detached_data=fixture["data_path"],
)

assert isinstance(result, VerifyResult)
assert result.valid is True
# pubkey_fingerprint is the primary key, fingerprint is the signing subkey
assert result.pubkey_fingerprint.upper() == fixture["fingerprint"].upper()
assert result.key_id == result.fingerprint[-16:].upper()
assert result.data is None # detached signatures return None for data

def test_inline_signature_valid(self, inline_sig_fixture):
"""Verify inline signatures for all key configurations."""
fixture = inline_sig_fixture

result = gpg_verify(fixture["pubkey"], fixture["sig_path"])

assert isinstance(result, VerifyResult)
assert result.valid is True
assert result.pubkey_fingerprint.upper() == fixture["fingerprint"].upper()
assert result.key_id == result.fingerprint[-16:].upper()
assert result.data == fixture["data"]


class TestVerifyResultAPI:
"""Test VerifyResult API completeness."""

def test_verify_result_detached(self, tmp_path):
"""Test VerifyResult attributes for detached signatures."""
tsk = Tsk.generate("Test <test@example.com>", profile=Profile.RFC9580)
pubkey = str(tsk.extract_certificate())
fingerprint = tsk.extract_certificate().fingerprint

data = b"test data"
sig_bytes = sign(tsk.signer(), data, mode=SignatureMode.DETACHED)

sig_file = tmp_path / "sig.asc"
sig_file.write_bytes(sig_bytes)

data_file = tmp_path / "data.txt"
data_file.write_bytes(data)

result = gpg_verify(pubkey, str(sig_file), detached_data=str(data_file))

# Test all attributes
assert result.valid is True
assert isinstance(result.fingerprint, str)
assert len(result.fingerprint) in (40, 64)
assert isinstance(result.pubkey_fingerprint, str)
assert result.pubkey_fingerprint.upper() == fingerprint.upper()
assert isinstance(result.key_id, str)
assert result.key_id == result.fingerprint[-16:].upper()
assert result.data is None # detached signature

# Test repr
repr_str = repr(result)
assert "VerifyResult" in repr_str
assert "valid=True" in repr_str
assert "fingerprint=" in repr_str
assert "key_id=" in repr_str

def test_verify_result_inline(self, tmp_path):
"""Test that VerifyResult.data contains plaintext for inline signatures."""
tsk = Tsk.generate("Test <test@example.com>", profile=Profile.RFC9580)
pubkey = str(tsk.extract_certificate())

data = b"test data"
signed = sign(tsk.signer(), data)

sig_file = tmp_path / "inline_sig.pgp"
sig_file.write_bytes(signed)

result = gpg_verify(pubkey, str(sig_file))

# For inline signatures, data should contain the plaintext
assert result.valid is True
assert result.data is not None
assert isinstance(result.data, bytes)
assert result.data == data


class TestGpgVerifyErrorHandling:
"""Test gpg_verify error handling."""

def test_wrong_data(self, tmp_path):
"""Test that tampered data fails validation."""
tsk = Tsk.generate("Test <test@example.com>", profile=Profile.RFC9580)
pubkey = str(tsk.extract_certificate())

data = b"original data"
sig_bytes = sign(tsk.signer(), data, mode=SignatureMode.DETACHED)

sig_file = tmp_path / "sig.asc"
sig_file.write_bytes(sig_bytes)

tampered_file = tmp_path / "tampered.txt"
tampered_file.write_bytes(b"tampered data")

with pytest.raises(InvalidSignatureError):
gpg_verify(pubkey, str(sig_file), detached_data=str(tampered_file))

def test_wrong_key(self, tmp_path):
"""Test that wrong public key fails validation."""
tsk = Tsk.generate("Signer <signer@example.com>", profile=Profile.RFC9580)
wrong_tsk = Tsk.generate("Wrong <wrong@example.com>", profile=Profile.RFC9580)
wrong_pubkey = str(wrong_tsk.extract_certificate())

data = b"test data"
sig_bytes = sign(tsk.signer(), data, mode=SignatureMode.DETACHED)

sig_file = tmp_path / "sig.asc"
sig_file.write_bytes(sig_bytes)

data_file = tmp_path / "data.txt"
data_file.write_bytes(data)

with pytest.raises(InvalidSignatureError):
gpg_verify(wrong_pubkey, str(sig_file), detached_data=str(data_file))

def test_invalid_signature_data(self, tmp_path):
"""Test that invalid signature data raises InvalidSignatureError."""
tsk = Tsk.generate("Test <test@example.com>", profile=Profile.RFC9580)
pubkey = str(tsk.extract_certificate())

bad_sig_file = tmp_path / "bad_sig.asc"
bad_sig_file.write_bytes(b"not a valid signature")

data_file = tmp_path / "data.txt"
data_file.write_bytes(b"some data")

with pytest.raises(InvalidSignatureError):
gpg_verify(pubkey, str(bad_sig_file), detached_data=str(data_file))

def test_corrupted_signature(self, tmp_path):
"""Test that corrupted signature raises InvalidSignatureError."""
tsk = Tsk.generate("Test <test@example.com>", profile=Profile.RFC9580)
pubkey = str(tsk.extract_certificate())

data = b"test data"
sig_bytes = sign(tsk.signer(), data, mode=SignatureMode.DETACHED)

# Corrupt the signature
corrupted_sig = sig_bytes[:-20] + b"X" * 20

sig_file = tmp_path / "corrupt_sig.asc"
sig_file.write_bytes(corrupted_sig)

data_file = tmp_path / "data.txt"
data_file.write_bytes(data)

with pytest.raises(InvalidSignatureError):
gpg_verify(pubkey, str(sig_file), detached_data=str(data_file))
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -56,7 +56,7 @@ dependencies = [
"pygtrie>=2.5,<=2.5.0",
"psycopg[binary]>=3.3.4,<3.4", # SemVer, not explicitely stated, but mentioned on multiple changes.
"pyparsing>=3.1.0,<3.4", # Looks like only bugfixes in z-Stream.
"pysequoia>=0.1.33,<0.2",
"pysequoia>=0.1.35,<0.2.0",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we know about the specific versioning policy here?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It hasn't been the most consistent. Personally I think it ought to have been a 0.2.0 release given the major changes, but there were no breaking changes at least.

"PyYAML>=5.1.1,<6.1", # Looks like only bugfixes in z-Stream.
"redis>=4.3.0,<8.2", # Looks like only bugfixes in z-Stream.
"tablib>=3.5.0,<4.0, !=3.6", # 3.6.0 breaks with import export. Not sure about semver.
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' PQC support via new pysequoia version by dralley · Pull Request #8011 · pulp/pulpcore · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES/+post-quantum-crypto.feature
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
Pulpcore is now considered provisionally "post quantum cryptography" (PQC) compatible. PQC certificates are now supported for TLS, PGP operations, and signing services.
1 change: 1 addition & 0 deletions CHANGES/plugin_api/+pqc-verify.feature
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
`gpg_verify()` now supports post-quantum cryptography (PQC) algorithms.
23 changes: 23 additions & 0 deletions pulpcore/tests/functional/api/test_signing_service.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@

from pulpcore.pytest_plugin import (
KEY_V4_RSA4K_PRIVATE,
KEY_V6_MLDSA65_ED25519_PRIVATE,

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, OK, we still download the fixture keys here.

create_signing_service,
import_signing_key,
make_signing_script,
Expand DownExpand Up@@ -41,3 +42,25 @@ def test_add_signing_service_key_with_subkeys(backend, tmp_path_factory):
assert len(fingerprint) in (40, 64)

remove_signing_service(service_name)


def test_add_signing_service_pqc_key(tmp_path_factory):
"""Verify that add-signing-service works with PQC (ML-DSA) keys.

Post-quantum cryptographic keys using ML-DSA should be supported
for creating signing services using the Sequoia backend.
"""
home = tmp_path_factory.mktemp("pqc_mldsa_test")
script_dir = tmp_path_factory.mktemp("pqc_mldsa_script")

# PQC keys require Sequoia backend
_sq, fingerprint, _keyid = import_signing_key(
KEY_V6_MLDSA65_ED25519_PRIVATE, home, backend="sq"
)
script_path = make_signing_script(home, fingerprint, script_dir, backend="sq")
service_name = create_signing_service(home, fingerprint, script_path, backend="sq")

# v6 keys use 64-character fingerprints
assert len(fingerprint) == 64

remove_signing_service(service_name)
244 changes: 244 additions & 0 deletions pulpcore/tests/unit/test_gpg_verify.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
"""Unit tests for gpg_verify covering OpenPGP v4, v6, classical, and PQC algorithms."""

import pytest
from pysequoia import CipherSuite, Profile, SignatureMode, Tsk, sign

from pulpcore.app.util import VerifyResult, gpg_verify
from pulpcore.exceptions.validation import InvalidSignatureError

# Test key configurations: (name, key_generator, description)
TEST_KEYS = [
("v4_ed25519", (Profile.RFC4880, None), "OpenPGP v4 Ed25519"),
("v6_ed25519", (Profile.RFC9580, None), "OpenPGP v6 Ed25519"),
(
"v6_mldsa65_ed25519",
(Profile.RFC9580, CipherSuite.MLDSA65_Ed25519),
"OpenPGP v6 ML-DSA-65 + Ed25519 (PQC)",
),
]


@pytest.fixture(params=TEST_KEYS, ids=[k[0] for k in TEST_KEYS], scope="module")

@dralleydralleyAug 30, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think these tests have some value, because they do test our own API wrapper around pysequoia, but if you think they (or any individual test(s) are) more of a glorified pysequoia unit test, I can drop it.

@dralleydralleyAug 30, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternatively we can deprecate the gpg_verify function completely and remove it in the next breaking change release, because it's only a thin wrapper around pysequoia anyway, which plugins could just use directly if they wanted to.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, didn't we keep the interface of gpg_verify stable with this change? Then it already won. Why would we remove it?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It predated having any kind of usable library for this purpose - python-gnupg was kind of a pain to use because you had to set up ephemeral directories all the time - at this point the wrapper is probably not needed.

Of course we did keep the API stable anyway, but that doesn't mean we can't move away from it over the longer term. Question is just whether we ought to mark it deprecated or not.

def key_pair(request):
"""Generate an ephemeral keypair once per test configuration."""
name, key_generator, description = request.param
profile, cipher_suite = key_generator
tsk = Tsk.generate(
f"Test {name} <test-{name}@example.com>",
profile=profile,
cipher_suite=cipher_suite,
)
cert = tsk.extract_certificate()

return {
"name": name,
"description": description,
"tsk": tsk,
"pubkey": str(cert),
"fingerprint": cert.fingerprint,
}


@pytest.fixture
def detached_sig_fixture(key_pair, tmp_path):
"""Create a detached signature for a given key configuration."""
data = f"test data for {key_pair['description']}".encode()

# Sign with pysequoia
sig_bytes = sign(key_pair["tsk"].signer(), data, mode=SignatureMode.DETACHED)
sig_file = tmp_path / "sig.asc"
sig_file.write_bytes(sig_bytes)

data_file = tmp_path / "data.txt"
data_file.write_bytes(data)

return {
"pubkey": key_pair["pubkey"],
"sig_path": str(sig_file),
"data_path": str(data_file),
"fingerprint": key_pair["fingerprint"],
"data": data,
"description": key_pair["description"],
}


@pytest.fixture
def inline_sig_fixture(key_pair, tmp_path):
"""Create an inline signature for a given key configuration."""
data = f"inline signed data for {key_pair['description']}".encode()

# Sign with pysequoia
signed = sign(key_pair["tsk"].signer(), data)
sig_file = tmp_path / "inline_sig.pgp"
sig_file.write_bytes(signed)

return {
"pubkey": key_pair["pubkey"],
"sig_path": str(sig_file),
"fingerprint": key_pair["fingerprint"],
"data": data,
"description": key_pair["description"],
}


class TestGpgVerify:
"""Test gpg_verify across all key types."""

def test_detached_signature_valid(self, detached_sig_fixture):
"""Verify a valid detached signature for all key configurations."""
fixture = detached_sig_fixture

result = gpg_verify(
fixture["pubkey"],
fixture["sig_path"],
detached_data=fixture["data_path"],
)

assert isinstance(result, VerifyResult)
assert result.valid is True
# pubkey_fingerprint is the primary key, fingerprint is the signing subkey
assert result.pubkey_fingerprint.upper() == fixture["fingerprint"].upper()
assert result.key_id == result.fingerprint[-16:].upper()
assert result.data is None # detached signatures return None for data

def test_inline_signature_valid(self, inline_sig_fixture):
"""Verify inline signatures for all key configurations."""
fixture = inline_sig_fixture

result = gpg_verify(fixture["pubkey"], fixture["sig_path"])

assert isinstance(result, VerifyResult)
assert result.valid is True
assert result.pubkey_fingerprint.upper() == fixture["fingerprint"].upper()
assert result.key_id == result.fingerprint[-16:].upper()
assert result.data == fixture["data"]


class TestVerifyResultAPI:
"""Test VerifyResult API completeness."""

def test_verify_result_detached(self, tmp_path):
"""Test VerifyResult attributes for detached signatures."""
tsk = Tsk.generate("Test <test@example.com>", profile=Profile.RFC9580)
pubkey = str(tsk.extract_certificate())
fingerprint = tsk.extract_certificate().fingerprint

data = b"test data"
sig_bytes = sign(tsk.signer(), data, mode=SignatureMode.DETACHED)

sig_file = tmp_path / "sig.asc"
sig_file.write_bytes(sig_bytes)

data_file = tmp_path / "data.txt"
data_file.write_bytes(data)

result = gpg_verify(pubkey, str(sig_file), detached_data=str(data_file))

# Test all attributes
assert result.valid is True
assert isinstance(result.fingerprint, str)
assert len(result.fingerprint) in (40, 64)
assert isinstance(result.pubkey_fingerprint, str)
assert result.pubkey_fingerprint.upper() == fingerprint.upper()
assert isinstance(result.key_id, str)
assert result.key_id == result.fingerprint[-16:].upper()
assert result.data is None # detached signature

# Test repr
repr_str = repr(result)
assert "VerifyResult" in repr_str
assert "valid=True" in repr_str
assert "fingerprint=" in repr_str
assert "key_id=" in repr_str

def test_verify_result_inline(self, tmp_path):
"""Test that VerifyResult.data contains plaintext for inline signatures."""
tsk = Tsk.generate("Test <test@example.com>", profile=Profile.RFC9580)
pubkey = str(tsk.extract_certificate())

data = b"test data"
signed = sign(tsk.signer(), data)

sig_file = tmp_path / "inline_sig.pgp"
sig_file.write_bytes(signed)

result = gpg_verify(pubkey, str(sig_file))

# For inline signatures, data should contain the plaintext
assert result.valid is True
assert result.data is not None
assert isinstance(result.data, bytes)
assert result.data == data


class TestGpgVerifyErrorHandling:
"""Test gpg_verify error handling."""

def test_wrong_data(self, tmp_path):
"""Test that tampered data fails validation."""
tsk = Tsk.generate("Test <test@example.com>", profile=Profile.RFC9580)
pubkey = str(tsk.extract_certificate())

data = b"original data"
sig_bytes = sign(tsk.signer(), data, mode=SignatureMode.DETACHED)

sig_file = tmp_path / "sig.asc"
sig_file.write_bytes(sig_bytes)

tampered_file = tmp_path / "tampered.txt"
tampered_file.write_bytes(b"tampered data")

with pytest.raises(InvalidSignatureError):
gpg_verify(pubkey, str(sig_file), detached_data=str(tampered_file))

def test_wrong_key(self, tmp_path):
"""Test that wrong public key fails validation."""
tsk = Tsk.generate("Signer <signer@example.com>", profile=Profile.RFC9580)
wrong_tsk = Tsk.generate("Wrong <wrong@example.com>", profile=Profile.RFC9580)
wrong_pubkey = str(wrong_tsk.extract_certificate())

data = b"test data"
sig_bytes = sign(tsk.signer(), data, mode=SignatureMode.DETACHED)

sig_file = tmp_path / "sig.asc"
sig_file.write_bytes(sig_bytes)

data_file = tmp_path / "data.txt"
data_file.write_bytes(data)

with pytest.raises(InvalidSignatureError):
gpg_verify(wrong_pubkey, str(sig_file), detached_data=str(data_file))

def test_invalid_signature_data(self, tmp_path):
"""Test that invalid signature data raises InvalidSignatureError."""
tsk = Tsk.generate("Test <test@example.com>", profile=Profile.RFC9580)
pubkey = str(tsk.extract_certificate())

bad_sig_file = tmp_path / "bad_sig.asc"
bad_sig_file.write_bytes(b"not a valid signature")

data_file = tmp_path / "data.txt"
data_file.write_bytes(b"some data")

with pytest.raises(InvalidSignatureError):
gpg_verify(pubkey, str(bad_sig_file), detached_data=str(data_file))

def test_corrupted_signature(self, tmp_path):
"""Test that corrupted signature raises InvalidSignatureError."""
tsk = Tsk.generate("Test <test@example.com>", profile=Profile.RFC9580)
pubkey = str(tsk.extract_certificate())

data = b"test data"
sig_bytes = sign(tsk.signer(), data, mode=SignatureMode.DETACHED)

# Corrupt the signature
corrupted_sig = sig_bytes[:-20] + b"X" * 20

sig_file = tmp_path / "corrupt_sig.asc"
sig_file.write_bytes(corrupted_sig)

data_file = tmp_path / "data.txt"
data_file.write_bytes(data)

with pytest.raises(InvalidSignatureError):
gpg_verify(pubkey, str(sig_file), detached_data=str(data_file))
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -56,7 +56,7 @@ dependencies = [
"pygtrie>=2.5,<=2.5.0",
"psycopg[binary]>=3.3.4,<3.4", # SemVer, not explicitely stated, but mentioned on multiple changes.
"pyparsing>=3.1.0,<3.4", # Looks like only bugfixes in z-Stream.
"pysequoia>=0.1.33,<0.2",
"pysequoia>=0.1.35,<0.2.0",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we know about the specific versioning policy here?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It hasn't been the most consistent. Personally I think it ought to have been a 0.2.0 release given the major changes, but there were no breaking changes at least.

"PyYAML>=5.1.1,<6.1", # Looks like only bugfixes in z-Stream.
"redis>=4.3.0,<8.2", # Looks like only bugfixes in z-Stream.
"tablib>=3.5.0,<4.0, !=3.6", # 3.6.0 breaks with import export. Not sure about semver.
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' PQC support via new pysequoia version by dralley · Pull Request #8011 · pulp/pulpcore · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES/+post-quantum-crypto.feature
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
Pulpcore is now considered provisionally "post quantum cryptography" (PQC) compatible. PQC certificates are now supported for TLS, PGP operations, and signing services.
1 change: 1 addition & 0 deletions CHANGES/plugin_api/+pqc-verify.feature
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
`gpg_verify()` now supports post-quantum cryptography (PQC) algorithms.
23 changes: 23 additions & 0 deletions pulpcore/tests/functional/api/test_signing_service.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@

from pulpcore.pytest_plugin import (
KEY_V4_RSA4K_PRIVATE,
KEY_V6_MLDSA65_ED25519_PRIVATE,

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, OK, we still download the fixture keys here.

create_signing_service,
import_signing_key,
make_signing_script,
Expand DownExpand Up@@ -41,3 +42,25 @@ def test_add_signing_service_key_with_subkeys(backend, tmp_path_factory):
assert len(fingerprint) in (40, 64)

remove_signing_service(service_name)


def test_add_signing_service_pqc_key(tmp_path_factory):
"""Verify that add-signing-service works with PQC (ML-DSA) keys.

Post-quantum cryptographic keys using ML-DSA should be supported
for creating signing services using the Sequoia backend.
"""
home = tmp_path_factory.mktemp("pqc_mldsa_test")
script_dir = tmp_path_factory.mktemp("pqc_mldsa_script")

# PQC keys require Sequoia backend
_sq, fingerprint, _keyid = import_signing_key(
KEY_V6_MLDSA65_ED25519_PRIVATE, home, backend="sq"
)
script_path = make_signing_script(home, fingerprint, script_dir, backend="sq")
service_name = create_signing_service(home, fingerprint, script_path, backend="sq")

# v6 keys use 64-character fingerprints
assert len(fingerprint) == 64

remove_signing_service(service_name)
244 changes: 244 additions & 0 deletions pulpcore/tests/unit/test_gpg_verify.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
"""Unit tests for gpg_verify covering OpenPGP v4, v6, classical, and PQC algorithms."""

import pytest
from pysequoia import CipherSuite, Profile, SignatureMode, Tsk, sign

from pulpcore.app.util import VerifyResult, gpg_verify
from pulpcore.exceptions.validation import InvalidSignatureError

# Test key configurations: (name, key_generator, description)
TEST_KEYS = [
("v4_ed25519", (Profile.RFC4880, None), "OpenPGP v4 Ed25519"),
("v6_ed25519", (Profile.RFC9580, None), "OpenPGP v6 Ed25519"),
(
"v6_mldsa65_ed25519",
(Profile.RFC9580, CipherSuite.MLDSA65_Ed25519),
"OpenPGP v6 ML-DSA-65 + Ed25519 (PQC)",
),
]


@pytest.fixture(params=TEST_KEYS, ids=[k[0] for k in TEST_KEYS], scope="module")

@dralleydralleyAug 30, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think these tests have some value, because they do test our own API wrapper around pysequoia, but if you think they (or any individual test(s) are) more of a glorified pysequoia unit test, I can drop it.

@dralleydralleyAug 30, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternatively we can deprecate the gpg_verify function completely and remove it in the next breaking change release, because it's only a thin wrapper around pysequoia anyway, which plugins could just use directly if they wanted to.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, didn't we keep the interface of gpg_verify stable with this change? Then it already won. Why would we remove it?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It predated having any kind of usable library for this purpose - python-gnupg was kind of a pain to use because you had to set up ephemeral directories all the time - at this point the wrapper is probably not needed.

Of course we did keep the API stable anyway, but that doesn't mean we can't move away from it over the longer term. Question is just whether we ought to mark it deprecated or not.

def key_pair(request):
"""Generate an ephemeral keypair once per test configuration."""
name, key_generator, description = request.param
profile, cipher_suite = key_generator
tsk = Tsk.generate(
f"Test {name} <test-{name}@example.com>",
profile=profile,
cipher_suite=cipher_suite,
)
cert = tsk.extract_certificate()

return {
"name": name,
"description": description,
"tsk": tsk,
"pubkey": str(cert),
"fingerprint": cert.fingerprint,
}


@pytest.fixture
def detached_sig_fixture(key_pair, tmp_path):
"""Create a detached signature for a given key configuration."""
data = f"test data for {key_pair['description']}".encode()

# Sign with pysequoia
sig_bytes = sign(key_pair["tsk"].signer(), data, mode=SignatureMode.DETACHED)
sig_file = tmp_path / "sig.asc"
sig_file.write_bytes(sig_bytes)

data_file = tmp_path / "data.txt"
data_file.write_bytes(data)

return {
"pubkey": key_pair["pubkey"],
"sig_path": str(sig_file),
"data_path": str(data_file),
"fingerprint": key_pair["fingerprint"],
"data": data,
"description": key_pair["description"],
}


@pytest.fixture
def inline_sig_fixture(key_pair, tmp_path):
"""Create an inline signature for a given key configuration."""
data = f"inline signed data for {key_pair['description']}".encode()

# Sign with pysequoia
signed = sign(key_pair["tsk"].signer(), data)
sig_file = tmp_path / "inline_sig.pgp"
sig_file.write_bytes(signed)

return {
"pubkey": key_pair["pubkey"],
"sig_path": str(sig_file),
"fingerprint": key_pair["fingerprint"],
"data": data,
"description": key_pair["description"],
}


class TestGpgVerify:
"""Test gpg_verify across all key types."""

def test_detached_signature_valid(self, detached_sig_fixture):
"""Verify a valid detached signature for all key configurations."""
fixture = detached_sig_fixture

result = gpg_verify(
fixture["pubkey"],
fixture["sig_path"],
detached_data=fixture["data_path"],
)

assert isinstance(result, VerifyResult)
assert result.valid is True
# pubkey_fingerprint is the primary key, fingerprint is the signing subkey
assert result.pubkey_fingerprint.upper() == fixture["fingerprint"].upper()
assert result.key_id == result.fingerprint[-16:].upper()
assert result.data is None # detached signatures return None for data

def test_inline_signature_valid(self, inline_sig_fixture):
"""Verify inline signatures for all key configurations."""
fixture = inline_sig_fixture

result = gpg_verify(fixture["pubkey"], fixture["sig_path"])

assert isinstance(result, VerifyResult)
assert result.valid is True
assert result.pubkey_fingerprint.upper() == fixture["fingerprint"].upper()
assert result.key_id == result.fingerprint[-16:].upper()
assert result.data == fixture["data"]


class TestVerifyResultAPI:
"""Test VerifyResult API completeness."""

def test_verify_result_detached(self, tmp_path):
"""Test VerifyResult attributes for detached signatures."""
tsk = Tsk.generate("Test <test@example.com>", profile=Profile.RFC9580)
pubkey = str(tsk.extract_certificate())
fingerprint = tsk.extract_certificate().fingerprint

data = b"test data"
sig_bytes = sign(tsk.signer(), data, mode=SignatureMode.DETACHED)

sig_file = tmp_path / "sig.asc"
sig_file.write_bytes(sig_bytes)

data_file = tmp_path / "data.txt"
data_file.write_bytes(data)

result = gpg_verify(pubkey, str(sig_file), detached_data=str(data_file))

# Test all attributes
assert result.valid is True
assert isinstance(result.fingerprint, str)
assert len(result.fingerprint) in (40, 64)
assert isinstance(result.pubkey_fingerprint, str)
assert result.pubkey_fingerprint.upper() == fingerprint.upper()
assert isinstance(result.key_id, str)
assert result.key_id == result.fingerprint[-16:].upper()
assert result.data is None # detached signature

# Test repr
repr_str = repr(result)
assert "VerifyResult" in repr_str
assert "valid=True" in repr_str
assert "fingerprint=" in repr_str
assert "key_id=" in repr_str

def test_verify_result_inline(self, tmp_path):
"""Test that VerifyResult.data contains plaintext for inline signatures."""
tsk = Tsk.generate("Test <test@example.com>", profile=Profile.RFC9580)
pubkey = str(tsk.extract_certificate())

data = b"test data"
signed = sign(tsk.signer(), data)

sig_file = tmp_path / "inline_sig.pgp"
sig_file.write_bytes(signed)

result = gpg_verify(pubkey, str(sig_file))

# For inline signatures, data should contain the plaintext
assert result.valid is True
assert result.data is not None
assert isinstance(result.data, bytes)
assert result.data == data


class TestGpgVerifyErrorHandling:
"""Test gpg_verify error handling."""

def test_wrong_data(self, tmp_path):
"""Test that tampered data fails validation."""
tsk = Tsk.generate("Test <test@example.com>", profile=Profile.RFC9580)
pubkey = str(tsk.extract_certificate())

data = b"original data"
sig_bytes = sign(tsk.signer(), data, mode=SignatureMode.DETACHED)

sig_file = tmp_path / "sig.asc"
sig_file.write_bytes(sig_bytes)

tampered_file = tmp_path / "tampered.txt"
tampered_file.write_bytes(b"tampered data")

with pytest.raises(InvalidSignatureError):
gpg_verify(pubkey, str(sig_file), detached_data=str(tampered_file))

def test_wrong_key(self, tmp_path):
"""Test that wrong public key fails validation."""
tsk = Tsk.generate("Signer <signer@example.com>", profile=Profile.RFC9580)
wrong_tsk = Tsk.generate("Wrong <wrong@example.com>", profile=Profile.RFC9580)
wrong_pubkey = str(wrong_tsk.extract_certificate())

data = b"test data"
sig_bytes = sign(tsk.signer(), data, mode=SignatureMode.DETACHED)

sig_file = tmp_path / "sig.asc"
sig_file.write_bytes(sig_bytes)

data_file = tmp_path / "data.txt"
data_file.write_bytes(data)

with pytest.raises(InvalidSignatureError):
gpg_verify(wrong_pubkey, str(sig_file), detached_data=str(data_file))

def test_invalid_signature_data(self, tmp_path):
"""Test that invalid signature data raises InvalidSignatureError."""
tsk = Tsk.generate("Test <test@example.com>", profile=Profile.RFC9580)
pubkey = str(tsk.extract_certificate())

bad_sig_file = tmp_path / "bad_sig.asc"
bad_sig_file.write_bytes(b"not a valid signature")

data_file = tmp_path / "data.txt"
data_file.write_bytes(b"some data")

with pytest.raises(InvalidSignatureError):
gpg_verify(pubkey, str(bad_sig_file), detached_data=str(data_file))

def test_corrupted_signature(self, tmp_path):
"""Test that corrupted signature raises InvalidSignatureError."""
tsk = Tsk.generate("Test <test@example.com>", profile=Profile.RFC9580)
pubkey = str(tsk.extract_certificate())

data = b"test data"
sig_bytes = sign(tsk.signer(), data, mode=SignatureMode.DETACHED)

# Corrupt the signature
corrupted_sig = sig_bytes[:-20] + b"X" * 20

sig_file = tmp_path / "corrupt_sig.asc"
sig_file.write_bytes(corrupted_sig)

data_file = tmp_path / "data.txt"
data_file.write_bytes(data)

with pytest.raises(InvalidSignatureError):
gpg_verify(pubkey, str(sig_file), detached_data=str(data_file))
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -56,7 +56,7 @@ dependencies = [
"pygtrie>=2.5,<=2.5.0",
"psycopg[binary]>=3.3.4,<3.4", # SemVer, not explicitely stated, but mentioned on multiple changes.
"pyparsing>=3.1.0,<3.4", # Looks like only bugfixes in z-Stream.
"pysequoia>=0.1.33,<0.2",
"pysequoia>=0.1.35,<0.2.0",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we know about the specific versioning policy here?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It hasn't been the most consistent. Personally I think it ought to have been a 0.2.0 release given the major changes, but there were no breaking changes at least.

"PyYAML>=5.1.1,<6.1", # Looks like only bugfixes in z-Stream.
"redis>=4.3.0,<8.2", # Looks like only bugfixes in z-Stream.
"tablib>=3.5.0,<4.0, !=3.6", # 3.6.0 breaks with import export. Not sure about semver.
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' PQC support via new pysequoia version by dralley · Pull Request #8011 · pulp/pulpcore · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES/+post-quantum-crypto.feature
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
Pulpcore is now considered provisionally "post quantum cryptography" (PQC) compatible. PQC certificates are now supported for TLS, PGP operations, and signing services.
1 change: 1 addition & 0 deletions CHANGES/plugin_api/+pqc-verify.feature
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
`gpg_verify()` now supports post-quantum cryptography (PQC) algorithms.
23 changes: 23 additions & 0 deletions pulpcore/tests/functional/api/test_signing_service.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@

from pulpcore.pytest_plugin import (
KEY_V4_RSA4K_PRIVATE,
KEY_V6_MLDSA65_ED25519_PRIVATE,

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, OK, we still download the fixture keys here.

create_signing_service,
import_signing_key,
make_signing_script,
Expand DownExpand Up@@ -41,3 +42,25 @@ def test_add_signing_service_key_with_subkeys(backend, tmp_path_factory):
assert len(fingerprint) in (40, 64)

remove_signing_service(service_name)


def test_add_signing_service_pqc_key(tmp_path_factory):
"""Verify that add-signing-service works with PQC (ML-DSA) keys.

Post-quantum cryptographic keys using ML-DSA should be supported
for creating signing services using the Sequoia backend.
"""
home = tmp_path_factory.mktemp("pqc_mldsa_test")
script_dir = tmp_path_factory.mktemp("pqc_mldsa_script")

# PQC keys require Sequoia backend
_sq, fingerprint, _keyid = import_signing_key(
KEY_V6_MLDSA65_ED25519_PRIVATE, home, backend="sq"
)
script_path = make_signing_script(home, fingerprint, script_dir, backend="sq")
service_name = create_signing_service(home, fingerprint, script_path, backend="sq")

# v6 keys use 64-character fingerprints
assert len(fingerprint) == 64

remove_signing_service(service_name)
244 changes: 244 additions & 0 deletions pulpcore/tests/unit/test_gpg_verify.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
"""Unit tests for gpg_verify covering OpenPGP v4, v6, classical, and PQC algorithms."""

import pytest
from pysequoia import CipherSuite, Profile, SignatureMode, Tsk, sign

from pulpcore.app.util import VerifyResult, gpg_verify
from pulpcore.exceptions.validation import InvalidSignatureError

# Test key configurations: (name, key_generator, description)
TEST_KEYS = [
("v4_ed25519", (Profile.RFC4880, None), "OpenPGP v4 Ed25519"),
("v6_ed25519", (Profile.RFC9580, None), "OpenPGP v6 Ed25519"),
(
"v6_mldsa65_ed25519",
(Profile.RFC9580, CipherSuite.MLDSA65_Ed25519),
"OpenPGP v6 ML-DSA-65 + Ed25519 (PQC)",
),
]


@pytest.fixture(params=TEST_KEYS, ids=[k[0] for k in TEST_KEYS], scope="module")

@dralleydralleyAug 30, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think these tests have some value, because they do test our own API wrapper around pysequoia, but if you think they (or any individual test(s) are) more of a glorified pysequoia unit test, I can drop it.

@dralleydralleyAug 30, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternatively we can deprecate the gpg_verify function completely and remove it in the next breaking change release, because it's only a thin wrapper around pysequoia anyway, which plugins could just use directly if they wanted to.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, didn't we keep the interface of gpg_verify stable with this change? Then it already won. Why would we remove it?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It predated having any kind of usable library for this purpose - python-gnupg was kind of a pain to use because you had to set up ephemeral directories all the time - at this point the wrapper is probably not needed.

Of course we did keep the API stable anyway, but that doesn't mean we can't move away from it over the longer term. Question is just whether we ought to mark it deprecated or not.

def key_pair(request):
"""Generate an ephemeral keypair once per test configuration."""
name, key_generator, description = request.param
profile, cipher_suite = key_generator
tsk = Tsk.generate(
f"Test {name} <test-{name}@example.com>",
profile=profile,
cipher_suite=cipher_suite,
)
cert = tsk.extract_certificate()

return {
"name": name,
"description": description,
"tsk": tsk,
"pubkey": str(cert),
"fingerprint": cert.fingerprint,
}


@pytest.fixture
def detached_sig_fixture(key_pair, tmp_path):
"""Create a detached signature for a given key configuration."""
data = f"test data for {key_pair['description']}".encode()

# Sign with pysequoia
sig_bytes = sign(key_pair["tsk"].signer(), data, mode=SignatureMode.DETACHED)
sig_file = tmp_path / "sig.asc"
sig_file.write_bytes(sig_bytes)

data_file = tmp_path / "data.txt"
data_file.write_bytes(data)

return {
"pubkey": key_pair["pubkey"],
"sig_path": str(sig_file),
"data_path": str(data_file),
"fingerprint": key_pair["fingerprint"],
"data": data,
"description": key_pair["description"],
}


@pytest.fixture
def inline_sig_fixture(key_pair, tmp_path):
"""Create an inline signature for a given key configuration."""
data = f"inline signed data for {key_pair['description']}".encode()

# Sign with pysequoia
signed = sign(key_pair["tsk"].signer(), data)
sig_file = tmp_path / "inline_sig.pgp"
sig_file.write_bytes(signed)

return {
"pubkey": key_pair["pubkey"],
"sig_path": str(sig_file),
"fingerprint": key_pair["fingerprint"],
"data": data,
"description": key_pair["description"],
}


class TestGpgVerify:
"""Test gpg_verify across all key types."""

def test_detached_signature_valid(self, detached_sig_fixture):
"""Verify a valid detached signature for all key configurations."""
fixture = detached_sig_fixture

result = gpg_verify(
fixture["pubkey"],
fixture["sig_path"],
detached_data=fixture["data_path"],
)

assert isinstance(result, VerifyResult)
assert result.valid is True
# pubkey_fingerprint is the primary key, fingerprint is the signing subkey
assert result.pubkey_fingerprint.upper() == fixture["fingerprint"].upper()
assert result.key_id == result.fingerprint[-16:].upper()
assert result.data is None # detached signatures return None for data

def test_inline_signature_valid(self, inline_sig_fixture):
"""Verify inline signatures for all key configurations."""
fixture = inline_sig_fixture

result = gpg_verify(fixture["pubkey"], fixture["sig_path"])

assert isinstance(result, VerifyResult)
assert result.valid is True
assert result.pubkey_fingerprint.upper() == fixture["fingerprint"].upper()
assert result.key_id == result.fingerprint[-16:].upper()
assert result.data == fixture["data"]


class TestVerifyResultAPI:
"""Test VerifyResult API completeness."""

def test_verify_result_detached(self, tmp_path):
"""Test VerifyResult attributes for detached signatures."""
tsk = Tsk.generate("Test <test@example.com>", profile=Profile.RFC9580)
pubkey = str(tsk.extract_certificate())
fingerprint = tsk.extract_certificate().fingerprint

data = b"test data"
sig_bytes = sign(tsk.signer(), data, mode=SignatureMode.DETACHED)

sig_file = tmp_path / "sig.asc"
sig_file.write_bytes(sig_bytes)

data_file = tmp_path / "data.txt"
data_file.write_bytes(data)

result = gpg_verify(pubkey, str(sig_file), detached_data=str(data_file))

# Test all attributes
assert result.valid is True
assert isinstance(result.fingerprint, str)
assert len(result.fingerprint) in (40, 64)
assert isinstance(result.pubkey_fingerprint, str)
assert result.pubkey_fingerprint.upper() == fingerprint.upper()
assert isinstance(result.key_id, str)
assert result.key_id == result.fingerprint[-16:].upper()
assert result.data is None # detached signature

# Test repr
repr_str = repr(result)
assert "VerifyResult" in repr_str
assert "valid=True" in repr_str
assert "fingerprint=" in repr_str
assert "key_id=" in repr_str

def test_verify_result_inline(self, tmp_path):
"""Test that VerifyResult.data contains plaintext for inline signatures."""
tsk = Tsk.generate("Test <test@example.com>", profile=Profile.RFC9580)
pubkey = str(tsk.extract_certificate())

data = b"test data"
signed = sign(tsk.signer(), data)

sig_file = tmp_path / "inline_sig.pgp"
sig_file.write_bytes(signed)

result = gpg_verify(pubkey, str(sig_file))

# For inline signatures, data should contain the plaintext
assert result.valid is True
assert result.data is not None
assert isinstance(result.data, bytes)
assert result.data == data


class TestGpgVerifyErrorHandling:
"""Test gpg_verify error handling."""

def test_wrong_data(self, tmp_path):
"""Test that tampered data fails validation."""
tsk = Tsk.generate("Test <test@example.com>", profile=Profile.RFC9580)
pubkey = str(tsk.extract_certificate())

data = b"original data"
sig_bytes = sign(tsk.signer(), data, mode=SignatureMode.DETACHED)

sig_file = tmp_path / "sig.asc"
sig_file.write_bytes(sig_bytes)

tampered_file = tmp_path / "tampered.txt"
tampered_file.write_bytes(b"tampered data")

with pytest.raises(InvalidSignatureError):
gpg_verify(pubkey, str(sig_file), detached_data=str(tampered_file))

def test_wrong_key(self, tmp_path):
"""Test that wrong public key fails validation."""
tsk = Tsk.generate("Signer <signer@example.com>", profile=Profile.RFC9580)
wrong_tsk = Tsk.generate("Wrong <wrong@example.com>", profile=Profile.RFC9580)
wrong_pubkey = str(wrong_tsk.extract_certificate())

data = b"test data"
sig_bytes = sign(tsk.signer(), data, mode=SignatureMode.DETACHED)

sig_file = tmp_path / "sig.asc"
sig_file.write_bytes(sig_bytes)

data_file = tmp_path / "data.txt"
data_file.write_bytes(data)

with pytest.raises(InvalidSignatureError):
gpg_verify(wrong_pubkey, str(sig_file), detached_data=str(data_file))

def test_invalid_signature_data(self, tmp_path):
"""Test that invalid signature data raises InvalidSignatureError."""
tsk = Tsk.generate("Test <test@example.com>", profile=Profile.RFC9580)
pubkey = str(tsk.extract_certificate())

bad_sig_file = tmp_path / "bad_sig.asc"
bad_sig_file.write_bytes(b"not a valid signature")

data_file = tmp_path / "data.txt"
data_file.write_bytes(b"some data")

with pytest.raises(InvalidSignatureError):
gpg_verify(pubkey, str(bad_sig_file), detached_data=str(data_file))

def test_corrupted_signature(self, tmp_path):
"""Test that corrupted signature raises InvalidSignatureError."""
tsk = Tsk.generate("Test <test@example.com>", profile=Profile.RFC9580)
pubkey = str(tsk.extract_certificate())

data = b"test data"
sig_bytes = sign(tsk.signer(), data, mode=SignatureMode.DETACHED)

# Corrupt the signature
corrupted_sig = sig_bytes[:-20] + b"X" * 20

sig_file = tmp_path / "corrupt_sig.asc"
sig_file.write_bytes(corrupted_sig)

data_file = tmp_path / "data.txt"
data_file.write_bytes(data)

with pytest.raises(InvalidSignatureError):
gpg_verify(pubkey, str(sig_file), detached_data=str(data_file))
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -56,7 +56,7 @@ dependencies = [
"pygtrie>=2.5,<=2.5.0",
"psycopg[binary]>=3.3.4,<3.4", # SemVer, not explicitely stated, but mentioned on multiple changes.
"pyparsing>=3.1.0,<3.4", # Looks like only bugfixes in z-Stream.
"pysequoia>=0.1.33,<0.2",
"pysequoia>=0.1.35,<0.2.0",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we know about the specific versioning policy here?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It hasn't been the most consistent. Personally I think it ought to have been a 0.2.0 release given the major changes, but there were no breaking changes at least.

"PyYAML>=5.1.1,<6.1", # Looks like only bugfixes in z-Stream.
"redis>=4.3.0,<8.2", # Looks like only bugfixes in z-Stream.
"tablib>=3.5.0,<4.0, !=3.6", # 3.6.0 breaks with import export. Not sure about semver.
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' PQC support via new pysequoia version by dralley · Pull Request #8011 · pulp/pulpcore · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES/+post-quantum-crypto.feature
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
Pulpcore is now considered provisionally "post quantum cryptography" (PQC) compatible. PQC certificates are now supported for TLS, PGP operations, and signing services.
1 change: 1 addition & 0 deletions CHANGES/plugin_api/+pqc-verify.feature
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
`gpg_verify()` now supports post-quantum cryptography (PQC) algorithms.
23 changes: 23 additions & 0 deletions pulpcore/tests/functional/api/test_signing_service.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@

from pulpcore.pytest_plugin import (
KEY_V4_RSA4K_PRIVATE,
KEY_V6_MLDSA65_ED25519_PRIVATE,

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, OK, we still download the fixture keys here.

create_signing_service,
import_signing_key,
make_signing_script,
Expand DownExpand Up@@ -41,3 +42,25 @@ def test_add_signing_service_key_with_subkeys(backend, tmp_path_factory):
assert len(fingerprint) in (40, 64)

remove_signing_service(service_name)


def test_add_signing_service_pqc_key(tmp_path_factory):
"""Verify that add-signing-service works with PQC (ML-DSA) keys.

Post-quantum cryptographic keys using ML-DSA should be supported
for creating signing services using the Sequoia backend.
"""
home = tmp_path_factory.mktemp("pqc_mldsa_test")
script_dir = tmp_path_factory.mktemp("pqc_mldsa_script")

# PQC keys require Sequoia backend
_sq, fingerprint, _keyid = import_signing_key(
KEY_V6_MLDSA65_ED25519_PRIVATE, home, backend="sq"
)
script_path = make_signing_script(home, fingerprint, script_dir, backend="sq")
service_name = create_signing_service(home, fingerprint, script_path, backend="sq")

# v6 keys use 64-character fingerprints
assert len(fingerprint) == 64

remove_signing_service(service_name)
244 changes: 244 additions & 0 deletions pulpcore/tests/unit/test_gpg_verify.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
"""Unit tests for gpg_verify covering OpenPGP v4, v6, classical, and PQC algorithms."""

import pytest
from pysequoia import CipherSuite, Profile, SignatureMode, Tsk, sign

from pulpcore.app.util import VerifyResult, gpg_verify
from pulpcore.exceptions.validation import InvalidSignatureError

# Test key configurations: (name, key_generator, description)
TEST_KEYS = [
("v4_ed25519", (Profile.RFC4880, None), "OpenPGP v4 Ed25519"),
("v6_ed25519", (Profile.RFC9580, None), "OpenPGP v6 Ed25519"),
(
"v6_mldsa65_ed25519",
(Profile.RFC9580, CipherSuite.MLDSA65_Ed25519),
"OpenPGP v6 ML-DSA-65 + Ed25519 (PQC)",
),
]


@pytest.fixture(params=TEST_KEYS, ids=[k[0] for k in TEST_KEYS], scope="module")

@dralleydralleyAug 30, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think these tests have some value, because they do test our own API wrapper around pysequoia, but if you think they (or any individual test(s) are) more of a glorified pysequoia unit test, I can drop it.

@dralleydralleyAug 30, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternatively we can deprecate the gpg_verify function completely and remove it in the next breaking change release, because it's only a thin wrapper around pysequoia anyway, which plugins could just use directly if they wanted to.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, didn't we keep the interface of gpg_verify stable with this change? Then it already won. Why would we remove it?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It predated having any kind of usable library for this purpose - python-gnupg was kind of a pain to use because you had to set up ephemeral directories all the time - at this point the wrapper is probably not needed.

Of course we did keep the API stable anyway, but that doesn't mean we can't move away from it over the longer term. Question is just whether we ought to mark it deprecated or not.

def key_pair(request):
"""Generate an ephemeral keypair once per test configuration."""
name, key_generator, description = request.param
profile, cipher_suite = key_generator
tsk = Tsk.generate(
f"Test {name} <test-{name}@example.com>",
profile=profile,
cipher_suite=cipher_suite,
)
cert = tsk.extract_certificate()

return {
"name": name,
"description": description,
"tsk": tsk,
"pubkey": str(cert),
"fingerprint": cert.fingerprint,
}


@pytest.fixture
def detached_sig_fixture(key_pair, tmp_path):
"""Create a detached signature for a given key configuration."""
data = f"test data for {key_pair['description']}".encode()

# Sign with pysequoia
sig_bytes = sign(key_pair["tsk"].signer(), data, mode=SignatureMode.DETACHED)
sig_file = tmp_path / "sig.asc"
sig_file.write_bytes(sig_bytes)

data_file = tmp_path / "data.txt"
data_file.write_bytes(data)

return {
"pubkey": key_pair["pubkey"],
"sig_path": str(sig_file),
"data_path": str(data_file),
"fingerprint": key_pair["fingerprint"],
"data": data,
"description": key_pair["description"],
}


@pytest.fixture
def inline_sig_fixture(key_pair, tmp_path):
"""Create an inline signature for a given key configuration."""
data = f"inline signed data for {key_pair['description']}".encode()

# Sign with pysequoia
signed = sign(key_pair["tsk"].signer(), data)
sig_file = tmp_path / "inline_sig.pgp"
sig_file.write_bytes(signed)

return {
"pubkey": key_pair["pubkey"],
"sig_path": str(sig_file),
"fingerprint": key_pair["fingerprint"],
"data": data,
"description": key_pair["description"],
}


class TestGpgVerify:
"""Test gpg_verify across all key types."""

def test_detached_signature_valid(self, detached_sig_fixture):
"""Verify a valid detached signature for all key configurations."""
fixture = detached_sig_fixture

result = gpg_verify(
fixture["pubkey"],
fixture["sig_path"],
detached_data=fixture["data_path"],
)

assert isinstance(result, VerifyResult)
assert result.valid is True
# pubkey_fingerprint is the primary key, fingerprint is the signing subkey
assert result.pubkey_fingerprint.upper() == fixture["fingerprint"].upper()
assert result.key_id == result.fingerprint[-16:].upper()
assert result.data is None # detached signatures return None for data

def test_inline_signature_valid(self, inline_sig_fixture):
"""Verify inline signatures for all key configurations."""
fixture = inline_sig_fixture

result = gpg_verify(fixture["pubkey"], fixture["sig_path"])

assert isinstance(result, VerifyResult)
assert result.valid is True
assert result.pubkey_fingerprint.upper() == fixture["fingerprint"].upper()
assert result.key_id == result.fingerprint[-16:].upper()
assert result.data == fixture["data"]


class TestVerifyResultAPI:
"""Test VerifyResult API completeness."""

def test_verify_result_detached(self, tmp_path):
"""Test VerifyResult attributes for detached signatures."""
tsk = Tsk.generate("Test <test@example.com>", profile=Profile.RFC9580)
pubkey = str(tsk.extract_certificate())
fingerprint = tsk.extract_certificate().fingerprint

data = b"test data"
sig_bytes = sign(tsk.signer(), data, mode=SignatureMode.DETACHED)

sig_file = tmp_path / "sig.asc"
sig_file.write_bytes(sig_bytes)

data_file = tmp_path / "data.txt"
data_file.write_bytes(data)

result = gpg_verify(pubkey, str(sig_file), detached_data=str(data_file))

# Test all attributes
assert result.valid is True
assert isinstance(result.fingerprint, str)
assert len(result.fingerprint) in (40, 64)
assert isinstance(result.pubkey_fingerprint, str)
assert result.pubkey_fingerprint.upper() == fingerprint.upper()
assert isinstance(result.key_id, str)
assert result.key_id == result.fingerprint[-16:].upper()
assert result.data is None # detached signature

# Test repr
repr_str = repr(result)
assert "VerifyResult" in repr_str
assert "valid=True" in repr_str
assert "fingerprint=" in repr_str
assert "key_id=" in repr_str

def test_verify_result_inline(self, tmp_path):
"""Test that VerifyResult.data contains plaintext for inline signatures."""
tsk = Tsk.generate("Test <test@example.com>", profile=Profile.RFC9580)
pubkey = str(tsk.extract_certificate())

data = b"test data"
signed = sign(tsk.signer(), data)

sig_file = tmp_path / "inline_sig.pgp"
sig_file.write_bytes(signed)

result = gpg_verify(pubkey, str(sig_file))

# For inline signatures, data should contain the plaintext
assert result.valid is True
assert result.data is not None
assert isinstance(result.data, bytes)
assert result.data == data


class TestGpgVerifyErrorHandling:
"""Test gpg_verify error handling."""

def test_wrong_data(self, tmp_path):
"""Test that tampered data fails validation."""
tsk = Tsk.generate("Test <test@example.com>", profile=Profile.RFC9580)
pubkey = str(tsk.extract_certificate())

data = b"original data"
sig_bytes = sign(tsk.signer(), data, mode=SignatureMode.DETACHED)

sig_file = tmp_path / "sig.asc"
sig_file.write_bytes(sig_bytes)

tampered_file = tmp_path / "tampered.txt"
tampered_file.write_bytes(b"tampered data")

with pytest.raises(InvalidSignatureError):
gpg_verify(pubkey, str(sig_file), detached_data=str(tampered_file))

def test_wrong_key(self, tmp_path):
"""Test that wrong public key fails validation."""
tsk = Tsk.generate("Signer <signer@example.com>", profile=Profile.RFC9580)
wrong_tsk = Tsk.generate("Wrong <wrong@example.com>", profile=Profile.RFC9580)
wrong_pubkey = str(wrong_tsk.extract_certificate())

data = b"test data"
sig_bytes = sign(tsk.signer(), data, mode=SignatureMode.DETACHED)

sig_file = tmp_path / "sig.asc"
sig_file.write_bytes(sig_bytes)

data_file = tmp_path / "data.txt"
data_file.write_bytes(data)

with pytest.raises(InvalidSignatureError):
gpg_verify(wrong_pubkey, str(sig_file), detached_data=str(data_file))

def test_invalid_signature_data(self, tmp_path):
"""Test that invalid signature data raises InvalidSignatureError."""
tsk = Tsk.generate("Test <test@example.com>", profile=Profile.RFC9580)
pubkey = str(tsk.extract_certificate())

bad_sig_file = tmp_path / "bad_sig.asc"
bad_sig_file.write_bytes(b"not a valid signature")

data_file = tmp_path / "data.txt"
data_file.write_bytes(b"some data")

with pytest.raises(InvalidSignatureError):
gpg_verify(pubkey, str(bad_sig_file), detached_data=str(data_file))

def test_corrupted_signature(self, tmp_path):
"""Test that corrupted signature raises InvalidSignatureError."""
tsk = Tsk.generate("Test <test@example.com>", profile=Profile.RFC9580)
pubkey = str(tsk.extract_certificate())

data = b"test data"
sig_bytes = sign(tsk.signer(), data, mode=SignatureMode.DETACHED)

# Corrupt the signature
corrupted_sig = sig_bytes[:-20] + b"X" * 20

sig_file = tmp_path / "corrupt_sig.asc"
sig_file.write_bytes(corrupted_sig)

data_file = tmp_path / "data.txt"
data_file.write_bytes(data)

with pytest.raises(InvalidSignatureError):
gpg_verify(pubkey, str(sig_file), detached_data=str(data_file))
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -56,7 +56,7 @@ dependencies = [
"pygtrie>=2.5,<=2.5.0",
"psycopg[binary]>=3.3.4,<3.4", # SemVer, not explicitely stated, but mentioned on multiple changes.
"pyparsing>=3.1.0,<3.4", # Looks like only bugfixes in z-Stream.
"pysequoia>=0.1.33,<0.2",
"pysequoia>=0.1.35,<0.2.0",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we know about the specific versioning policy here?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It hasn't been the most consistent. Personally I think it ought to have been a 0.2.0 release given the major changes, but there were no breaking changes at least.

"PyYAML>=5.1.1,<6.1", # Looks like only bugfixes in z-Stream.
"redis>=4.3.0,<8.2", # Looks like only bugfixes in z-Stream.
"tablib>=3.5.0,<4.0, !=3.6", # 3.6.0 breaks with import export. Not sure about semver.
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' PQC support via new pysequoia version by dralley · Pull Request #8011 · pulp/pulpcore · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES/+post-quantum-crypto.feature
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
Pulpcore is now considered provisionally "post quantum cryptography" (PQC) compatible. PQC certificates are now supported for TLS, PGP operations, and signing services.
1 change: 1 addition & 0 deletions CHANGES/plugin_api/+pqc-verify.feature
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
`gpg_verify()` now supports post-quantum cryptography (PQC) algorithms.
23 changes: 23 additions & 0 deletions pulpcore/tests/functional/api/test_signing_service.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@

from pulpcore.pytest_plugin import (
KEY_V4_RSA4K_PRIVATE,
KEY_V6_MLDSA65_ED25519_PRIVATE,

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, OK, we still download the fixture keys here.

create_signing_service,
import_signing_key,
make_signing_script,
Expand DownExpand Up@@ -41,3 +42,25 @@ def test_add_signing_service_key_with_subkeys(backend, tmp_path_factory):
assert len(fingerprint) in (40, 64)

remove_signing_service(service_name)


def test_add_signing_service_pqc_key(tmp_path_factory):
"""Verify that add-signing-service works with PQC (ML-DSA) keys.

Post-quantum cryptographic keys using ML-DSA should be supported
for creating signing services using the Sequoia backend.
"""
home = tmp_path_factory.mktemp("pqc_mldsa_test")
script_dir = tmp_path_factory.mktemp("pqc_mldsa_script")

# PQC keys require Sequoia backend
_sq, fingerprint, _keyid = import_signing_key(
KEY_V6_MLDSA65_ED25519_PRIVATE, home, backend="sq"
)
script_path = make_signing_script(home, fingerprint, script_dir, backend="sq")
service_name = create_signing_service(home, fingerprint, script_path, backend="sq")

# v6 keys use 64-character fingerprints
assert len(fingerprint) == 64

remove_signing_service(service_name)
244 changes: 244 additions & 0 deletions pulpcore/tests/unit/test_gpg_verify.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
"""Unit tests for gpg_verify covering OpenPGP v4, v6, classical, and PQC algorithms."""

import pytest
from pysequoia import CipherSuite, Profile, SignatureMode, Tsk, sign

from pulpcore.app.util import VerifyResult, gpg_verify
from pulpcore.exceptions.validation import InvalidSignatureError

# Test key configurations: (name, key_generator, description)
TEST_KEYS = [
("v4_ed25519", (Profile.RFC4880, None), "OpenPGP v4 Ed25519"),
("v6_ed25519", (Profile.RFC9580, None), "OpenPGP v6 Ed25519"),
(
"v6_mldsa65_ed25519",
(Profile.RFC9580, CipherSuite.MLDSA65_Ed25519),
"OpenPGP v6 ML-DSA-65 + Ed25519 (PQC)",
),
]


@pytest.fixture(params=TEST_KEYS, ids=[k[0] for k in TEST_KEYS], scope="module")

@dralleydralleyAug 30, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think these tests have some value, because they do test our own API wrapper around pysequoia, but if you think they (or any individual test(s) are) more of a glorified pysequoia unit test, I can drop it.

@dralleydralleyAug 30, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternatively we can deprecate the gpg_verify function completely and remove it in the next breaking change release, because it's only a thin wrapper around pysequoia anyway, which plugins could just use directly if they wanted to.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, didn't we keep the interface of gpg_verify stable with this change? Then it already won. Why would we remove it?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It predated having any kind of usable library for this purpose - python-gnupg was kind of a pain to use because you had to set up ephemeral directories all the time - at this point the wrapper is probably not needed.

Of course we did keep the API stable anyway, but that doesn't mean we can't move away from it over the longer term. Question is just whether we ought to mark it deprecated or not.

def key_pair(request):
"""Generate an ephemeral keypair once per test configuration."""
name, key_generator, description = request.param
profile, cipher_suite = key_generator
tsk = Tsk.generate(
f"Test {name} <test-{name}@example.com>",
profile=profile,
cipher_suite=cipher_suite,
)
cert = tsk.extract_certificate()

return {
"name": name,
"description": description,
"tsk": tsk,
"pubkey": str(cert),
"fingerprint": cert.fingerprint,
}


@pytest.fixture
def detached_sig_fixture(key_pair, tmp_path):
"""Create a detached signature for a given key configuration."""
data = f"test data for {key_pair['description']}".encode()

# Sign with pysequoia
sig_bytes = sign(key_pair["tsk"].signer(), data, mode=SignatureMode.DETACHED)
sig_file = tmp_path / "sig.asc"
sig_file.write_bytes(sig_bytes)

data_file = tmp_path / "data.txt"
data_file.write_bytes(data)

return {
"pubkey": key_pair["pubkey"],
"sig_path": str(sig_file),
"data_path": str(data_file),
"fingerprint": key_pair["fingerprint"],
"data": data,
"description": key_pair["description"],
}


@pytest.fixture
def inline_sig_fixture(key_pair, tmp_path):
"""Create an inline signature for a given key configuration."""
data = f"inline signed data for {key_pair['description']}".encode()

# Sign with pysequoia
signed = sign(key_pair["tsk"].signer(), data)
sig_file = tmp_path / "inline_sig.pgp"
sig_file.write_bytes(signed)

return {
"pubkey": key_pair["pubkey"],
"sig_path": str(sig_file),
"fingerprint": key_pair["fingerprint"],
"data": data,
"description": key_pair["description"],
}


class TestGpgVerify:
"""Test gpg_verify across all key types."""

def test_detached_signature_valid(self, detached_sig_fixture):
"""Verify a valid detached signature for all key configurations."""
fixture = detached_sig_fixture

result = gpg_verify(
fixture["pubkey"],
fixture["sig_path"],
detached_data=fixture["data_path"],
)

assert isinstance(result, VerifyResult)
assert result.valid is True
# pubkey_fingerprint is the primary key, fingerprint is the signing subkey
assert result.pubkey_fingerprint.upper() == fixture["fingerprint"].upper()
assert result.key_id == result.fingerprint[-16:].upper()
assert result.data is None # detached signatures return None for data

def test_inline_signature_valid(self, inline_sig_fixture):
"""Verify inline signatures for all key configurations."""
fixture = inline_sig_fixture

result = gpg_verify(fixture["pubkey"], fixture["sig_path"])

assert isinstance(result, VerifyResult)
assert result.valid is True
assert result.pubkey_fingerprint.upper() == fixture["fingerprint"].upper()
assert result.key_id == result.fingerprint[-16:].upper()
assert result.data == fixture["data"]


class TestVerifyResultAPI:
"""Test VerifyResult API completeness."""

def test_verify_result_detached(self, tmp_path):
"""Test VerifyResult attributes for detached signatures."""
tsk = Tsk.generate("Test <test@example.com>", profile=Profile.RFC9580)
pubkey = str(tsk.extract_certificate())
fingerprint = tsk.extract_certificate().fingerprint

data = b"test data"
sig_bytes = sign(tsk.signer(), data, mode=SignatureMode.DETACHED)

sig_file = tmp_path / "sig.asc"
sig_file.write_bytes(sig_bytes)

data_file = tmp_path / "data.txt"
data_file.write_bytes(data)

result = gpg_verify(pubkey, str(sig_file), detached_data=str(data_file))

# Test all attributes
assert result.valid is True
assert isinstance(result.fingerprint, str)
assert len(result.fingerprint) in (40, 64)
assert isinstance(result.pubkey_fingerprint, str)
assert result.pubkey_fingerprint.upper() == fingerprint.upper()
assert isinstance(result.key_id, str)
assert result.key_id == result.fingerprint[-16:].upper()
assert result.data is None # detached signature

# Test repr
repr_str = repr(result)
assert "VerifyResult" in repr_str
assert "valid=True" in repr_str
assert "fingerprint=" in repr_str
assert "key_id=" in repr_str

def test_verify_result_inline(self, tmp_path):
"""Test that VerifyResult.data contains plaintext for inline signatures."""
tsk = Tsk.generate("Test <test@example.com>", profile=Profile.RFC9580)
pubkey = str(tsk.extract_certificate())

data = b"test data"
signed = sign(tsk.signer(), data)

sig_file = tmp_path / "inline_sig.pgp"
sig_file.write_bytes(signed)

result = gpg_verify(pubkey, str(sig_file))

# For inline signatures, data should contain the plaintext
assert result.valid is True
assert result.data is not None
assert isinstance(result.data, bytes)
assert result.data == data


class TestGpgVerifyErrorHandling:
"""Test gpg_verify error handling."""

def test_wrong_data(self, tmp_path):
"""Test that tampered data fails validation."""
tsk = Tsk.generate("Test <test@example.com>", profile=Profile.RFC9580)
pubkey = str(tsk.extract_certificate())

data = b"original data"
sig_bytes = sign(tsk.signer(), data, mode=SignatureMode.DETACHED)

sig_file = tmp_path / "sig.asc"
sig_file.write_bytes(sig_bytes)

tampered_file = tmp_path / "tampered.txt"
tampered_file.write_bytes(b"tampered data")

with pytest.raises(InvalidSignatureError):
gpg_verify(pubkey, str(sig_file), detached_data=str(tampered_file))

def test_wrong_key(self, tmp_path):
"""Test that wrong public key fails validation."""
tsk = Tsk.generate("Signer <signer@example.com>", profile=Profile.RFC9580)
wrong_tsk = Tsk.generate("Wrong <wrong@example.com>", profile=Profile.RFC9580)
wrong_pubkey = str(wrong_tsk.extract_certificate())

data = b"test data"
sig_bytes = sign(tsk.signer(), data, mode=SignatureMode.DETACHED)

sig_file = tmp_path / "sig.asc"
sig_file.write_bytes(sig_bytes)

data_file = tmp_path / "data.txt"
data_file.write_bytes(data)

with pytest.raises(InvalidSignatureError):
gpg_verify(wrong_pubkey, str(sig_file), detached_data=str(data_file))

def test_invalid_signature_data(self, tmp_path):
"""Test that invalid signature data raises InvalidSignatureError."""
tsk = Tsk.generate("Test <test@example.com>", profile=Profile.RFC9580)
pubkey = str(tsk.extract_certificate())

bad_sig_file = tmp_path / "bad_sig.asc"
bad_sig_file.write_bytes(b"not a valid signature")

data_file = tmp_path / "data.txt"
data_file.write_bytes(b"some data")

with pytest.raises(InvalidSignatureError):
gpg_verify(pubkey, str(bad_sig_file), detached_data=str(data_file))

def test_corrupted_signature(self, tmp_path):
"""Test that corrupted signature raises InvalidSignatureError."""
tsk = Tsk.generate("Test <test@example.com>", profile=Profile.RFC9580)
pubkey = str(tsk.extract_certificate())

data = b"test data"
sig_bytes = sign(tsk.signer(), data, mode=SignatureMode.DETACHED)

# Corrupt the signature
corrupted_sig = sig_bytes[:-20] + b"X" * 20

sig_file = tmp_path / "corrupt_sig.asc"
sig_file.write_bytes(corrupted_sig)

data_file = tmp_path / "data.txt"
data_file.write_bytes(data)

with pytest.raises(InvalidSignatureError):
gpg_verify(pubkey, str(sig_file), detached_data=str(data_file))
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -56,7 +56,7 @@ dependencies = [
"pygtrie>=2.5,<=2.5.0",
"psycopg[binary]>=3.3.4,<3.4", # SemVer, not explicitely stated, but mentioned on multiple changes.
"pyparsing>=3.1.0,<3.4", # Looks like only bugfixes in z-Stream.
"pysequoia>=0.1.33,<0.2",
"pysequoia>=0.1.35,<0.2.0",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we know about the specific versioning policy here?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It hasn't been the most consistent. Personally I think it ought to have been a 0.2.0 release given the major changes, but there were no breaking changes at least.

"PyYAML>=5.1.1,<6.1", # Looks like only bugfixes in z-Stream.
"redis>=4.3.0,<8.2", # Looks like only bugfixes in z-Stream.
"tablib>=3.5.0,<4.0, !=3.6", # 3.6.0 breaks with import export. Not sure about semver.
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); PQC support via new pysequoia version by dralley · Pull Request #8011 · pulp/pulpcore · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES/+post-quantum-crypto.feature
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
Pulpcore is now considered provisionally "post quantum cryptography" (PQC) compatible. PQC certificates are now supported for TLS, PGP operations, and signing services.
1 change: 1 addition & 0 deletions CHANGES/plugin_api/+pqc-verify.feature
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
`gpg_verify()` now supports post-quantum cryptography (PQC) algorithms.
23 changes: 23 additions & 0 deletions pulpcore/tests/functional/api/test_signing_service.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@

from pulpcore.pytest_plugin import (
KEY_V4_RSA4K_PRIVATE,
KEY_V6_MLDSA65_ED25519_PRIVATE,

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, OK, we still download the fixture keys here.

create_signing_service,
import_signing_key,
make_signing_script,
Expand DownExpand Up@@ -41,3 +42,25 @@ def test_add_signing_service_key_with_subkeys(backend, tmp_path_factory):
assert len(fingerprint) in (40, 64)

remove_signing_service(service_name)


def test_add_signing_service_pqc_key(tmp_path_factory):
"""Verify that add-signing-service works with PQC (ML-DSA) keys.

Post-quantum cryptographic keys using ML-DSA should be supported
for creating signing services using the Sequoia backend.
"""
home = tmp_path_factory.mktemp("pqc_mldsa_test")
script_dir = tmp_path_factory.mktemp("pqc_mldsa_script")

# PQC keys require Sequoia backend
_sq, fingerprint, _keyid = import_signing_key(
KEY_V6_MLDSA65_ED25519_PRIVATE, home, backend="sq"
)
script_path = make_signing_script(home, fingerprint, script_dir, backend="sq")
service_name = create_signing_service(home, fingerprint, script_path, backend="sq")

# v6 keys use 64-character fingerprints
assert len(fingerprint) == 64

remove_signing_service(service_name)
244 changes: 244 additions & 0 deletions pulpcore/tests/unit/test_gpg_verify.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
"""Unit tests for gpg_verify covering OpenPGP v4, v6, classical, and PQC algorithms."""

import pytest
from pysequoia import CipherSuite, Profile, SignatureMode, Tsk, sign

from pulpcore.app.util import VerifyResult, gpg_verify
from pulpcore.exceptions.validation import InvalidSignatureError

# Test key configurations: (name, key_generator, description)
TEST_KEYS = [
("v4_ed25519", (Profile.RFC4880, None), "OpenPGP v4 Ed25519"),
("v6_ed25519", (Profile.RFC9580, None), "OpenPGP v6 Ed25519"),
(
"v6_mldsa65_ed25519",
(Profile.RFC9580, CipherSuite.MLDSA65_Ed25519),
"OpenPGP v6 ML-DSA-65 + Ed25519 (PQC)",
),
]


@pytest.fixture(params=TEST_KEYS, ids=[k[0] for k in TEST_KEYS], scope="module")

@dralleydralleyAug 30, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think these tests have some value, because they do test our own API wrapper around pysequoia, but if you think they (or any individual test(s) are) more of a glorified pysequoia unit test, I can drop it.

@dralleydralleyAug 30, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternatively we can deprecate the gpg_verify function completely and remove it in the next breaking change release, because it's only a thin wrapper around pysequoia anyway, which plugins could just use directly if they wanted to.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, didn't we keep the interface of gpg_verify stable with this change? Then it already won. Why would we remove it?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It predated having any kind of usable library for this purpose - python-gnupg was kind of a pain to use because you had to set up ephemeral directories all the time - at this point the wrapper is probably not needed.

Of course we did keep the API stable anyway, but that doesn't mean we can't move away from it over the longer term. Question is just whether we ought to mark it deprecated or not.

def key_pair(request):
"""Generate an ephemeral keypair once per test configuration."""
name, key_generator, description = request.param
profile, cipher_suite = key_generator
tsk = Tsk.generate(
f"Test {name} <test-{name}@example.com>",
profile=profile,
cipher_suite=cipher_suite,
)
cert = tsk.extract_certificate()

return {
"name": name,
"description": description,
"tsk": tsk,
"pubkey": str(cert),
"fingerprint": cert.fingerprint,
}


@pytest.fixture
def detached_sig_fixture(key_pair, tmp_path):
"""Create a detached signature for a given key configuration."""
data = f"test data for {key_pair['description']}".encode()

# Sign with pysequoia
sig_bytes = sign(key_pair["tsk"].signer(), data, mode=SignatureMode.DETACHED)
sig_file = tmp_path / "sig.asc"
sig_file.write_bytes(sig_bytes)

data_file = tmp_path / "data.txt"
data_file.write_bytes(data)

return {
"pubkey": key_pair["pubkey"],
"sig_path": str(sig_file),
"data_path": str(data_file),
"fingerprint": key_pair["fingerprint"],
"data": data,
"description": key_pair["description"],
}


@pytest.fixture
def inline_sig_fixture(key_pair, tmp_path):
"""Create an inline signature for a given key configuration."""
data = f"inline signed data for {key_pair['description']}".encode()

# Sign with pysequoia
signed = sign(key_pair["tsk"].signer(), data)
sig_file = tmp_path / "inline_sig.pgp"
sig_file.write_bytes(signed)

return {
"pubkey": key_pair["pubkey"],
"sig_path": str(sig_file),
"fingerprint": key_pair["fingerprint"],
"data": data,
"description": key_pair["description"],
}


class TestGpgVerify:
"""Test gpg_verify across all key types."""

def test_detached_signature_valid(self, detached_sig_fixture):
"""Verify a valid detached signature for all key configurations."""
fixture = detached_sig_fixture

result = gpg_verify(
fixture["pubkey"],
fixture["sig_path"],
detached_data=fixture["data_path"],
)

assert isinstance(result, VerifyResult)
assert result.valid is True
# pubkey_fingerprint is the primary key, fingerprint is the signing subkey
assert result.pubkey_fingerprint.upper() == fixture["fingerprint"].upper()
assert result.key_id == result.fingerprint[-16:].upper()
assert result.data is None # detached signatures return None for data

def test_inline_signature_valid(self, inline_sig_fixture):
"""Verify inline signatures for all key configurations."""
fixture = inline_sig_fixture

result = gpg_verify(fixture["pubkey"], fixture["sig_path"])

assert isinstance(result, VerifyResult)
assert result.valid is True
assert result.pubkey_fingerprint.upper() == fixture["fingerprint"].upper()
assert result.key_id == result.fingerprint[-16:].upper()
assert result.data == fixture["data"]


class TestVerifyResultAPI:
"""Test VerifyResult API completeness."""

def test_verify_result_detached(self, tmp_path):
"""Test VerifyResult attributes for detached signatures."""
tsk = Tsk.generate("Test <test@example.com>", profile=Profile.RFC9580)
pubkey = str(tsk.extract_certificate())
fingerprint = tsk.extract_certificate().fingerprint

data = b"test data"
sig_bytes = sign(tsk.signer(), data, mode=SignatureMode.DETACHED)

sig_file = tmp_path / "sig.asc"
sig_file.write_bytes(sig_bytes)

data_file = tmp_path / "data.txt"
data_file.write_bytes(data)

result = gpg_verify(pubkey, str(sig_file), detached_data=str(data_file))

# Test all attributes
assert result.valid is True
assert isinstance(result.fingerprint, str)
assert len(result.fingerprint) in (40, 64)
assert isinstance(result.pubkey_fingerprint, str)
assert result.pubkey_fingerprint.upper() == fingerprint.upper()
assert isinstance(result.key_id, str)
assert result.key_id == result.fingerprint[-16:].upper()
assert result.data is None # detached signature

# Test repr
repr_str = repr(result)
assert "VerifyResult" in repr_str
assert "valid=True" in repr_str
assert "fingerprint=" in repr_str
assert "key_id=" in repr_str

def test_verify_result_inline(self, tmp_path):
"""Test that VerifyResult.data contains plaintext for inline signatures."""
tsk = Tsk.generate("Test <test@example.com>", profile=Profile.RFC9580)
pubkey = str(tsk.extract_certificate())

data = b"test data"
signed = sign(tsk.signer(), data)

sig_file = tmp_path / "inline_sig.pgp"
sig_file.write_bytes(signed)

result = gpg_verify(pubkey, str(sig_file))

# For inline signatures, data should contain the plaintext
assert result.valid is True
assert result.data is not None
assert isinstance(result.data, bytes)
assert result.data == data


class TestGpgVerifyErrorHandling:
"""Test gpg_verify error handling."""

def test_wrong_data(self, tmp_path):
"""Test that tampered data fails validation."""
tsk = Tsk.generate("Test <test@example.com>", profile=Profile.RFC9580)
pubkey = str(tsk.extract_certificate())

data = b"original data"
sig_bytes = sign(tsk.signer(), data, mode=SignatureMode.DETACHED)

sig_file = tmp_path / "sig.asc"
sig_file.write_bytes(sig_bytes)

tampered_file = tmp_path / "tampered.txt"
tampered_file.write_bytes(b"tampered data")

with pytest.raises(InvalidSignatureError):
gpg_verify(pubkey, str(sig_file), detached_data=str(tampered_file))

def test_wrong_key(self, tmp_path):
"""Test that wrong public key fails validation."""
tsk = Tsk.generate("Signer <signer@example.com>", profile=Profile.RFC9580)
wrong_tsk = Tsk.generate("Wrong <wrong@example.com>", profile=Profile.RFC9580)
wrong_pubkey = str(wrong_tsk.extract_certificate())

data = b"test data"
sig_bytes = sign(tsk.signer(), data, mode=SignatureMode.DETACHED)

sig_file = tmp_path / "sig.asc"
sig_file.write_bytes(sig_bytes)

data_file = tmp_path / "data.txt"
data_file.write_bytes(data)

with pytest.raises(InvalidSignatureError):
gpg_verify(wrong_pubkey, str(sig_file), detached_data=str(data_file))

def test_invalid_signature_data(self, tmp_path):
"""Test that invalid signature data raises InvalidSignatureError."""
tsk = Tsk.generate("Test <test@example.com>", profile=Profile.RFC9580)
pubkey = str(tsk.extract_certificate())

bad_sig_file = tmp_path / "bad_sig.asc"
bad_sig_file.write_bytes(b"not a valid signature")

data_file = tmp_path / "data.txt"
data_file.write_bytes(b"some data")

with pytest.raises(InvalidSignatureError):
gpg_verify(pubkey, str(bad_sig_file), detached_data=str(data_file))

def test_corrupted_signature(self, tmp_path):
"""Test that corrupted signature raises InvalidSignatureError."""
tsk = Tsk.generate("Test <test@example.com>", profile=Profile.RFC9580)
pubkey = str(tsk.extract_certificate())

data = b"test data"
sig_bytes = sign(tsk.signer(), data, mode=SignatureMode.DETACHED)

# Corrupt the signature
corrupted_sig = sig_bytes[:-20] + b"X" * 20

sig_file = tmp_path / "corrupt_sig.asc"
sig_file.write_bytes(corrupted_sig)

data_file = tmp_path / "data.txt"
data_file.write_bytes(data)

with pytest.raises(InvalidSignatureError):
gpg_verify(pubkey, str(sig_file), detached_data=str(data_file))
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -56,7 +56,7 @@ dependencies = [
"pygtrie>=2.5,<=2.5.0",
"psycopg[binary]>=3.3.4,<3.4", # SemVer, not explicitely stated, but mentioned on multiple changes.
"pyparsing>=3.1.0,<3.4", # Looks like only bugfixes in z-Stream.
"pysequoia>=0.1.33,<0.2",
"pysequoia>=0.1.35,<0.2.0",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we know about the specific versioning policy here?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It hasn't been the most consistent. Personally I think it ought to have been a 0.2.0 release given the major changes, but there were no breaking changes at least.

"PyYAML>=5.1.1,<6.1", # Looks like only bugfixes in z-Stream.
"redis>=4.3.0,<8.2", # Looks like only bugfixes in z-Stream.
"tablib>=3.5.0,<4.0, !=3.6", # 3.6.0 breaks with import export. Not sure about semver.
Expand Down
Loading