স্পেসিফিকেশনটি ইংরেজিতে প্রকাশিত (ক্যানোনিকাল রুশ মূলের অনুবাদ) - এই নথির ভিত্তিতেই অ্যাপ ও ব্রাউজার এক্সটেনশন পরীক্ষা করা হয়।

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.
  • Known meta conventions: type: "sk-login" / "sk-login-challenge" / "sk-login-code" - signing in to a third-party service via Secret Keeper (§ 4.5).

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.

4.5. Sign-in via SK (sk-login)

Secret Keeper acts as an authenticator for a third-party service: the service shows a QR/link https://secretkeeper.net/auth?v=1&sid=<...>&target=<id> (or sk://auth?...), and after explicit user consent SK proves to the service's server that it owns the key of its address. The endpoint URL and the server's sk1 address come only from the client's built-in target list, never from the payload - they cannot be swapped via the QR (QRLjacking); only v, sid, and target are read from the payload.

The transport is minimal: the parties exchange raw armored envelopes (POST with Content-Type: text/plain; charset=utf-8, the body is the envelope as-is) - no JSON wrappers and no escaping. All step semantics live inside the envelope: meta.data of every step carries {"target":<id>,"v":1,"sid":<sid>} - the sid sits inside the AEAD (authenticated) and is never sent as an open transport field.

Steps (all envelopes are regular § 4.1 envelopes, distinguished by meta.type):

  1. SK → server: POST of the request envelope to the target's endpoint. Recipient - the server's address, text is empty (all step data lives in meta), meta.type = sk-login. The server takes the sid from meta and the account from the sender.
  2. Server → SK (2xx HTTP response body): the challenge envelope. Recipient - the sender of step 1, text = a short one-time code, meta.type = sk-login-challenge, data.sid - the same sid. SK detects the challenge by the armor markers in the response body; a body without an envelope (empty, any other text) is a one-step success (the server considered step 1 sufficient).
  3. SK → server: POST of the code envelope to the same endpoint. text = the code from the challenge, meta.type = sk-login-code, data.sid - the same sid. The server compares the code against the one issued for this sid and activates the service session. Response: 2xx - done; 2xx with the JSON body {"sent":false} - the code was accepted but the event could not be delivered to the service's client (push/SignalR) - SK shows the code to the user (any other body means delivered); 4xx - the code/sid has expired. If the step-3 POST fails (network/5xx), SK also shows the code - typing it manually into the service's client proves the same thing through the same verifier.

Mandatory server checks: successful decrypt (that alone authenticates the sender), meta type and target match the step, the sid from meta is live and single-use, the code is single-use with a TTL and an attempt limit (a short code is brute-forceable) and is bound to its sid and sender. Distinct type values prevent feeding one step's envelope to another step. SK, in turn, accepts a challenge only from the server identity in the built-in list and only with the data.sid of its own request (a safeguard for concurrent logins).

Security model:

  • No signature needed. Sender authentication is a property of the KEK construction (§ 4.2, the NaCl crypto_box model): a slot the server managed to unwrap could only have been built by the owner of sender_static_priv (otherwise shared2 does not match - a CDH problem on Curve25519). Planting someone else's sender_pub fails decryption. A signature would only add non-repudiation, which login does not need: the server is the sole verifier.
  • KCI and why step 2 exists. With a stolen server private key an attacker can forge a step-1 envelope "from" any address (ECDH(server_priv, victim_pub) suffices). But they cannot open the step-2 challenge: its KEK requires ECDH(eph, victim), and eph_priv is single-use and known only to the server. The code round trip proves key ownership in a way that does not depend on the server key staying secret.
  • Server pinning. SK checks the challenge envelope's sender against the server's sk1 address from the built-in list - the server identity is authenticated on top of TLS.
  • Crypto does not fix social engineering: scanning someone else's QR is stopped only by the mandatory consent screen in SK.

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>"}}

settings is a profile settings map. Known keys: themeMode, locale, displayName, pinWipeThreshold, relockSeconds. Readers skip unknown keys. If a custom copies folder is chosen, backupDir (absolute path, or an Android SAF tree content:// URI) is added, and on macOS/iOS backupDirBookmark (base64 security-scoped bookmark). A path from another OS usually does not exist — the reader must not create the directory or apply a dead path; a bookmark from another machine will not open; an Android URI without a live persistable permission is ignored. The browser extension ignores these keys.

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. Every format change (a new payload flag, a new .skf header field) must land in both generators: vectors produced by a single implementation cannot detect an implementation divergence.

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.

আপনার প্ল্যাটফর্মে ব্যবহার করে দেখুন

iOS, Android, macOS, Windows - এবং ব্রাউজার এক্সটেনশন।

Secret Keeper ডাউনলোড করুন