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 with from_armored, from_file or from_env. Bot does the loading for you, from its pgp_private_key argument 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_key sends export_public with PUT /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.copy and copy.deepcopy raise TypeError.

  • 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:

PublicKey

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. Bot runs 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_file and from_env read 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.crypto logger, naming the fingerprint at most: when rPGP can’t read the key (incoming signatures then come back SignatureStatus.NOT_CHECKED, and decrypt can’t look for compression), and when the key announces SEIPDv2, a message format phones on PGPainless 1.7 can’t open (see encrypt).

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_secret returns and save writes 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 passphrase is 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 save to 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 – name is empty or only spaces, or passphrase is an empty string.

Return type:

BotKey

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.

data is a -----BEGIN PGP PRIVATE KEY BLOCK-----, protected by a passphrase or not (the binary form works too), or a -----BEGIN PGP MESSAGE----- that save or export_secret sealed with a passphrase. A byte order mark at the start, as Notepad and PowerShell 5 write it, is skipped.

Parameters:
  • data (str | bytes) – The key.

  • passphrase (str | Secret | Callable[[], str] | None) – Opens a sealed file and unlocks a protected key; one passphrase serves both. A string, a Secret, or a function that returns one, called once, here. None for a key in clear.

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 – data is 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 – passphrase is not a string, a Secret or a function, or the function returned something other than a string, a Secret or None.

Return type:

BotKey

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 600 fixes that). Windows is not checked: keep the file in a folder only the service’s user can read.

Parameters:
Returns:

The key, unlocked.

Raises:
Return type:

BotKey

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_FILE when that is set, or else from PGPCHAT_BOT_KEY: the armored key itself, or the path of a key file. The file named by PGPCHAT_BOT_KEY_FILE is read as UTF-8 text, so it must hold the armored key (a binary key there fails with UnicodeDecodeError). A path in PGPCHAT_BOT_KEY goes through from_file, which reads both forms and checks the file’s permissions.

The passphrase is passphrase when given, or else the contents of the file named by PGPCHAT_BOT_KEY_PASSPHRASE_FILE, or else PGPCHAT_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.

Bot calls this when it gets no pgp_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 None when neither PGPCHAT_BOT_KEY_FILE nor PGPCHAT_BOT_KEY is set, or the key text is blank.

Raises:
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 generate makes. Show it to people with format_fingerprint.

property protected: bool

Whether the private key itself is protected by a passphrase.

True for a key made by generate with a passphrase, or loaded from a protected PRIVATE KEY BLOCK: export_secret and save then write it protected. False for a key in clear, even one loaded from a file that save sealed with a passphrase: that passphrase protected the file, not the key inside it, and export_secret without a passphrase returns the key in clear.

export_public()

Exports the public key, ASCII-armored.

This is the part people may see: Bot.publish_key sends it with PUT /key. It holds no secret material.

Returns:

The -----BEGIN PGP PUBLIC KEY BLOCK----- text, the same as public_key.armored.

Return type:

str

export_secret(*, passphrase=None)

Exports the private key, ASCII-armored.

Without passphrase the key comes out as it is stored: protected if it has a passphrase of its own (see protected), in clear otherwise. With one, the whole armored key is sealed in a password-encrypted OpenPGP message, the same format as gpg -c, which from_armored, gpg -d and sq decrypt can open.

A key with a passphrase of its own can be sealed only with that same passphrase. from_armored takes 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 the PGP MESSAGE that holds it.

Raises:
  • ConfigurationError – passphrase is an empty string, or the key has a passphrase of its own and passphrase is another one.

  • EncryptionError – Sealing with the passphrase failed.

Return type:

str

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.

passphrase has no default on purpose: writing a private key in clear should be a decision, not a default. With None the key is written as it is stored (see export_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 at path is replaced, not followed. Outside Windows the new file is private from the first byte (mode 0600): 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:
  • path (str | PathLike[str]) – Where to write the key.

  • passphrase (str | None) – Seals the key with this passphrase, or None to write it as it is stored. Required, even as None.

  • overwrite (bool) – Replace what is already at path. Off by default.

Returns:

path, as a Path.

Raises:
  • ConfigurationError – Something is already at path, even a broken symbolic link, and overwrite is False; or passphrase is 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:

Path

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=None on 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 the armored field of a message. Bot.seal does this for a whole chat, with the recipients the server lists and the same checks as an automatic send.

Parameters:
  • text (str | bytes) – The message, as str (sealed as UTF-8) or bytes.

  • recipients (Iterable[PublicKey]) – The keys to seal it to. The bot’s own key is always added.

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 – text is not str or bytes, or a recipient is not a PublicKey.

Return type:

str

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 generate never 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 from generate, the result is SEIPDv1 with AES-256 and a SHA-512 signature, not compressed.

Parameters:
  • data (str | bytes) – What to seal: str (sealed as UTF-8) or bytes (sealed exactly as they are).

  • recipients (Iterable[PublicKey]) – The keys to seal to.

  • sign (bool) – Sign with the bot’s key. False leaves the signature out.

  • armor (bool) – True returns ASCII armor as str; False returns the binary message as bytes.

Returns:

The sealed message, str when armor is True, bytes otherwise.

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 – data is not str or bytes, or a recipient is not a PublicKey.

Return type:

str | bytes

Note

The key version is not checked here. Phones on PGPainless 1.7 can’t use v6 keys, so Bot never seals to one: check PublicKey.version when 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_with holds keys, rPGP opens it a second time and checks the signature; the two engines must agree on the content. verification.status of the result is one of these SignatureStatus values:

  • VALID: signed by one of the verify_with keys, and the signature is correct. signer_fingerprint names that key.

  • UNKNOWN_KEY: signed, but by none of the verify_with keys.

  • 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_with is empty, or rPGP can’t read the bot’s key, the sender’s key, the message or that kind of signature. Except for an empty verify_with, detail says why.

A bad or missing signature does not raise: the data comes back anyway. Look at the status before you trust it, or let Bot drop such messages with verify_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. Bot uses 1 MiB for messages, and the download limit for attachments.

  • allow_compression (bool) – False refuses a compressed message before anything is inflated. Phones never compress attachments, and a compressed one could open to many gigabytes, so Bot passes False for 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_size bytes, or is compressed while allow_compression is False.

  • TypeError – data is not str or bytes.

Return type:

Decrypted

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_size limits 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 only max_size applies. With a protected key, the signature check and the compression check each unlock the key again in rPGP, which is slow by design.

Warning

VALID shows that the message was made with one of the verify_with keys 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_file or from_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:

str

Wraps a certificate that pysequoia has already read.

Most code reads keys with from_armored, from_file or from_user_key instead: 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 – data holds an armored private key block, more than one key, or no readable OpenPGP key.

Return type:

PublicKey

classmethod from_file(path)

Reads one public key from a file, armored or binary.

Parameters:

path (str | PathLike[str]) – The key file.

Returns:

The key, read as from_armored reads it. From a binary private key, only the public part is kept.

Raises:
Return type:

PublicKey

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 keys that AsyncClient.get_chat_keys returns.

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:

PublicKey

property armored: str

The key, ASCII-armored (-----BEGIN PGP PUBLIC KEY BLOCK-----).

property version: int

The OpenPGP key version, 4 or 6, told by the length of the fingerprint.

Phones on PGPainless 1.7 can’t use v6 keys. Bot never 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.encrypt does 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 by BotKey.generate("Weather", username="weatherbot"). Empty when the key has none.

property expires_at: datetime | None

When the key expires, in UTC, or None when it never expires.

This is the expiry of the key as a whole. A subkey can expire sooner: usable checks that too.

property revoked: bool

Whether the key as a whole has been revoked.

A revoked subkey alone does not count here; usable checks the subkeys too.

property expired: bool

Whether expires_at has passed, checked against the clock on each call.

property usable: bool

Whether a message can be sealed to this key now.

True when 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.encrypt refuses 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.decrypt returns it. It is read-only.

data

The decrypted bytes, exactly as they were sealed.

Type:

bytes

verification

What the signature check found. Its status is SignatureStatus.NOT_CHECKED when BotKey.decrypt got no verify_with keys. Look at it before you trust data: a bad signature does not stop decryption.

Type:

Verification

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. Bot reads that format for you and fills message.text and message.media.

property text: str

The data decoded as UTF-8, with invalid bytes replaced by U+FFFD.

Line ends stay as they were sealed: text from OpenPGP.js has CRLF.

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; None in a readable chat), and BotKey.decrypt returns one in its Decrypted. By default (verify_signatures="auto") the bot only reports what it found: a message with a bad signature, or none, still reaches the handlers. With verify_signatures="require", a message that is not valid raises SignatureError and goes to the error handlers instead.

status

What the check found.

Type:

SignatureStatus

signer_fingerprint

When status is valid, the fingerprint of the key that made the signature, in upper-case hex. None otherwise.

Type:

str | None

detail

Why the status is not valid, in a few words for a log, or None. It is written for people: branch on status, 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 valid signature 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.

property valid: bool

True when status is valid.

class SignatureStatus(*values)

What the bot could tell about the OpenPGP signature of an encrypted message.

It is the status of a Verification. For a message it receives, the bot checks the signature against the key the server lists for the sender; BotKey.decrypt checks it against the keys given in verify_with. A str enum 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.detail says 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:

str

Example

>>> format_fingerprint("3f1a9c0d52e7b8a46c110d93e2f47a559b10c6d2")
'3F1A 9C0D 52E7 B8A4 6C11 0D93 E2F4 7A55 9B10 C6D2'