Secret Keeper Protocol v1
Specification of the cryptographic protocol shared by the app, the browser extension and the site. Compatibility with the legacy RSA/node-forge scheme is intentionally absent.
1. Mnemonic (BIP-39)
- 12 words, English BIP-39 wordlist (2048 words).
- Generation: 128 bits of entropy + 4 checksum bits (SHA-256).
- Validation: wordlist + checksum (instant, offline).
- Seed:
PBKDF2-HMAC-SHA512(password=NFKD(mnemonic), salt=NFKD("mnemonic"), iterations=2048, dkLen=64). - Passphrase: empty string (default).
2. Key derivation
From the 64-byte BIP-39 seed via HKDF-SHA256 (empty salt):
| Key | info | length |
|---|---|---|
| X25519 private | secret-keeper/x25519/v1 |
32 |
| Ed25519 private | secret-keeper/ed25519/v1 |
32 |
Public keys - standard X25519 / Ed25519 derivation. For X25519 the RFC 7748 clamp is applied to the 32 bytes of HKDF output before using them as the private scalar:
key[0] &= 248
key[31] &= 127
key[31] |= 64
Ed25519 is reserved for future features (signatures); it is not used in the envelope.
3. Identity
- Address = bech32 encoding of the 32-byte X25519 pubkey.
- HRP (human-readable part):
sk. - Example:
sk1q3xz...(~60 characters). - Addresses are exchanged as text or QR; no server directory is needed.
- Safety numbers (visual pair verification):
SHA-256(min(pubkey_a, pubkey_b) || max(pubkey_a, pubkey_b))(lexicographic byte order - both peers get the same number) → 25 decimal digits in 5 groups of 5. Not used for addressing.
4. Envelope (key slots)
The payload is encrypted once with a random message_key, which is then
"wrapped" into a slot for each recipient. The sender's slot is added
always - so the sender decrypts their own messages with the same decrypt
operation (chat history, second device). Up to 255 recipients (groundwork
for groups).
4.1. Binary format
version : u8 = 0x02
sender_pub: 32 bytes (X25519)
eph_pub : 32 bytes (X25519 ephemeral)
slot_count: u8 (>= 1; recipients + sender, no duplicates)
slots : 48 bytes x slot_count
nonce : 24 bytes (random)
ciphertext: variable (payload + 16-byte Poly1305 tag)
Slots are anonymous (the envelope carries no addresses); their order is shuffled and means nothing.
Version 0x01 (payload was raw UTF-8 text without framing) is rejected with
"Unsupported envelope version": payload formats cannot be reliably
distinguished within a single version.
4.1.1. Payload (inside AEAD)
flags : u8 bit0 = meta present; bit1 = recipient list present;
remaining bits are reserved (0; non-zero → parse failure)
sentAt : u64 BE when the envelope was created: Unix epoch milliseconds, UTC
metaLen: u32 BE only when flags & 1
meta : metaLen bytes of UTF-8 (only when flags & 1)
toCount: u8 only when flags & 2 (>= 1)
to : toCount entries (only when flags & 2), each:
len u8 + bech32 address in UTF-8
text : remainder, UTF-8
sentAtis always present; set by the sender, the recipient may show it as the message time.to- the envelope's recipients without the sender: when the sender reads their own envelope (history, second device), this tells them which chat it belongs to. Flagbit1is set on every message except a message to self (which has no recipients besides the sender). The list lives inside the AEAD: recipients are not visible from outside; only slot owners can read them.text- what is shown to the user. May contain markdown (bold/italic, lists, links, GFM tables): a client either renders it or strips the markup when displaying (the browser extension does the latter).meta- an opaque string for the recipient's automation (convention - JSON of the form{"type": ..., "data": ...}; thedatafield is optional, meta may consist of a singletype). It is not shown in the feed and is available through the message's "Metadata" menu item. Encrypted and authenticated together with the text - from the outside even its length is invisible, only the total ciphertext size.
4.2. Slot and KEK
A slot is the message_key (32 bytes) encrypted with AEAD under the
recipient's KEK with a zero 24-byte nonce (the KEK is single-use - an
ephemeral key per envelope):
slot = AEAD(message_key, KEK_i, nonce=0) // 32 + 16 (tag) = 48 bytes
shared1 = X25519(eph_priv, R_i_pub)
shared2 = X25519(sender_static_priv, R_i_pub)
KEK_i = HKDF-SHA256(shared1 || shared2, salt=empty,
info="secret-keeper/kek/v1", len=32)
The formula is symmetric: the reader computes
HKDF(X25519(my_priv, eph_pub) || X25519(my_priv, sender_pub)) and tries to
unwrap every slot - foreign slots are rejected by the AEAD tag. The sender's
slot uses the same formula (self-ECDH); there is no separate code path.
Sender authentication: without sender_static_priv it is impossible to
produce a valid shared2.
4.3. AEAD
- Algorithm: XChaCha20-Poly1305 (both body and slots).
- AAD: none (empty).
- Body nonce: 24 random bytes per message; slot nonce is zero.
4.4. Armor (text wrapping)
-----BEGIN SECRET MESSAGE V1-----
<base64(binary envelope)>
-----END SECRET MESSAGE V1-----
- Base64: standard, no line breaks.
- Whitespace around the base64 is tolerated when parsing.
- Reading is tolerant to surrounding text: the markers are located anywhere inside an arbitrary string (envelopes are often forwarded wrapped in messenger quotes). Clients emit and store only the canonical BEGIN…END block - the wrapper is stripped before decryption/storage.
5. File container .skf
A file is encrypted with the same slot-based envelope, but without armor and with the body in chunks - so encryption/decryption streams regardless of file size.
magic : "SKF1" (4 bytes)
version : u8 = 0x01
sender_pub: 32 bytes
eph_pub : 32 bytes
slot_count: u8 (>= 1)
slots : 48 bytes x slot_count // same formula as the envelope
nonce_pfx : 19 bytes (chunk nonce prefix)
header_len: u32 BE (16 <= len <= 65536; ciphertext + tag)
header_nnc: 24 bytes
header : header_len bytes - AEAD(message_key) over the JSON header
chunks : per chunk: ciphertext + 16-byte tag
The JSON header (encrypted, so the file name is not visible from outside):
{"name": "...", "size": 12345, "chunk": 1048576,
"sentAt": 1784191445123, "note": "...", "to": ["sk1..."]}
note and to are optional; to is the same recipients-without-sender
list as in the envelope payload.
Chunk i nonce: nonce_pfx (19) || counter u32 BE || final byte (0x01 for
the last chunk, 0x00 otherwise) - the STREAM construction (age/Tink): the
counter catches chunk reordering and duplication, the final byte catches
file truncation. Chunk count is ceil(size / chunk); an empty file has one
empty final chunk, so truncation "to zero chunks" does not pass either.
Data after the last chunk is a sign of tampering; parsing must fail.
6. Backups
| Format | Contents |
|---|---|
.sk1 |
JSON: {type: "secret_keeper_backup", version, contacts[], settings} |
.skb (v2, current) |
a .skf container (§ 5) encrypted to self; the plaintext is the zip of the archive folder (same as legacy) |
.skb (legacy) |
bare zip of the archive folder: meta.sk1e at the root + per-peer folders |
The container wrapper hides the zip metadata: entry names are peer
addresses, and messages.ndjson exposes message times and directions in
the clear - the contact graph and timings must not be readable without the
keys. Clients write v2 only; reading distinguishes the formats by content
(SKF magic vs "PK"), so legacy backups keep importing. The encrypted
container header's name field ends with .skb - that is how the app
tells a backup apart from a regular file container when a file is opened
from the OS. A container from another profile does not unwrap (no slot is
ours) - the same "backup from a different profile" refusal as meta.sk1e
in legacy.
meta.sk1e is an armored envelope encrypted to self; its plaintext:
{"version": 1, "settings": {...}, "contacts": [...],
"avatars": {"sk1...": "<base64 png>"}}
If it does not decrypt with your key, the backup was taken from a different profile (different seed) - and that is the only way to find out: the envelope carries no addresses.
A peer folder contains messages.ndjson (one line per message:
{"at": ms, "out": bool, "sha": "...", "armored": "..."} or
{"at": ms, "out": bool, "sha": "...", "skf": "<id>.skf"}) and the .skf
containers themselves. A client without a history model (the browser
extension) imports only settings and contacts from the archive and
ignores the rest.
Secrets (seed phrase, PIN) are never in the backup: the profile is restored from the seed phrase.
7. Properties
- PFS: an ephemeral X25519 key per message.
- Sender reads their own: a sender slot in every envelope.
- No state: no pairwise keys, no server-side AES storage.
- No user IDs: the address is the public key.
- Cross-platform: Dart (
cryptography), JS (@noble/*,@scure/*) - shared test vectors intools/test_vectors/.
8. Implementation stack
| Platform | Libraries |
|---|---|
| Flutter | cryptography, crypto (PBKDF2/HKDF fallback) |
| Browser plugin / site | @scure/bip39, @noble/curves, @noble/ciphers, @noble/hashes, @scure/base, fflate (unzip .skb) |
Test vectors verify the format in both directions, and every format change
(a new payload flag, a new .skf header field) is added to both
generators; otherwise an implementation divergence goes unnoticed, as happened with
the to flag: the app had been writing it for almost a week while the
extension rejected such envelopes, because automated tests ran in one
direction only.
| Direction | Generator | Readers |
|---|---|---|
| JS writes | tools/test_vectors/generate.js → test_vectors.json |
test/protocol_cross_test.dart, extension/src/crypto/*.test.ts |
| App writes | flutter test tools/test_vectors/generate_app_fixtures.dart → extension/test/fixtures/app_fixtures.json |
extension/src/crypto/app_fixtures.test.ts |
App fixtures are produced by the app's own code (envelopes, .skf, .skb
backups in both formats from ArchiveStore); keys and nonces are random -
the file changes entirely on regeneration, which is expected.