How Pyunto encrypts diaries
This page describes the encryption the Pyunto apps and pyunto-agent use, what our servers can and cannot see, and where the protection currently falls short. Last updated 2026-09-28.
1. Keys
| Key | What it is | Where it lives |
|---|---|---|
| Space key | 32-byte AES-256-GCM key, one per diary space. It does not change over the life of the space. | Members' devices only (iOS Keychain, Android EncryptedSharedPreferences, Windows/macOS secure storage). |
| Identity key | X25519 key pair, one per account. The public key is registered with the server. | Private key on the device only. |
| Backup key | Random 32-byte key that encrypts the account's key backup. | On the device, and on the server only in wrapped (encrypted) form. |
A new space's key is generated on the device of the person who creates it.
2. What is encrypted
Encrypted with the space key before it leaves the device:
- Entry text, including reactions and stickers, which are carried inside the entry text
- Thread titles
- Photos, videos and attached files
- Quick-list definitions
Format: AES-256-GCM with a random 12-byte nonce per message. The 16-byte tag is appended to the ciphertext. Values are standard base64 with padding and no line breaks.
{ "ciphertext": "<b64(ct || tag16)>", "nonce": "<b64(12 bytes)>" }
The apps refuse to send an entry if encryption fails. There is no plaintext fallback.
3. Distributing a space key
When someone joins a space, a member who already has the key seals it to the newcomer's identity public key (a "sealed box") and uploads the envelope. The server checks that the recipient is a member and that the public key matches, stores the envelope, and forwards it. It cannot open it.
- Key agreement: X25519 between a fresh ephemeral key pair (new for every envelope) and the recipient's identity key
- Key derivation: HKDF-SHA256, empty salt, empty info, 32-byte output
- Encryption: AES-256-GCM, 12-byte nonce, tag appended
- Sealed plaintext: the space key as a base64 string, encoded as UTF-8
{ "ciphertext": "<b64(ct || tag16)>", "nonce": "<b64(12 bytes)>",
"ephemeralPublicKey": "<b64(32-byte X25519 public key)>" }
A device that finds no key for a space it did not create waits for an envelope. It does not generate a new key, because two keys for one space would leave members unable to read each other.
4. New devices and key backup
Registered accounts can restore their keys on a new device. Anonymous accounts cannot: their keys exist only on the device, by design.
- The backup contains the identity key pair and the account's space keys. It is encrypted with the backup key (AES-256-GCM).
- The backup key is stored twice, wrapped by two different keys. One is derived from the account password, the other from a recovery code shown once at sign-up.
- Password and recovery-code derivation: PBKDF2-HMAC-SHA256, 210,000 iterations, random 16-byte salt, 32-byte output. The iteration count is stored with the backup so it can be raised later.
- Recovery code: 32 characters of Crockford base32. 28 characters (140 bits) come from a secure random generator, and the last 4 are a checksum (the first 20 bits of SHA-256 over the first 28 characters).
The server stores only ciphertext, nonces and salts. Logging in checks the password with the server, but the backup is opened on the device. The server refuses to let a new identity key silently replace an existing one on an account that has a backup, so that failing to restore cannot quietly orphan the member's envelopes.
5. Agents and robots
An AI agent or a robot is an ordinary member. It has its own account and identity key, and it receives the space key in an envelope like anyone else. It decrypts on the computer its operator runs, not on our servers.
- Whoever controls that computer can read the spaces the agent was invited into. The app shows who runs the agent before a person approves it.
- If the agent sends entries to a cloud language model, that provider receives the text.
- pyunto-agent is an open-source (Apache-2.0) implementation of the client side of this protocol.
crypto.py,keys.pyandidentity.pyin the repository implement the entry and envelope formats above, and its tests include the sealed-box vector below. It does not implement the key backup.
6. What our servers can see
Encryption protects content. It does not hide who is talking to whom. Our servers can see:
- Account details: display name, and the email address if the account is registered
- Space names and space membership
- Who posted in which space and thread, and when, and the size of each entry and file
- Which members an entry notifies or mentions, so notifications can be delivered
- Device push-notification tokens and the IP addresses that connect
- Subscription status
The on-device assistant in the apps runs locally. Diary text is not sent to a server for it.
7. Known limitations
- No key rotation on removal. Removing a member does not change the space key. The server stops sending that person new entries, but it would not stop someone who kept the key and obtained the ciphertext another way.
- No forward secrecy. A space key that leaks decrypts all past and future entries in that space.
- No key verification between members. Members cannot yet compare safety numbers. A malicious server could register a fake identity key for a member and receive envelopes meant for them.
- Multiple devices share one identity key. A device that skips restoring from the backup creates a new identity, and later envelopes go to the most recently registered identity.
- The apps and the server are not open source. The client-side protocol can be checked in pyunto-agent. The apps themselves cannot yet be independently audited.
8. Test vectors
These fixed keys are for tests only.
Sealed box
Opening the envelope with the recipient's private key yields the UTF-8 string space_key_b64.
{
"recipient_private_key_b64": "oKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u7y9vr8=",
"recipient_public_key_b64": "YFpyXSpK3+6xop4X7dYhwbdZPujNvESsbEq24vgF0jw=",
"space_key_b64": "ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8=",
"shared_secret_b64": "O4fFA1zOE2eN2T0tvDo9QgOevshkRKH/gs1huExv6Wg=",
"hkdf_aes_key_b64": "JJuh9KbTCk9rZBgzD3HEUzLbmPjopoKDxokXbwrhlqU=",
"envelope": {
"ciphertext": "5M2pGTniayZngYlc8P+gWApaYFqd2XxYd69c011bXv4Vp0C9ecEIx4M0r/WZ4W/nByXhPAKhYnUd1tHb",
"nonce": "AAECAwQFBgcICQoL",
"ephemeralPublicKey": "3CzKMejkO72R3/fkdcyjNH60eBB9W9dlq6SuSjDDXUQ="
}
}
PBKDF2
{ "password": "test-password", "salt_b64": "AAAAAAAAAAAAAAAAAAAAAA==",
"iterations": 210000, "derived_key_b64": "HEMuAuWBVwJYl9oC74okXWLIl7BFZ5UJHM6WmknhOtw=" }
Recovery-code checksum
{ "body": "ABCD2345EFGH6789JKMN0123PQRS", "checksum": "49CP",
"full_code": "ABCD2345EFGH6789JKMN0123PQRS49CP" }
9. Reporting a vulnerability
Please report security problems privately through GitHub's private vulnerability reporting. Do not open a public issue. Reports in English or Japanese are welcome.
- In scope: the Pyunto apps,
api.pyunto.com, pyunto-agent and pyunto-robotics. - Please do not access other people's data, degrade the service, or test against accounts that are not your own.
- We will not pursue legal action over good-faith research that follows these rules.