Keys and encryption¶
What the bot does with these is explained in Cryptography; the steps are in
OpenPGP step by step. BotKey, PublicKey, Decrypted and
format_fingerprint() are also importable from pgpcbot.crypto; Verification
and SignatureStatus only from pgpcbot.
- class BotKey(tsk, secret_armored, passphrase)¶
The bot’s own OpenPGP key pair, private key included.
The private key is the bot’s identity: whoever has it can read every message sealed to the bot and sign in its name. Keep it like the token.
Make a new key with
generate, or load one withfrom_armored,from_fileorfrom_env.Botdoes the loading for you, from itspgp_private_keyargument or from the environment.How the private key is kept:
Nothing in this class talks to the network. Only the public half is meant to leave the process:
Bot.publish_keysendsexport_publicwithPUT /key, and the client refuses a private key block there.Nothing writes the key to disk but
save.The key and its passphrase are never logged.
repr()shows only the fingerprint and whether the key is protected (<BotKey 3F1A 9C0D ... protected>).pickle,copy.copyandcopy.deepcopyraiseTypeError.A protected key is unlocked once, when it is loaded, so a wrong passphrase fails at start-up rather than on the first message. The passphrase stays in memory, wrapped in a
Secret, because rPGP needs it for each message it opens: to check the signature, and to look for compression.Python can’t wipe memory: the unlocked key lives in the process for as long as the object does. Treat the process like the key (no readable core dumps, no debugger that others can reach).
- public_key¶
The public half: what other people seal messages to and check the bot’s signatures against.
- Type:
Example
Make the key once, then load it each time the bot starts:
passphrase = os.environ["BOT_KEY_PASSPHRASE"] key = BotKey.generate("Weather", username="weatherbot", passphrase=passphrase) key.save("bot-key.asc", passphrase=None) # already protected key = BotKey.from_file("bot-key.asc", passphrase)
Note
The methods are plain blocking calls.
Botruns sealing and opening in a worker thread (asyncio.to_thread); do the same in async code of your own, so the event loop is not held up.Wraps a private key that pysequoia has read, and unlocks it.
Most code never calls this:
generate,from_armored,from_fileandfrom_envread the key and call it for you.The signing key and the encryption subkey are unlocked here, once. Two warnings can be logged on the
pgpcbot.cryptologger, naming the fingerprint at most: when rPGP can’t read the key (incoming signatures then come backSignatureStatus.NOT_CHECKED, anddecryptcan’t look for compression), and when the key announces SEIPDv2, a message format phones on PGPainless 1.7 can’t open (seeencrypt).- Parameters:
tsk (pysequoia.Tsk) – The private key, as a
pysequoia.Tsk.secret_armored (str) – The same key, ASCII-armored: what rPGP reads, and what
export_secretreturns andsavewrites when they get no passphrase.passphrase (str | None) – The passphrase that unlocks the key, or
None. Not used, and not kept, when the key is not locked: the passphrase of a sealed file around the key, for example.
- Raises:
PassphraseError – The key is locked and
passphraseis missing or wrong.InvalidKeyError – The key has no signing key or no encryption subkey it can use now (expired or revoked), or Sequoia can’t use it for another reason.
- classmethod generate(name='PGP Chat bot', *, username=None, passphrase=None)¶
Makes a new key pair, of the kind every phone can use.
The key is OpenPGP v4, the same kind the phones make: an Ed25519 primary key (EdDSA, algorithm 22) that certifies and signs, and a Curve25519 subkey (ECDH, algorithm 18) that encrypts. Its features say SEIPDv1 only. Every message the bot seals is also sealed to its own key, so Sequoia always writes SEIPDv1, which phones on PGPainless 1.7 can open, whatever keys the other members have. Its preferences list AES-256 and AES-128, SHA-512 and SHA-256, and no compression, ZLIB or ZIP.
The key never expires: an expired key could not open any message, old ones included. To change it, rotate it (
Bot.rotate_key).rPGP makes the key in memory. Nothing is sent or written: call
saveto keep it.- Parameters:
name (str) – The name in the key’s user ID. Spaces at the ends are removed.
username (str | None) – The bot’s handle, with or without the
@. With it, the user ID is"Name (@username)"; without it, the name alone.passphrase (str | None) – Protects the private parts of the key in every export: iterated and salted S2K with SHA-256 and the highest iteration count, then AES-256. Any OpenPGP tool can unlock it with the passphrase.
None(the default) makes a key in clear.
- Returns:
The new key, unlocked and ready to use.
- Raises:
ConfigurationError –
nameis empty or only spaces, orpassphraseis an empty string.- Return type:
Example
A key protected by a passphrase from the environment:
key = BotKey.generate( "Weather", username="weatherbot", passphrase=os.environ["BOT_KEY_PASSPHRASE"] ) print(key.fingerprint)
- classmethod from_armored(data, passphrase=None)¶
Loads a private key from text or bytes, and unlocks it.
datais a-----BEGIN PGP PRIVATE KEY BLOCK-----, protected by a passphrase or not (the binary form works too), or a-----BEGIN PGP MESSAGE-----thatsaveorexport_secretsealed with a passphrase. A byte order mark at the start, as Notepad and PowerShell 5 write it, is skipped.- Parameters:
- Returns:
The key, unlocked.
- Raises:
PassphraseError – The key or the sealed file needs a passphrase and none was given, or the passphrase is wrong.
InvalidKeyError –
datais a public key or not an OpenPGP private key, or the key has no signing key or encryption subkey it can use now.DecryptionError – A sealed file can’t be opened for another reason.
ConfigurationError –
passphraseis not a string, aSecretor a function, or the function returned something other than a string, aSecretorNone.
- Return type:
- classmethod from_file(path, passphrase=None)¶
Loads a private key from a file, and unlocks it.
The file is read whole and handed to
from_armored. Outside Windows, a warning is logged when the file’s group or other users have any access to it (chmod 600fixes that). Windows is not checked: keep the file in a folder only the service’s user can read.- Parameters:
- Returns:
The key, unlocked.
- Raises:
ConfigurationError – The file can’t be read, or
passphraseis of the wrong kind, as infrom_armored.PassphraseError – As in
from_armored.InvalidKeyError – As in
from_armored.DecryptionError – As in
from_armored.
- Return type:
- classmethod from_env(passphrase=None)¶
Loads the private key the environment names, or returns
None.The key comes from the file named by
PGPCHAT_BOT_KEY_FILEwhen that is set, or else fromPGPCHAT_BOT_KEY: the armored key itself, or the path of a key file. The file named byPGPCHAT_BOT_KEY_FILEis read as UTF-8 text, so it must hold the armored key (a binary key there fails withUnicodeDecodeError). A path inPGPCHAT_BOT_KEYgoes throughfrom_file, which reads both forms and checks the file’s permissions.The passphrase is
passphrasewhen given, or else the contents of the file named byPGPCHAT_BOT_KEY_PASSPHRASE_FILE, or elsePGPCHAT_BOT_KEY_PASSPHRASE. The line ends an editor leaves at the end of a file, and a byte order mark at its start, are not part of the value.A file is safer than a variable: child processes inherit the environment, and crash dumps include it.
Botcalls this when it gets nopgp_private_key.- Parameters:
passphrase (str | Secret | Callable[[], str] | None) – Used instead of the passphrase in the environment. As in
from_armored.- Returns:
The key, unlocked, or
Nonewhen neitherPGPCHAT_BOT_KEY_FILEnorPGPCHAT_BOT_KEYis set, or the key text is blank.- Raises:
ConfigurationError – A file named by one of the variables can’t be read, or
passphraseis of the wrong kind, as infrom_armored.PassphraseError – As in
from_armored.InvalidKeyError – As in
from_armored.DecryptionError – As in
from_armored.
- Return type:
BotKey | None
- property fingerprint: str¶
The key’s fingerprint, in upper-case hex without spaces.
40 digits for a v4 key such as the ones
generatemakes. Show it to people withformat_fingerprint.
- property protected: bool¶
Whether the private key itself is protected by a passphrase.
Truefor a key made bygeneratewith apassphrase, or loaded from a protectedPRIVATE KEY BLOCK:export_secretandsavethen write it protected.Falsefor a key in clear, even one loaded from a file thatsavesealed with a passphrase: that passphrase protected the file, not the key inside it, andexport_secretwithout a passphrase returns the key in clear.
- export_public()¶
Exports the public key, ASCII-armored.
This is the part people may see:
Bot.publish_keysends it withPUT /key. It holds no secret material.- Returns:
The
-----BEGIN PGP PUBLIC KEY BLOCK-----text, the same aspublic_key.armored.- Return type:
- export_secret(*, passphrase=None)¶
Exports the private key, ASCII-armored.
Without
passphrasethe key comes out as it is stored: protected if it has a passphrase of its own (seeprotected), in clear otherwise. With one, the whole armored key is sealed in a password-encrypted OpenPGP message, the same format asgpg -c, whichfrom_armored,gpg -dandsq decryptcan open.A key with a passphrase of its own can be sealed only with that same passphrase.
from_armoredtakes one passphrase for the file and the key inside it, so a file sealed with another one could never be loaded back.- Parameters:
passphrase (str | None) – Seals the key with this passphrase.
None(the default) exports it as it is stored.- Returns:
The
PRIVATE KEY BLOCK, or thePGP MESSAGEthat holds it.- Raises:
ConfigurationError –
passphraseis an empty string, or the key has a passphrase of its own andpassphraseis another one.EncryptionError – Sealing with the passphrase failed.
- Return type:
Warning
Whoever has the result, and its passphrase if it has one, can read every message sealed to the bot and sign in its name. Never log it or send it anywhere.
- save(path, *, passphrase, overwrite=False)¶
Writes the private key to a file.
passphrasehas no default on purpose: writing a private key in clear should be a decision, not a default. WithNonethe key is written as it is stored (seeexport_secret): protected if it has a passphrase of its own, in clear otherwise, with a warning in the log. With a passphrase, the file holds the key sealed in a password-encrypted OpenPGP message.The key goes into a new file in the same folder, which then replaces
path: an old file’s permissions are never reused, and a symbolic link atpathis replaced, not followed. Outside Windows the new file is private from the first byte (mode0600): only its owner can read it. On Windows it gets the permissions of its folder: keep it in a folder only the service’s user can read. Missing folders are created.- Parameters:
- Returns:
path, as aPath.- Raises:
ConfigurationError – Something is already at
path, even a broken symbolic link, andoverwriteisFalse; orpassphraseis empty, or is not the key’s own passphrase when the key has one.EncryptionError – Sealing with the passphrase failed.
OSError – The folder or the file can’t be written.
- Return type:
Example
A key generated with a passphrase is written as it is; a key in clear is sealed with one:
protected_key.save("bot-key.asc", passphrase=None) clear_key.save("backup.asc", passphrase=os.environ["BACKUP_PASSPHRASE"])
Warning
passphrase=Noneon a key in clear writes the private key in clear, with only a warning in the log. Do it only when the file is protected some other way.
- seal(text, recipients)¶
Encrypts and signs a message, ASCII-armored: what goes into a chat.
The same as
encrypt(text, recipients, sign=True, armor=True); the result goes in thearmoredfield of a message.Bot.sealdoes this for a whole chat, with the recipients the server lists and the same checks as an automatic send.- Parameters:
- Returns:
The
-----BEGIN PGP MESSAGE-----text.- Raises:
EncryptionError – A recipient is revoked, expired or has no live encryption subkey, Sequoia could not seal the message, or the result came out as SEIPDv2. There is no fallback to clear text.
TypeError –
textis notstrorbytes, or a recipient is not aPublicKey.
- Return type:
Example
By hand, for one person:
anna = PublicKey.from_file("anna.asc") armored = key.seal("hi Anna", [anna])
- encrypt(data, recipients, *, sign=True, armor=True)¶
Seals data to some keys and to the bot’s own key.
Every recipient must be
PublicKey.usable, or nothing is sealed: there is no fallback to clear text, and no recipient is quietly left out. The bot’s own key and repeated keys count once. With no recipients, only the bot can open the result.Sequoia seals the message. It writes SEIPDv2 only when every key it seals to announces SEIPDv2, the bot’s own key included. A key from
generatenever announces it, so its messages are always SEIPDv1. rPGP then reads what came out: a message in SEIPDv2, which phones on PGPainless 1.7 can’t open, is refused. Sealed to the phones’ keys by a key fromgenerate, the result is SEIPDv1 with AES-256 and a SHA-512 signature, not compressed.- Parameters:
- Returns:
The sealed message,
strwhenarmorisTrue,bytesotherwise.- Raises:
EncryptionError – A recipient is revoked, expired or has no live encryption subkey, Sequoia could not seal the message, or the result came out as SEIPDv2.
TypeError –
datais notstrorbytes, or a recipient is not aPublicKey.
- Return type:
Note
The key version is not checked here. Phones on PGPainless 1.7 can’t use v6 keys, so
Botnever seals to one: checkPublicKey.versionwhen you choose recipients by hand.
- decrypt(data, *, verify_with=(), max_size=None, allow_compression=True)¶
Opens a message sealed to this key, and checks who signed it.
Sequoia opens the message. When
verify_withholds keys, rPGP opens it a second time and checks the signature; the two engines must agree on the content.verification.statusof the result is one of theseSignatureStatusvalues:VALID: signed by one of theverify_withkeys, and the signature is correct.signer_fingerprintnames that key.UNKNOWN_KEY: signed, but by none of theverify_withkeys.INVALID: signed by one of them, but the signature does not check out; or the two engines disagree on the content.UNSIGNED: no signature at all.NOT_CHECKED:verify_withis empty, or rPGP can’t read the bot’s key, the sender’s key, the message or that kind of signature. Except for an emptyverify_with,detailsays why.
A bad or missing signature does not raise: the data comes back anyway. Look at the status before you trust it, or let
Botdrop such messages withverify_signatures="require".- Parameters:
data (str | bytes) – The message: armored text, or
bytes, armored or binary.verify_with (Iterable[PublicKey]) – The keys the sender may have signed with, usually the one the server lists for them. Empty (the default): the signature is not checked.
max_size (int | None) – Refuse a message that opens to more bytes than this.
None(the default) sets no limit.Botuses 1 MiB for messages, and the download limit for attachments.allow_compression (bool) –
Falserefuses a compressed message before anything is inflated. Phones never compress attachments, and a compressed one could open to many gigabytes, soBotpassesFalsefor them.
- Returns:
The decrypted bytes and the result of the signature check, as a
Decrypted.- Raises:
NotARecipientError – The message was not sealed to this key.
DecryptionError – The message is malformed or was changed on the way, opens to more than
max_sizebytes, or is compressed whileallow_compressionisFalse.TypeError –
datais notstrorbytes.
- Return type:
Example
A message from Anna, checked against her key:
result = key.decrypt(armored, verify_with=[anna]) if result.verification.valid: print(result.text)
Note
Neither engine can stop half way through a compressed message, so
max_sizelimits what comes back, not the memory used on the way. Sequoia opens one message at a time in the whole process, so a crafted message costs that memory once, not once per thread. The compression check needs rPGP; when rPGP can’t read the bot’s key or the message, it is skipped and onlymax_sizeapplies. With a protected key, the signature check and the compression check each unlock the key again in rPGP, which is slow by design.Warning
VALIDshows that the message was made with one of theverify_withkeys and that nobody changed it. It does not show that the key belongs to that person (the server hands out the keys), that the message was meant for this chat, or that it is new.
- class PublicKey(cert)¶
Somebody’s OpenPGP public key, to seal messages to and check signatures against.
Read one with
from_armored,from_fileorfrom_user_key. The last one is for keys the server hands out: it also checks the fingerprint the server claims. None of them keeps secret material: an armored private key is refused, and secret parts in other data are dropped.Keys compare and hash by fingerprint, so a key can go in a set or be a dict key.
repr()shows the fingerprint in groups of four, the version and the first user ID.- fingerprint¶
The key’s fingerprint in upper-case hex, without spaces. 40 digits for a v4 key, 64 for a v6 key.
- Type:
Wraps a certificate that pysequoia has already read.
Most code reads keys with
from_armored,from_fileorfrom_user_keyinstead: they check what they read and keep only the public part.- Parameters:
cert (pysequoia.Cert) – The certificate, a
pysequoia.Cert.
- classmethod from_armored(data)¶
Reads one public key from text or bytes.
- Parameters:
data (str | bytes) – A
-----BEGIN PGP PUBLIC KEY BLOCK-----, or the same key in binary form. A byte order mark at the start is skipped.- Returns:
The key. Only its public part is kept.
- Raises:
InvalidKeyError –
dataholds an armored private key block, more than one key, or no readable OpenPGP key.- Return type:
- classmethod from_file(path)¶
Reads one public key from a file, armored or binary.
- Parameters:
- Returns:
The key, read as
from_armoredreads it. From a binary private key, only the public part is kept.- Raises:
ConfigurationError – The file can’t be read.
InvalidKeyError – The file holds an armored private key block, more than one key, or no readable OpenPGP key.
- Return type:
- classmethod from_user_key(user_key)¶
Reads a key the server handed out, and checks the fingerprint the server gives for it.
The server stores whatever a phone published and never parses it, so the key is read here and its real fingerprint is compared with the one the server lists; spaces and case do not count. When the server lists no fingerprint, there is nothing to compare and the key is taken as it is.
A match shows that the key and the fingerprint go together, not that the key belongs to that person: the server hands out both. To notice a key that changes, use
Bot(pin_keys=True); for the people who matter, compare fingerprints over another channel.- Parameters:
user_key (UserKey) – A person’s key as the server lists it, such as one of the
keysthatAsyncClient.get_chat_keysreturns.- Returns:
The key.
- Raises:
InvalidKeyError – The key can’t be read, is a private key block, or does not have the fingerprint the server lists; in that last case the error names both fingerprints.
- Return type:
- property version: int¶
The OpenPGP key version,
4or6, told by the length of the fingerprint.Phones on PGPainless 1.7 can’t use v6 keys.
Botnever seals to one (a group member with a v6 key is left out, a send to a private chat fails) and refuses to publish one as its own key.BotKey.encryptdoes not look at the version.
- property key_id: str¶
The key ID, 16 upper-case hex digits.
The last 16 digits of the fingerprint for a v4 key, the first 16 for a v6 key, as OpenPGP defines it.
- property user_ids: list[str]¶
The user IDs of the key, as text.
["Weather (@weatherbot)"]for a key made byBotKey.generate("Weather", username="weatherbot"). Empty when the key has none.
- property expires_at: datetime | None¶
When the key expires, in UTC, or
Nonewhen it never expires.This is the expiry of the key as a whole. A subkey can expire sooner:
usablechecks that too.
- property revoked: bool¶
Whether the key as a whole has been revoked.
A revoked subkey alone does not count here;
usablechecks the subkeys too.
- property expired: bool¶
Whether
expires_athas passed, checked against the clock on each call.
- property usable: bool¶
Whether a message can be sealed to this key now.
Truewhen the key is not revoked, not expired, and has an encryption subkey that is neither revoked nor expired. Worked out on every call, not cached: keys expire while a bot runs.BotKey.encryptrefuses a key that is not usable.Sealing with Sequoia alone would accept a revoked key, and fall back to an expired subkey when there is no live one: this check is what stops both. rPGP looks at the subkeys; when it can’t read the key, Sequoia’s answer stands.
- class Decrypted(data, verification)¶
An opened message, with what its signature check found.
BotKey.decryptreturns it. It is read-only.- verification¶
What the signature check found. Its
statusisSignatureStatus.NOT_CHECKEDwhenBotKey.decryptgot noverify_withkeys. Look at it before you trustdata: a bad signature does not stop decryption.- Type:
Note
A phone writes the app’s message format, not always plain words. A text message is the text itself; a photo, an album, a forward or a sticker starts with a marker followed by JSON.
Botreads that format for you and fillsmessage.textandmessage.media.
- class Verification(status, signer_fingerprint=None, detail=None)¶
The outcome of checking the signature of an encrypted message.
The bot sets one on every message it decrypts (
message.verification;Nonein a readable chat), andBotKey.decryptreturns one in itsDecrypted. By default (verify_signatures="auto") the bot only reports what it found: a message with a bad signature, or none, still reaches the handlers. Withverify_signatures="require", a message that is notvalidraisesSignatureErrorand goes to the error handlers instead.- status¶
What the check found.
- Type:
- signer_fingerprint¶
When
statusisvalid, the fingerprint of the key that made the signature, in upper-case hex.Noneotherwise.- Type:
str | None
- detail¶
Why the status is not
valid, in a few words for a log, orNone. It is written for people: branch onstatus, not on this text.- Type:
str | None
Example
Tell the sender what the bot found:
from pgpcbot import filters as f @bot.on_message(f.encrypted) async def check(message): v = message.verification if v.valid: await message.reply("Signed with your key.") else: await message.reply(f"Signature: {v.status} ({v.detail})")
Note
A
validsignature proves that the message comes from whoever holds the key the server lists for the sender, and that nobody changed it. It does not prove that the key belongs to that person: the server hands out the keys.
- class SignatureStatus(*values)¶
What the bot could tell about the OpenPGP signature of an encrypted message.
It is the
statusof aVerification. For a message it receives, the bot checks the signature against the key the server lists for the sender;BotKey.decryptchecks it against the keys given inverify_with. Astrenum like the others, but the library makes it, not the server, so it only ever holds these five values.- VALID¶
"valid", signed with one of those keys, and the signature checks out.
- INVALID¶
"invalid", signed with one of those keys, but the signature does not check out; or the two OpenPGP engines that open every message disagree on what it says.
- UNKNOWN_KEY¶
"unknown_key", signed, but with none of those keys.
- UNSIGNED¶
"unsigned", no signature at all.
- NOT_CHECKED¶
"not_checked", the signature was not checked. Checking is off (verify_signatures="off"), there is no key to check against (a post in a channel’s name has no sender; the chat’s keys could not be fetched, or hold no usable key for the sender), or the engine that checks signatures could not read the message or the keys.Verification.detailsays why, unless checking is off or no keys were given.
- format_fingerprint(fingerprint)¶
Splits a fingerprint into groups of four, the way people read them out to each other.
Spaces are removed and letters made upper case first, so the result is the same with or without spaces, in upper or lower case. Nothing is checked: any text is grouped.
- Parameters:
fingerprint (str) – The fingerprint, with or without spaces.
- Returns:
The fingerprint in upper case, in groups of four separated by spaces.
- Return type:
Example
>>> format_fingerprint("3f1a9c0d52e7b8a46c110d93e2f47a559b10c6d2") '3F1A 9C0D 52E7 B8A4 6C11 0D93 E2F4 7A55 9B10 C6D2'