diff --git a/.justfile b/.justfile index 62d4850..1714977 100755 --- a/.justfile +++ b/.justfile @@ -260,7 +260,7 @@ update-stubs: # Check types in the README [metadata('pacman', 'mypy', 'python', 'rust', 'tangler')] check-types: update-stubs - tangler python < README.md > README.py + tangler --preserve-newlines python < README.md > README.py mypy --config-file pyproject.toml README.py # Generate stubs and check types diff --git a/README.md b/README.md index 140fe52..2417407 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ gpg --batch --pinentry-mode loopback --passphrase '' --export-secret-key no-pass All examples assume that these basic classes have been imported: ```python -from pysequoia import Cert, Sig +from pysequoia import Cert, Sig, Tsk ``` ### sign @@ -74,20 +74,18 @@ Signs data and returns armored output: ```python from pysequoia import sign, SignatureMode -s = Cert.from_file("tests/fixtures/signing-key.asc") -signed = sign(s.secrets.signer(), "data to be signed".encode("utf8")) +s = Tsk.from_file("tests/fixtures/signing-key.asc") +signed = sign(s.signer(), "data to be signed".encode("utf8")) print(f"Signed data: {signed!r}") assert "PGP MESSAGE" in str(signed) detached = sign( - s.secrets.signer(), "data to be signed".encode("utf8"), mode=SignatureMode.DETACHED + s.signer(), "data to be signed".encode("utf8"), mode=SignatureMode.DETACHED ) print(f"Detached signature: {detached!r}") assert "PGP SIGNATURE" in str(detached) -clear = sign( - s.secrets.signer(), "data to be signed".encode("utf8"), mode=SignatureMode.CLEAR -) +clear = sign(s.signer(), "data to be signed".encode("utf8"), mode=SignatureMode.CLEAR) print(f"Clear signed: {clear!r}") assert "PGP SIGNED MESSAGE" in str(clear) ``` @@ -100,7 +98,7 @@ Signs data from a file and writes the signed output to another file: from pysequoia import sign_file, SignatureMode import tempfile, os -s = Cert.from_file("tests/fixtures/signing-key.asc") +s = Tsk.from_file("tests/fixtures/signing-key.asc") # create a file with data to sign with tempfile.NamedTemporaryFile(delete=False, suffix=".txt") as inp: @@ -110,7 +108,7 @@ with tempfile.NamedTemporaryFile(delete=False, suffix=".txt") as inp: with tempfile.NamedTemporaryFile(delete=False, suffix=".pgp") as out: output_path = out.name -sign_file(s.secrets.signer(), input_path, output_path) +sign_file(s.signer(), input_path, output_path) signed = open(output_path, "rb").read() assert b"PGP MESSAGE" in signed @@ -118,7 +116,7 @@ assert b"PGP MESSAGE" in signed with tempfile.NamedTemporaryFile(delete=False, suffix=".sig") as out: detached_path = out.name -sign_file(s.secrets.signer(), input_path, detached_path, mode=SignatureMode.DETACHED) +sign_file(s.signer(), input_path, detached_path, mode=SignatureMode.DETACHED) detached = open(detached_path, "rb").read() assert b"PGP SIGNATURE" in detached @@ -135,14 +133,14 @@ Verifies signed data and returns verified data: from pysequoia import verify # sign some data -signing_key = Cert.from_file("tests/fixtures/signing-key.asc") -signed = sign(s.secrets.signer(), "data to be signed".encode("utf8")) +signing_key = Tsk.from_file("tests/fixtures/signing-key.asc") +signed = sign(signing_key.signer(), "data to be signed".encode("utf8")) def get_certs_verify(key_ids): # key_ids is an array of required signing keys print(f"For verification, we need these keys: {key_ids}") - return [signing_key] + return [signing_key.extract_certificate()] # verify the data @@ -160,7 +158,7 @@ Detached signatures can be verified by passing additional parameter with the det ```python data = "data to be signed".encode("utf8") -detached = sign(s.secrets.signer(), data, mode=SignatureMode.DETACHED) +detached = sign(signing_key.signer(), data, mode=SignatureMode.DETACHED) signature = Sig.from_bytes(detached) result = verify(bytes=data, store=get_certs_verify, signature=signature) @@ -177,7 +175,7 @@ import tempfile with tempfile.NamedTemporaryFile(delete=False) as tmp: data = "data to be signed".encode("utf8") - detached = sign(s.secrets.signer(), data, mode=SignatureMode.DETACHED) + detached = sign(signing_key.signer(), data, mode=SignatureMode.DETACHED) signature = Sig.from_bytes(detached) tmp.write(data) @@ -204,11 +202,11 @@ Signs and encrypts a string to one or more recipients: ```python from pysequoia import encrypt -s = Cert.from_file("passwd.pgp") +s = Tsk.from_file("passwd.pgp") r = Cert.from_bytes(open("tests/fixtures/wiktor.asc", "rb").read()) content = "content to encrypt" encrypted = encrypt( - signer=s.secrets.signer("hunter22"), recipients=[r], bytes=content.encode("utf8") + signer=s.signer("hunter22"), recipients=[r], bytes=content.encode("utf8") ) print(f"Encrypted data: {encrypted.decode('utf8')}") ``` @@ -233,7 +231,7 @@ Encrypts data from a file and writes the encrypted output to another file: from pysequoia import encrypt_file import tempfile, os -s = Cert.from_file("passwd.pgp") +s = Tsk.from_file("passwd.pgp") r = Cert.from_bytes(open("tests/fixtures/wiktor.asc", "rb").read()) # create a file with content to encrypt @@ -245,7 +243,7 @@ with tempfile.NamedTemporaryFile(delete=False, suffix=".pgp") as out: output_path = out.name encrypt_file( - signer=s.secrets.signer("hunter22"), + signer=s.signer("hunter22"), recipients=[r], input=input_path, output=output_path, @@ -270,7 +268,9 @@ content = "Red Green Blue" encrypted = encrypt(recipients=[receiver], bytes=content.encode("utf8")) -decrypted = decrypt(decryptor=receiver.secrets.decryptor("hunter22"), bytes=encrypted) +decrypted = decrypt( + decryptor=Tsk.from_file("passwd.pgp").decryptor("hunter22"), bytes=encrypted +) assert content == decrypted.bytes.decode("utf8") # this message did not contain any valid signatures @@ -288,7 +288,9 @@ receiver = Cert.from_file("passwd.pgp") content = "Red Green Blue" encrypted = encrypt( - signer=sender.secrets.signer(), recipients=[receiver], bytes=content.encode("utf8") + signer=Tsk.from_file("no-passwd.pgp").signer(), + recipients=[receiver], + bytes=content.encode("utf8"), ) @@ -298,7 +300,7 @@ def get_certs_decrypt(key_ids): decrypted = decrypt( - decryptor=receiver.secrets.decryptor("hunter22"), + decryptor=Tsk.from_file("passwd.pgp").decryptor("hunter22"), bytes=encrypted, store=get_certs_decrypt, ) @@ -350,7 +352,7 @@ with tempfile.NamedTemporaryFile(delete=False, suffix=".txt") as out: output_path = out.name decrypted = decrypt_file( - decryptor=receiver.secrets.decryptor("hunter22"), + decryptor=Tsk.from_file("passwd.pgp").decryptor("hunter22"), input=input_path, output=output_path, ) @@ -380,7 +382,9 @@ receiver = Cert.from_file("passwd.pgp") content = "Red Green Blue" encrypted = encrypt( - signer=sender.secrets.signer(), recipients=[receiver], bytes=content.encode("utf8") + signer=Tsk.from_file("no-passwd.pgp").signer(), + recipients=[receiver], + bytes=content.encode("utf8"), ) # write encrypted data to a file @@ -398,7 +402,7 @@ def get_certs_decrypt_file(key_ids): decrypted = decrypt_file( - decryptor=receiver.secrets.decryptor("hunter22"), + decryptor=Tsk.from_file("passwd.pgp").decryptor("hunter22"), input=input_path, output=output_path, store=get_certs_decrypt_file, @@ -431,7 +435,7 @@ by this package). Certificates have two forms, one is ASCII armored and one is raw bytes: ```python -cert = Cert.generate("Test ") +cert = Tsk.generate("Test ").extract_certificate() print(f"Armored cert: {cert}") print(f"Bytes of the cert: {bytes(cert)!r}") @@ -451,7 +455,7 @@ Certificates can be parsed from files (`Cert.from_file`) or bytes in memory (`Cert.from_bytes`). ```python -cert1 = Cert.generate("Test ") +cert1 = Tsk.generate("Test ").extract_certificate() buffer = bytes(cert1) parsed_cert = Cert.from_bytes(buffer) @@ -463,9 +467,9 @@ bytes in memory (`Cert.split_bytes`) which are collections of binary certificates. ```python -cert1 = Cert.generate("Test 1 ") -cert2 = Cert.generate("Test 2 ") -cert3 = Cert.generate("Test 3 ") +cert1 = Tsk.generate("Test 1 ").extract_certificate() +cert2 = Tsk.generate("Test 2 ").extract_certificate() +cert3 = Tsk.generate("Test 3 ").extract_certificate() buffer = bytes(cert1) + bytes(cert2) + bytes(cert3) certs = Cert.split_bytes(buffer) @@ -477,15 +481,16 @@ assert len(certs) == 3 Creates a new general purpose key with a given User ID: ```python -alice = Cert.generate("Alice ") -fpr = alice.fingerprint -print(f"Generated cert with fingerprint {fpr}:\n{alice}") +alice = Tsk.generate("Alice ") +alice_pub = alice.extract_certificate() +fpr = alice_pub.fingerprint +print(f"Generated cert with fingerprint {fpr}:\n{alice_pub}") ``` Multiple User IDs can be passed as a list to the `generate` function: ```python -cert = Cert.generate(user_ids=["First", "Second", "Third"]) +cert = Tsk.generate(user_ids=["First", "Second", "Third"]).extract_certificate() assert len(cert.user_ids) == 3 ``` @@ -493,13 +498,13 @@ Newly generated certificates are usable in both encryption and signing contexts: ```python -alice = Cert.generate("Alice ") -bob = Cert.generate("Bob ") +alice = Tsk.generate("Alice ") +bob = Tsk.generate("Bob ").extract_certificate() content = "content to encrypt" encrypted = encrypt( - signer=alice.secrets.signer(), recipients=[bob], bytes=content.encode("utf8") + signer=alice.signer(), recipients=[bob], bytes=content.encode("utf8") ) print(f"Encrypted data: {encrypted!r}") ``` @@ -511,7 +516,9 @@ keys][9580] can also be generated: ```python from pysequoia import Profile -mary = Cert.generate("Modern Mary ", profile=Profile.RFC9580) +mary = Tsk.generate( + "Modern Mary ", profile=Profile.RFC9580 +).extract_certificate() print(f"Generated cert with fingerprint {mary.fingerprint}:\n{mary}") ``` @@ -523,19 +530,25 @@ certificates yet. The expiration is controlled via `validity_seconds` keyword argument: ```python -assert Cert.generate(user_id="test", validity_seconds=3600).expiration is not None +assert ( + Tsk.generate(user_id="test", validity_seconds=3600).extract_certificate().expiration + is not None +) ``` Using `None` generates a certificate with no expiration: ```python -assert Cert.generate(user_id="test", validity_seconds=None).expiration is None +assert ( + Tsk.generate(user_id="test", validity_seconds=None).extract_certificate().expiration + is None +) ``` By default certificates are generated *with* expiration time: ```python -assert Cert.generate("test").expiration is not None +assert Tsk.generate("test").extract_certificate().expiration is not None ``` > [!WARNING] @@ -565,10 +578,11 @@ assert str(user_id).startswith("Wiktor Kwapisiewicz") Adding new User IDs: ```python -cert = Cert.generate("Alice ") +tsk = Tsk.generate("Alice ") +cert = tsk.extract_certificate() assert len(cert.user_ids) == 1 cert = cert.add_user_id( - value="Alice ", certifier=cert.secrets.certifier() + value="Alice ", certifier=tsk.certifier() ) assert len(cert.user_ids) == 2 @@ -577,17 +591,14 @@ assert len(cert.user_ids) == 2 Revoking User IDs: ```python -cert = Cert.generate("Bob ") +tsk = Tsk.generate("Bob ") +cert = tsk.extract_certificate() -cert = cert.add_user_id( - value="Bob ", certifier=cert.secrets.certifier() -) +cert = cert.add_user_id(value="Bob ", certifier=tsk.certifier()) assert len(cert.user_ids) == 2 # create User ID revocation -revocation = cert.revoke_user_id( - user_id=cert.user_ids[1], certifier=cert.secrets.certifier() -) +revocation = cert.revoke_user_id(user_id=cert.user_ids[1], certifier=tsk.certifier()) # merge the revocation with the cert cert = Cert.from_bytes(bytes(cert) + bytes(revocation)) @@ -616,12 +627,13 @@ Notations can also be added: ```python from pysequoia import Notation -cert = Cert.from_file("tests/fixtures/signing-key.asc") +tsk = Tsk.from_file("tests/fixtures/signing-key.asc") +cert = tsk.extract_certificate() # No notations initially assert len(cert.user_ids[0].notations) == 0 cert = cert.set_notations( - cert.secrets.certifier(), [Notation("proof@metacode.biz", "dns:metacode.biz")] + tsk.certifier(), [Notation("proof@metacode.biz", "dns:metacode.biz")] ) # Has one notation now @@ -655,14 +667,15 @@ Key expiration can also be adjusted with `set_expiration`: ```python from datetime import datetime -cert = Cert.from_file("tests/fixtures/signing-key.asc") +tsk = Tsk.from_file("tests/fixtures/signing-key.asc") +cert = tsk.extract_certificate() # Cert does not have any expiration date: assert cert.expiration is None # Set the expiration to some specified point in time expiration = datetime.fromisoformat("2021-11-04T00:05:23+00:00") -cert = cert.set_expiration(expiration=expiration, certifier=cert.secrets.certifier()) +cert = cert.set_expiration(expiration=expiration, certifier=tsk.certifier()) assert str(cert.expiration) == "2021-11-04 00:05:23+00:00" ``` @@ -675,8 +688,9 @@ irreversible. [EXP]: https://blogs.gentoo.org/mgorny/2018/08/13/openpgp-key-expiration-is-not-a-security-measure/ ```python -cert = Cert.generate("Test Revocation ") -revocation = cert.revoke(certifier=cert.secrets.certifier()) +tsk = Tsk.generate("Test Revocation ") +cert = tsk.extract_certificate() +revocation = cert.revoke(certifier=tsk.certifier()) # creating revocation signature does not revoke the key assert not cert.is_revoked @@ -688,26 +702,11 @@ assert revoked_cert.is_revoked ## Secret keys -Certificates generated through `Cert.generate()` contain secret keys +Certificates with secret keys are generated through `Tsk.generate()` and can be used for signing and decryption. -To avoid accidental leakage secret keys are never directly printed -when the Cert is written to a string. To enable this behavior use -`Cert.secrets`. `secrets` returns `None` on certificates which do -not contain any secret key material ("public keys"). - ```python -c = Cert.generate("Testing key ") -assert c.has_secret_keys - -# by default only public parts are exported -public_parts = Cert.from_bytes(f"{c}".encode("utf8")) -assert not public_parts.has_secret_keys -assert public_parts.secrets is None - -# to export secret parts use the following: -private_parts = Cert.from_bytes(f"{c.secrets}".encode("utf8")) -assert private_parts.has_secret_keys +c = Tsk.generate("Testing key ") ``` ## Signatures @@ -738,7 +737,7 @@ type-specific accessors for extracting fields. ```python from pysequoia.packet import PacketPile, Tag, SignatureType -cert = Cert.generate("Test ") +cert = Tsk.generate("Test ").extract_certificate() pile = PacketPile.from_bytes(bytes(cert)) for packet in pile: @@ -790,7 +789,7 @@ appropriate header, base64 encoding, and CRC24 checksum: ```python from pysequoia import armor, ArmorKind -cert = Cert.generate("Test ") +cert = Tsk.generate("Test ").extract_certificate() armored = armor(bytes(cert), ArmorKind.PublicKey) # same as: str(cert) assert "-----BEGIN PGP PUBLIC KEY BLOCK-----" in armored assert "-----END PGP PUBLIC KEY BLOCK-----" in armored diff --git a/python/pysequoia/__init__.pyi b/python/pysequoia/__init__.pyi index 724af50..d6b8803 100644 --- a/python/pysequoia/__init__.pyi +++ b/python/pysequoia/__init__.pyi @@ -132,6 +132,15 @@ class Tsk: def __str__(self, /) -> str: ... def certifier(self, /, password: str |None = None) -> PySigner: ... def decryptor(self, /, password: str |None = None) -> PyDecryptor: ... + def extract_certificate(self, /) -> Cert: ... + @staticmethod + def from_bytes(bytes: bytes) -> Tsk: ... + @staticmethod + def from_file(path: str) -> Tsk: ... + @staticmethod + def from_packets(packets: Sequence[Packet]) -> Tsk: ... + @staticmethod + def generate(user_id: str |None = None, user_ids: Sequence[str] |None = None, profile: Profile |None = None, validity_seconds: int |None = ...) -> Tsk: ... def signer(self, /, password: str |None = None) -> PySigner: ... @final diff --git a/src/cert.rs b/src/cert.rs index 1ddf010..850d040 100644 --- a/src/cert.rs +++ b/src/cert.rs @@ -11,13 +11,14 @@ use sequoia_openpgp::packet::{UserID, signature}; use sequoia_openpgp::parse::Parse; use sequoia_openpgp::policy::{Policy, StandardPolicy}; use sequoia_openpgp::serialize::SerializeInto; -use sequoia_openpgp::types::{KeyFlags, ReasonForRevocation, RevocationStatus, SignatureType}; +use sequoia_openpgp::types::{ReasonForRevocation, RevocationStatus, SignatureType}; use crate::notation::Notation; +use crate::pysequoia::Tsk; use crate::signer::PySigner; use crate::user_id::UserId; -static DEFAULT_POLICY: Lazy>>> = +pub static DEFAULT_POLICY: Lazy>>> = Lazy::new(|| Arc::new(Mutex::new(Box::new(StandardPolicy::new())))); /// An OpenPGP certificate (public key with associated user IDs, subkeys, and signatures). @@ -131,41 +132,26 @@ impl Cert { /// The generated certificate has a validity period of 3 years. #[staticmethod] #[pyo3(signature = (user_id=None, user_ids=None, profile=None, validity_seconds=3 * 52 * 7 * 24 * 60 * 60))] + #[pyo3(warn( + message = "Use Tsk::generate", + category = pyo3::exceptions::PyDeprecationWarning + ))] pub fn generate( user_id: Option<&str>, user_ids: Option>, profile: Option, validity_seconds: Option, ) -> PyResult { - let mut builder = CertBuilder::new() - .set_profile(profile.unwrap_or_default().into())? - .set_cipher_suite(CipherSuite::default()) - .set_primary_key_flags(KeyFlags::empty().set_certification()) - .add_signing_subkey() - .add_subkey( - KeyFlags::empty() - .set_transport_encryption() - .set_storage_encryption(), - None, - None, - ); - if let Some(validity_seconds) = validity_seconds { - builder = builder.set_validity_period(std::time::Duration::new(validity_seconds, 0)) - } - if let Some(u) = user_id { - builder = builder.add_userid(u); - } - if let Some(user_ids) = user_ids { - for user_id in user_ids { - builder = builder.add_userid(user_id); - } - } - - Ok(builder.generate()?.0.into()) + let tsk = Tsk::generate(user_id, user_ids, profile, validity_seconds)?; + tsk.extract_certificate() } /// Whether this certificate contains secret key material. #[getter] + #[pyo3(warn( + message = "Use Tsk::from_* functions", + category = pyo3::exceptions::PyDeprecationWarning + ))] pub fn has_secret_keys(&self) -> bool { self.cert.is_tsk() } @@ -174,6 +160,10 @@ impl Cert { /// /// Returns `None` if the certificate does not contain secret keys. #[getter] + #[pyo3(warn( + message = "Use Tsk::from_* functions", + category = pyo3::exceptions::PyDeprecationWarning + ))] pub fn secrets(&self) -> Option { if self.cert.is_tsk() { Some(secret::Tsk::new(self.cert.clone(), &self.policy)) diff --git a/src/cert/secret.rs b/src/cert/secret.rs index 54c43d0..8f32cfd 100644 --- a/src/cert/secret.rs +++ b/src/cert/secret.rs @@ -2,8 +2,12 @@ use std::borrow::Cow; use std::sync::{Arc, Mutex, MutexGuard}; use pyo3::prelude::*; +use sequoia_openpgp::cert::{CertBuilder, CipherSuite}; +use sequoia_openpgp::parse::Parse as _; +use sequoia_openpgp::types::KeyFlags; use sequoia_openpgp::{cert, policy::Policy, serialize::SerializeInto}; +use crate::cert::{DEFAULT_POLICY, Profile}; use crate::decrypt; use crate::signer::PySigner; @@ -29,8 +33,82 @@ impl Tsk { } } +impl From for Tsk { + fn from(cert: cert::Cert) -> Self { + Self { + cert, + policy: Arc::clone(&DEFAULT_POLICY), + } + } +} + #[pymethods] impl Tsk { + /// Generate a new TSK with a certification-capable primary key, + /// a signing subkey, and an encryption subkey. + /// + /// The generated certificate has a validity period of 3 years. + #[staticmethod] + #[pyo3(signature = (user_id=None, user_ids=None, profile=None, validity_seconds=3 * 52 * 7 * 24 * 60 * 60))] + pub fn generate( + user_id: Option<&str>, + user_ids: Option>, + profile: Option, + validity_seconds: Option, + ) -> PyResult { + let mut builder = CertBuilder::new() + .set_profile(profile.unwrap_or_default().into())? + .set_cipher_suite(CipherSuite::default()) + .set_primary_key_flags(KeyFlags::empty().set_certification()) + .add_signing_subkey() + .add_subkey( + KeyFlags::empty() + .set_transport_encryption() + .set_storage_encryption(), + None, + None, + ); + if let Some(validity_seconds) = validity_seconds { + builder = builder.set_validity_period(std::time::Duration::new(validity_seconds, 0)) + } + if let Some(u) = user_id { + builder = builder.add_userid(u); + } + if let Some(user_ids) = user_ids { + for user_id in user_ids { + builder = builder.add_userid(user_id); + } + } + + Ok(builder.generate()?.0.into()) + } + + /// Parse a certificate from a file on disk. + /// + /// The file may be binary or ASCII-armored. + #[staticmethod] + pub fn from_file(path: String) -> PyResult { + Ok(cert::Cert::from_file(path)?.into()) + } + + /// Parse a certificate from a byte string. + /// + /// The bytes may be binary or ASCII-armored. + #[staticmethod] + pub fn from_bytes(bytes: &[u8]) -> PyResult { + Ok(cert::Cert::from_bytes(bytes)?.into()) + } + + /// Build a certificate from a sequence of OpenPGP packets. + /// + /// The packets must form a valid certificate (a primary key followed by + /// its associated user IDs, user attributes, subkeys, and signatures). + #[staticmethod] + pub fn from_packets(packets: Vec) -> PyResult { + let sq_packets = packets.into_iter().map(|p| p.into_inner()); + Ok(cert::Cert::from_packets(sq_packets)?.into()) + } + /// Return the ASCII-armored secret key representation (Transferable Secret Key). pub fn __str__(&self) -> PyResult { let armored = self.cert.as_tsk().armored(); @@ -46,6 +124,11 @@ impl Tsk { Ok(self.cert.as_tsk().to_vec()?.into()) } + /// Extracts public parts of this TSK. + pub fn extract_certificate(&self) -> PyResult { + Ok(self.cert.clone().into()) + } + /// Get a signer using this certificate's signing component key. /// /// If the secret key is password-protected, provide the password to decrypt it. diff --git a/src/decrypt.rs b/src/decrypt.rs index 6bdb620..2935eab 100644 --- a/src/decrypt.rs +++ b/src/decrypt.rs @@ -15,7 +15,7 @@ use crate::{Decrypted, ValidSig}; /// A decryption helper that holds the key material needed to decrypt messages. /// -/// Obtain a `PyDecryptor` via `Cert.secrets.decryptor()`. +/// Obtain a `PyDecryptor` via `Tsk.decryptor()`. #[pyclass(from_py_object)] #[derive(Clone, Default)] pub struct PyDecryptor { diff --git a/src/signer.rs b/src/signer.rs index 62933f2..f8ca020 100644 --- a/src/signer.rs +++ b/src/signer.rs @@ -5,7 +5,7 @@ use sequoia_openpgp::{crypto, packet, types}; /// A handle to a signing key, used for creating signatures, certifications, and revocations. /// -/// Obtain a `PySigner` via `Cert.secrets.signer()` or `Cert.secrets.certifier()`. +/// Obtain a `PySigner` via `Tsk.signer()` or `Tsk.certifier()`. #[pyclass(from_py_object)] #[derive(Clone)] pub struct PySigner {