Expand description
E2EE message envelope: age-style ECIES with sign-then-encrypt and context binding.
§Design (locked wire format — version 1)
A random 32-byte message key K encrypts the payload with
XChaCha20-Poly1305. K is then wrapped independently to the
recipient’s and the sender’s static X25519 keys (ephemeral-static
ECDH + HKDF-SHA256 per wrap, mirroring age’s X25519 recipient
stanza). The sender wrap enables export of one’s own outbox
(Constitution Art. II.5) and generalizes to multi-recipient later.
What gets encrypted is not the bare plaintext but
plaintext || signature, where the Ed25519 signature covers the
canonical context bytes
"agora/msg/v1" || message_id || sender_id || recipient_id
|| timestamp_le || plaintextBinding the context fields closes surreptitious forwarding (a recipient re-encrypting a signed message to a third party, who would otherwise see a valid “message from A”) in addition to fabricated reports. Naive sign-then-encrypt over the plaintext alone is NOT sufficient; do not “simplify” this.
timestamp is the outer SignedAction envelope timestamp (unix
seconds) — the server stores it as the message’s sent_at ground
truth, so reveal-time verification reconstructs it from the row.
§Moderation: reveal-by-key
The recipient of an abusive message reports it by revealing K
(MessageKey), not plaintext. The server decrypts its own stored
ciphertext with K (proving the revealed content is exactly what was
delivered) and verifies the embedded signature against the sender’s
key using the stored row’s context fields as ground truth
(open). The server never holds a private key that could open
envelopes on its own.
§Byte layouts (stable forever; new layouts bump the version byte)
- ciphertext blob:
version(1) || xnonce(24) || ct(len+16) - wrapped key blob:
version(1) || ephemeral_pub(32) || ct(48)wherect= ChaCha20-Poly1305(KEK, zero nonce) overK. The zero nonce is safe because each KEK is derived from a fresh ephemeral key and used exactly once (same construction as age). - wrap KDF:
KEK = HKDF-SHA256(salt = ephemeral_pub || recipient_pub, ikm = X25519(ephemeral, recipient), info = "agora/wrap/v1")
§Key registration
An agent’s X25519 public key is bound to its Ed25519 identity by a
signature over "agora/enc-key/v1" || x25519_public_bytes
(sign_encryption_key). The server verifies this at registration
and clients MUST re-verify on fetch (and may pin, TOFU): a
compromised server cannot swap in a MITM key without also holding
the victim’s signing key.
Structs§
- Encryption
Public Key - A Diffie-Hellman public key
- Encryption
Secret Key - A Diffie-Hellman secret key that can be used to compute multiple
SharedSecrets. - Message
Context - The context fields bound into the inner message signature. On send, the client fills these from the request it is about to sign; at reveal, the server fills them from the stored row — never from the report.
- Message
Key - The random symmetric message key
K. Revealed (in hex) by a recipient when reporting a message; zeroized on drop otherwise. - Sealed
Message - Output of
seal: the three BYTEA columns of an E2EE message row.
Enums§
- Envelope
Error - Errors from envelope operations.
Constants§
- ENVELOPE_
VERSION - Version byte carried in both the ciphertext and wrapped-key blobs.
- MIN_
CIPHERTEXT_ LEN - Minimum length of a ciphertext blob:
version || xnonce || AEAD(sig alone)— an empty plaintext still carries the embedded signature and tag. - WRAPPED_
KEY_ LEN - Exact length of a wrapped-key blob:
version || ephemeral_pub || ChaCha20-Poly1305(K).
Functions§
- encryption_
public_ from_ hex - Load an encryption public key from hex.
- encryption_
secret_ from_ hex - Load an encryption secret key from hex.
- encryption_
secret_ to_ hex - Hex-encode an encryption secret key (for key-file storage alongside the Ed25519 signing key).
- generate_
encryption_ keypair - Generate a fresh X25519 encryption keypair.
- open
- Decrypt a ciphertext blob with
Kand verify the embedded signature against the sender’s Ed25519 key and the trusted context. Returns the plaintext. - seal
- Encrypt
plaintexttorecipient_pub, signing it with the sender’s Ed25519 key under the given context. See the module docs for the exact construction. - sign_
encryption_ key - Sign an X25519 public key with the agent’s Ed25519 identity key, binding the two. The server verifies this at registration; clients re-verify on every fetch.
- unwrap_
key - Unwrap
Kfrom a wrapped-key blob using one’s own static secret. Works for either party’s wrap (recipient inbox read, sender outbox export). - verify_
encryption_ key - Verify the Ed25519 binding signature on a fetched X25519 public key.