Mail Envelope¶
Mail is the signed envelope every protocol-level message travels in. Signing is mandatory. Encryption is opt-in.
Implementation: fp/mail.py, fp/core/cryptor.py.
Seal — building an outbound envelope¶
# fp/mail.py
@classmethod
def seal(
cls,
sender: FPAddress,
recipient: FPAddress,
message: Message,
sign_private_key: str,
encrypt_public_key: str | None = None,
) -> Mail:
...
Two paths:
encrypt_public_key |
Result |
|---|---|
None |
Signed plaintext mail. mail.message is the Message object. |
| Provided | Encrypt first (X25519 + AES-GCM), then sign the encrypted form. mail.message is a ciphertext string. |
In both paths, the final mail carries an Ed25519 signature over the canonical signable bytes.
Canonical signable bytes¶
Signatures are over a stable JSON form of the envelope (not the raw object):
data = {
"sender": str(self.sender),
"recipient": [str(r) for r in self.recipient],
"message": self.message.model_dump()
if hasattr(self.message, "model_dump")
else str(self.message),
}
return json.dumps(data, sort_keys=True).encode("utf-8")
sort_keys=True ensures byte-stable serialization regardless of dict ordering. The signature covers sender, recipient list, and the message payload — including the encrypted form when encryption is used. Tampering with any of these invalidates the signature.
Unseal — verifying and decrypting¶
def unseal(
self,
verify_public_key: str | None = None,
decrypt_private_key: str | None = None,
) -> Mail | None:
...
Behavior depends on whether the message is encrypted:
Encrypted mail (message is a string)
- Both
verify_public_keyanddecrypt_private_keyare required. - Verify the Ed25519 signature.
- Decrypt with the provided X25519 private key.
- Return a new
Mailwith the decryptedMessage, orNoneon any failure.
Plaintext mail (message is a Message object)
- If an explicit
verify_public_keyis supplied, use it. - Otherwise, attempt to extract
sender_card.sign_public_keyfrom the payload — this enables first-contact flows like friend requests where the recipient does not yet hold the sender's key. - If no key is available, return
None. - Verify the signature; return
Noneon failure.
Encryption — X25519 + HKDF-SHA256 + AES-GCM¶
When encryption is enabled, X25519EncryptorDecryptor produces a self-describing JSON payload:
{
"v": 1,
"alg": "X25519+AESGCM",
"epk": "<base64 ephemeral X25519 public key>",
"salt": "<base64 16-byte salt>",
"nonce": "<base64 12-byte nonce>",
"ct": "<base64 AES-GCM ciphertext>"
}
Properties:
- Forward secrecy at the message level — every encrypt call generates a fresh ephemeral X25519 keypair (
epk). - The AES-256 key is derived via HKDF-SHA256 with a fresh 16-byte salt and the context string
b"fp/x25519-aesgcm/v1". - The 12-byte nonce is randomly generated per message.
- The recipient's long-term X25519 private key plus the ephemeral public key reconstruct the shared secret, then HKDF rederives the AES key.
The ephemeral key is discarded after encryption, so compromise of a recipient's long-term key does not retroactively expose past messages that used distinct ephemeral keys — provided the long-term key is not used to derive the AES key directly.
Failure modes¶
Mail.unseal() returns None on every failure mode, including:
- Missing signature
- Invalid signature
- Missing verify key (and no
sender_cardin payload) - Missing decrypt key on an encrypted mail
- Decryption / AES-GCM tag check failure
This is intentional — a failed unseal is indistinguishable from a malformed or hostile envelope; the receiving entity simply drops the message.
Next: how mail moves between hosts — Federation & Friends.