Encryption Details
EtherPK encrypts synced graphs and protected documents with the algorithms, keys and formats below. See How Encryption Works for the design, the trust model and what the Sync Server can see.
Your device does all encryption, decryption and signing, whether you use EtherPK in the browser or the Headless Client, which runs the same code. It also derives every key for graph data, the vault and files. The Sync Server stores and relays ciphertext, checks signatures, reads the epoch number in each envelope header and derives two keys for the identity proof. It holds no key that can decrypt graph content, files, the vault or the Settings Vault.
Each account has a vault, a single encrypted record on the Sync Server. It holds your identity key pairs, Graph Keys and protection records, and the public keys you have saved for other people. The Settings Vault is a second record next to the vault, encrypted under the same key.
For a synced graph, the keys form a chain. The vault wrap key is derived from the Recovery Code and wraps the vault key, which encrypts the vault. The vault holds the Graph Key epoch keys, and they encrypt documents, cursors, the graph name copy and file metadata. Each file's chunks are encrypted under a file key that is stored in the file's metadata.
An unlocked device keeps the vault key, never the Recovery Code, and device approval sends that vault key to a new device. The Recovery Code only wraps the vault key, so a new code from Regenerate Recovery Code wraps the same vault key and your other unlocked devices stay unlocked.
The protection key is outside this chain. It is wrapped only under your protection passphrase and, on one device, under a passkey, so neither the Recovery Code nor an unlocked vault opens a protected document. If you forget the passphrase, there is no way to recover it.
Graph Keys move between accounts only inside sealed boxes, which are encrypted to the recipient's X25519 public key and signed with the sender's Ed25519 key. Every signature covers a length-prefixed transcript that starts with its own label, so a signature made for one purpose, account or key is not valid for another.
Everything encrypted under a shared key uses AES-256-GCM and the same envelope format. Each encryption also covers a label that names its purpose and place, so a ciphertext moved anywhere else fails to decrypt. The symmetric envelope, the sealed box, the vault and the protected document envelope each start with a format version byte and a kind byte, and EtherPK refuses any version or kind it does not know.
Algorithms
| Used for | Algorithm | Parameters |
|---|---|---|
| Encryption and key wrapping | AES-256-GCM | 256-bit key, random 96-bit nonce each time, 128-bit tag |
| Key agreement | X25519 | 32-byte keys |
| Signatures | Ed25519 | 32-byte keys, 64-byte signatures |
| Key derivation from random secrets | HKDF-SHA-256 | 256-bit output |
| Stretching a protection passphrase or device passcode | Argon2id | 19,456 KiB memory, 2 passes, parallelism 1, random 16-byte salt, 32-byte output |
| Keyed hashes for duplicate file tokens and journal entry ids | HMAC-SHA-256 | |
| Plain hashes | SHA-256 |
The Recovery Code is 128 random bits, so guessing codes against the stored vault is not feasible, and the vault wrap key comes from HKDF with no slow derivation.
Keys
| Key | Source | One per | Use |
|---|---|---|---|
| Graph Key epoch key | 256 random bits | Graph and epoch | Encrypts graph data. |
| File key | 256 random bits | File | Encrypts the file's chunks. |
| Protection key | 256 random bits | Graph and person | Encrypts protected document bodies. |
| Vault key | 256 random bits | Account | Encrypts the vault and the Settings Vault. |
| Vault wrap key | HKDF from the Recovery Code | Account | Wraps the vault key. |
| Recovery Code | 128 random bits | Account | Gives the vault wrap key. |
| X25519 identity key pair | 32-byte keys | Account | Receives sealed boxes. |
| Ed25519 identity key pair | 32-byte keys | Account | Signs the transcripts below. |
| Passphrase wrap key | Argon2id from the protection passphrase | Protection record | Wraps the protection key. |
| Passkey wrap key | HKDF from the passkey PRF output | Graph and device | Wraps the protection key. |
| Device passcode key | Argon2id from the device passcode | Device | Encrypts the vault keys cached on the device. |
| Sealed box key pair | X25519, new for each box | Sealed box | Gives the sealed box key. |
| Device approval key pair | X25519 | Device and approval request | Gives the device approval key. |
Your identity public keys are published on the Sync Server. EtherPK shows the Recovery Code once and never stores it. Nor does it store the three wrap keys, the unwrapped protection key, the sealed box and device approval keys, or the one-time key pairs.
Graph Key epochs
A Graph Key is a list of epoch keys numbered from 1, with the newest last. Envelopes under any other key carry epoch 0: the vault, the Settings Vault, file chunks, a protected document's inner envelope, wrapped protection keys, device passcode values and the device approval reply.
EtherPK reads the epoch number in each envelope header to pick the key. The number is not encrypted, so the Sync Server refuses a write under an older epoch or an epoch the graph has not reached, and a write whose stated epoch differs from its header. If the Sync Server refuses a write for using an older epoch, EtherPK collects the new key, re-encrypts the unsent edits and sends them again.
The Sync Server marks a new epoch as due when a member is removed, leaves or uses Reset Encryption Keys, or when an invite is withdrawn. It also does so when the owner uses Replace Encryption Keys or Rotate key. EtherPK on the owner's device then starts the new epoch, either from the Graphs page or when it opens the graph with the vault unlocked. Until then, new edits still use the old key.
When the new epoch starts, every member, the owner included, gets a sealed copy of every epoch key. EtherPK refuses a copy or an invite that carries a different key for an epoch number it already holds, and merges nothing from it.
Starting a new epoch re-encrypts nothing, so members keep the old epoch keys to read history and earlier files. A file stays under the epoch it was uploaded in, and a document moves to the newest epoch the next time it is compacted into a snapshot.
Journal entry ids are derived from the epoch 1 key, so a day has the same id on every device and in every epoch. Duplicate file tokens are derived from the newest epoch key, so a file stored before the newest epoch is uploaded again.
Key derivations
| Key | Input | Salt | Info |
|---|---|---|---|
| Vault wrap key | The 16 bytes of the Recovery Code | etherpk-vault-v1 |
vault-wrap |
| Sealed box key | Shared secret of the one-time private key and the recipient's public key | The one-time public key, then the recipient's public key | etherpk/sealed/v1 |
| Device approval key | Shared secret of the two one-time keys | The 32-byte approval transcript | etherpk/device-approval/reply/v2 |
| Duplicate file secret | Newest epoch key | Graph id | etherpk/asset-dedup/v1 |
| Journal entry id secret | Epoch 1 key | Graph id | etherpk/journal-doc-id/v1 |
| Identity proof key | Shared secret of your X25519 key and the key the Sync Server offers | The offered key, then your X25519 public key | etherpk/identity-proof/v1 |
| Key the Sync Server offers | A secret the Sync Server keeps | Empty | etherpk/identity-proof-key/v1| and the account id |
| Passkey wrap key | Passkey PRF output | etherpk-protection-device-v1 |
protection-device-wrap |
Info strings and salts are UTF-8 text, except the sealed box, device approval and identity proof salts, which are raw key or hash bytes. Argon2id takes the passphrase or passcode as UTF-8.
To make a sealed box, EtherPK generates a one-time X25519 key pair, derives the sealed box key and encrypts with AES-256-GCM under a new random nonce.
The identity proof key is used when an account with keys from an earlier version publishes its first Ed25519 key. Anyone could compute a key from an all-zero X25519 shared secret, so EtherPK refuses such a secret in the identity proof and in device approval. The Sync Server also refuses it in the identity proof.
Signed transcripts
Each part of a transcript, starting with the label, is preceded by its length as a 4-byte big-endian number. Numbers are written as decimal text, and text as UTF-8.
| Transcript | Label | Fields after the label |
|---|---|---|
| Key write | etherpk/key-write/v1 |
Account id, vault version replaced, SHA-256 of the vault record, identity hash of the published keys (empty if none) |
| Settings Vault write | etherpk/settings-vault-write/v1 |
Account id, version replaced, SHA-256 of the Settings Vault envelope |
| Key replacement | etherpk/key-replace/v1 |
Account id, vault version replaced, SHA-256 of the vault under the new vault key, identity hash of the new keys. Signed with both the old and the new Ed25519 key. |
| Invite | etherpk/invite/v1 |
Graph id, inviter id, invitee id, invitee's identity hash, SHA-256 of the sealed box |
| Graph Key copy | etherpk/key-handout/v1 |
Graph id, epoch, owner id, recipient id, X25519 key sealed to, SHA-256 of the sealed box |
The identity hash is SHA-256 over the text etherpk/identity-fingerprint/v2, then the 32-byte X25519 and Ed25519 public keys, in that order, with no length prefixes.
When EtherPK receives an invite or a Graph Key copy, it rebuilds the transcript from its own account id and keys. EtherPK and the Sync Server verify Ed25519 signatures with the same library under strict RFC 8032 rules (zip215: false), so they accept exactly the same signatures. Under these rules, both refuse non-canonical encodings and small-order public keys.
Associated data
Each label is UTF-8 text made of etherpk:v1 followed by its parts, separated by |.
| Key | Encrypts | Label |
|---|---|---|
| Graph Key | Document update or snapshot | etherpk:v1|update|graph:<graph id>|doc:<document id> |
| Cursors and selections | etherpk:v1|presence|graph:<graph id>|doc:<document id> |
|
| Graph name copy | etherpk:v1|graph-name|graph:<graph id> |
|
| File metadata | etherpk:v1|asset-meta|graph:<graph id>|id:<file id> |
|
| File key | File chunk | etherpk:v1|asset|id:<file id>|chunk:<chunk number from 0> |
| Vault key | Vault contents | etherpk:v1|vault|v3 |
| Settings Vault contents | etherpk:v1|settings-vault|v1 |
|
| Vault wrap key | Vault key | etherpk:v1|vault-key |
| Sealed box key | Invite | etherpk:v1|keyring-invite|graph:<graph id> |
| Graph Key copy | etherpk:v1|key-handout|graph:<graph id>|epoch:<epoch>|recipient:<account id> |
|
| Device approval key | Vault key | etherpk:v1|device-approval|v2|approval:<request id> |
| Device passcode key | Check value | etherpk:v1|device-passcode|check |
| Cached vault key | etherpk:v1|device-passcode|secret|<storage name of the cached key> |
|
| Passphrase wrap key | Protection key | etherpk:v1|protection-key|passphrase |
| Passkey wrap key | Protection key | etherpk:v1|protection-key|device |
| Protection key | Protected document | etherpk:v1|protected|<write time in ms>|<key fingerprint in base64url> |
Envelope formats
Symmetric envelope
0 format version 1
1 kind 1
2 to 5 epoch number, unsigned 32-bit little-endian, 0 where there is none
6 to 17 nonce
18 on ciphertext, then the 16-byte tag
Sealed box
0 format version 1
1 kind 2
2 to 33 one-time X25519 public key
34 to 45 nonce
46 on ciphertext, then the 16-byte tag
Vault
0 format version 1
1 kind 5
2 to 3 length of the wrapped vault key, unsigned 16-bit big-endian
4 on vault key in a symmetric envelope under the vault wrap key,
then the vault contents in a symmetric envelope under the vault key
Protected document envelope
0 format version 1
1 kind 4
2 to 9 write time, milliseconds since 1970, unsigned 64-bit big-endian
10 to 17 key fingerprint
18 to 21 length of the inner envelope, unsigned 32-bit big-endian
22 on symmetric envelope under the protection key
A value encrypted under the device passcode key is the text pc1: followed by a symmetric envelope in base64url.
The graph name copy is padded to a multiple of 64 bytes before it is encrypted under the newest epoch key. The padded form is the name length as a 2-byte big-endian number, then the UTF-8 name, then zero bytes.
Vault contents
The vault contents are JSON and hold:
- The identity key pairs, in base64url.
- Each Graph Key, as the graph id and each epoch number with its key.
- The protection records of synced graphs, by graph id.
- Other people's X25519 and Ed25519 public keys, by account id, with their email address, the time saved, and whether you compared fingerprints or took the keys on first use.
Settings that follow you between devices live in the Settings Vault, and any that an earlier version kept in the vault move there at the next sync.
EtherPK still opens an older vault of kind 3, and writes it as kind 5 the next time it saves the vault.
Files
Each file is split into 4 MiB chunks, and each chunk is a symmetric envelope under the file key. The file's metadata is JSON, encrypted under whichever Graph Key epoch is newest when the file is uploaded. It holds the file name and type, the SHA-256 hash of the contents in hex, whether the file is an image, the file key, the size and the chunk count. Chunks are stored as <graph id>/<file id>/<chunk number>, where the file id is random and never the file name or hash.
When EtherPK downloads a file, it checks the chunk count against the metadata first, then joins the chunks and checks the size and hash. For a file uploaded by an earlier version, it checks the hash only.
Every upload carries a duplicate file token, except an upload queued while offline. If the Sync Server already has a file with that token, EtherPK reuses that file when its metadata has the same hash, and otherwise uploads a fresh copy without the token.
Protected documents
A protection record is JSON and holds no secret:
| Field | Value |
|---|---|
v |
1 |
fingerprint |
Key fingerprint, base64url |
kdf |
Argon2id settings: m memory in KiB, t passes, p parallelism, salt in base64url |
wrapped |
The protection key under the passphrase wrap key, base64url |
A synced graph keeps its record in the vault, under the graph id, and a local graph keeps it in etherpk/protection.json in the graph folder.
A passkey wrap is the protection key wrapped under the passkey wrap key, stored with the fields v, fingerprint, credentialId and wrapped. It is kept in IndexedDB in one browser, with a backup copy in local storage, and neither copy is synced or exported. The PRF input is the UTF-8 text etherpk:protection-prf:v1: followed by the graph id.
A protected document's body is one fenced code block with the info string etherpk-cipher, holding one base64url envelope per line. Frontmatter stays above the fence and is not protected.
If two devices edit a protected document offline, the fence can end up with several envelopes, one per line or end to end, and EtherPK reads each one. Every client keeps the envelope with the latest write time (the write time can be read without the key) and breaks a tie by comparing the bytes. The edits in the other envelopes are discarded.
The key fingerprint is the first 8 bytes of SHA-256 over the text etherpk:protection-fingerprint:v1 followed by the protection key. EtherPK uses it to tell whose protection key a document needs.
Codes and fingerprints
A Recovery Code is 16 random bytes written in Crockford base32, which uses the alphabet 0123456789ABCDEFGHJKMNPQRSTVWXYZ. EtherPK shows it as EPK1- followed by 26 characters in groups of 5, 5, 5, 5 and 6, for example EPK1-4R9TN-0WXA2-JH6MK-P3VZE-C58DQW. When you type a code, EtherPK ignores case, spaces and hyphens, and reads O as 0 and I and L as 1. What remains must be EPK1 followed by 26 characters.
The security fingerprint is the first 16 bytes of the identity hash, written as 32 upper-case hex digits in eight groups of four, separated by spaces.
Device approval uses three values:
- The commitment is SHA-256 over the text
etherpk/device-approval/commit/v2followed by the one-time public key of the new device. - The transcript is SHA-256 over these length-prefixed parts: the label
etherpk/device-approval/transcript/v2, the request id, the commitment, and the one-time public keys of the approving device and then the new device. - The code is the first 40 bits of the transcript in Crockford base32, as 8 characters in two groups of four, such as
7Q4M-KX2A.
The new device commits to its one-time key before it sees the other key. A server that substitutes keys can therefore get matching codes only by chance, with odds of 1 in 2^40 for each attempt.
Source code
The code is in the etherpk-client repository, under these paths:
apps/client/src/lib/crypto: envelopes, sealed boxes, signatures, epochs, both vaults, the Recovery Code, codes, fingerprints and protection.apps/client/src/lib/sync: key creation, invites, saved public keys, new epochs, device approval, the device passcode, document sync, the graph name copy and Settings Vault writes.apps/client/src/lib/storage/synced: file encryption, decryption and checks.packages/shared/src/sync-identity.ts: the signed transcripts, shared by EtherPK and the Sync Server.packages/shared/src/envelope-header.ts: the symmetric envelope header.
Report security problems privately. See Help And Community for more information.