La especificación se publica en inglés (traducción del original ruso canónico) - es el documento con el que se prueban la aplicación y la extensión del navegador.

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
  • sentAt is 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. Flag bit1 is 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": ...}; the data field is optional, meta may consist of a single type). 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 in tools/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.jstest_vectors.json test/protocol_cross_test.dart, extension/src/crypto/*.test.ts
App writes flutter test tools/test_vectors/generate_app_fixtures.dartextension/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.