Secret Keeper Protocol v1
Specification of the cryptographic protocol shared by the app, the browser extension and the site.
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.- 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):
- SK → server: POST of the request envelope to the target's endpoint.
Recipient - the server's address,
textis empty (all step data lives in meta), meta.type =sk-login. The server takes thesidfrom meta and the account from the sender. - 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 samesid. SK detects the challenge by the armor markers in the response body; a body without an envelope (empty, any other text) is a service error: there is no one-step variant, without a challenge the user would have no confirmation point. - 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 samesid. The server compares the code against the one issued for thissidand 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/sidhas 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. This is the only manual-entry case: a server without its own delivery to the client (the browser polls the status itself, as lashin.su does) never returns{"sent":false}- after it the request is already confirmed and needs no manual entry.
Request context in the challenge (v2). Against QR substitution
(QRLjacking: the attacker shows the victim the QR of their own request)
the server may put, next to the code, facts about who obtained the
sid: where the request came from, which browser. The attacker cannot
forge them - they sit inside an envelope encrypted to the victim's
address and assembled by the server from the attacker's browser data.
Version negotiation:
- SK adds
"challenge": 2tometa.dataof the step-1 request - the highest challenge version it understands. No field - the client only understands v1 (text= code) and the server answers as described above. - A server that received
challenge >= 2answers with a challenge envelope carrying"challenge": 2inmeta.data(next totarget,v,sid) andtext= JSON{"code": "123456", "items": [{"name": "From", "value": "Moscow, Russia"}, {"name": "Browser", "value": "Chrome, macOS"}]}.codeis a string;itemsis an array ofname/valuestring pairs whose content and language (per the request'sAccept-Language) are up to the server. Thevfield in meta remains the protocol version (1). - Before step 3 SK always shows a confirmation: the service (where the
sign-in goes) as the first row, then
itemsverbatim (no parsing or comparison); with v1 or emptyitemsonly SK's own row. The code is not shown to the user; step 3 is sent only after "Confirm". The fallback of item 3 (step-3 POST failed) is the same with v2: the code is shown for manual entry - confirmation was already given, only the channel is broken. A response with a version above the declared one, non-JSON or withoutcodeis a service error; step 3 is not sent.
User refusal. "Cancel" on the confirmation (as well as a new request
that superseded an unconfirmed one) sends no code. To a server that
answered with challenge v2, SK sends a cancel envelope to the same
endpoint: meta.type = sk-login-cancel, meta.data =
{"target","v","sid"}, empty text. SK does not wait for the reply and
shows no errors (fire-and-forget). The server accepts the cancel only
from the request's sender and only in the "awaiting code" state: it
voids the sid and tells the service page that the sign-in was
declined, so the page does not wait for the TTL. A challenge v1 server
gets no envelope - there the request lives out its TTL. "No" on the
consent under the lock (§ 4.5, before step 1) sends nothing: no request
reached the server.
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).
Server refusal (step 1 or 3): status 4xx, body - JSON
{"error": <code>, "message": <text for the user>}. error is a stable
code: bad-envelope (body is not an envelope / did not decrypt),
bad-meta (wrong type, target or v), in-progress (the request is
already being processed), sid-expired (sid unknown, expired or
already used), code-invalid (the code did not match or attempts are
exhausted) - for these SK shows its own "code expired, refresh the QR"
text and does not show message; access-denied (sign-in confirmed, but
the service does not admit this address: not on the list, blocked) - SK
shows its own heading plus the server's message; an unknown code -
"the service declined the sign-in" plus message. The server writes
message in the language of the request's Accept-Language (SK sends
its UI locale as a single tag: ru, en, ...), falling back to English
for unknown languages; envelope contents and cryptographic failure
details never go into message. A 4xx body that does not follow this
format (plain text, empty) is treated by SK as "refresh the QR" with the
raw response shown. access-denied at step 3 does not offer the code for
manual entry: the same verifier would refuse the same way. The 2xx and
5xx semantics above are unchanged.
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(otherwiseshared2does not match - a CDH problem on Curve25519). Planting someone else'ssender_pubfails 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 requiresECDH(eph, victim), andeph_privis 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.
4.6. Data request by a site (sk-data)
A service asks the user for data of a given type - card details, a login
with password, personal data - and receives it from the SK Vault (§ 6.1)
as an encrypted envelope, skipping manual entry. The scenario mirrors
sign-in: the service shows a QR/link
https://secretkeeper.net/request?v=1&sid=<...>&target=<id>&kind=<kind>
(or sk://request?...), the user scans it or opens it on the same
device, SK shows a consent screen with the service name and the data
type, the user picks a Vault record (a single record of that type is
picked automatically), and SK sends its values to the service's server.
Only v, sid, target, and kind are read from the payload. The
endpoint URL and the server's sk1 address come only from the client's
built-in list, as in § 4.5. kind is a Vault record type from § 6.1;
a service cannot request an arbitrary set of fields - the type is the
dictionary. Requestable types in v1:
kind |
fields keys |
|---|---|
login-password |
site, login, password |
card-details |
holder, pan, exp, cvv, billingAddress |
personal-data |
name, birthdate, phone, email, address |
Transport and envelope shape are as in § 4.5: raw armored envelopes in
POST text/plain to the target's endpoint, meta.data of every step
carries {"target":<id>,"v":1,"sid":<sid>,"kind":<kind>} inside the
AEAD. The exchange is two-step and the data leaves only after the
challenge - the server never decrypts card details before the sender has
proven ownership of the address key:
- SK → server: request envelope, empty
text, meta.type =sk-data-request. - Server → SK (2xx body): challenge envelope,
text= one-time code, meta.type =sk-data-challenge,data.sid- the samesid. SK accepts it only from the server identity in the built-in list and only for its own request. There is no one-step variant: a response body without an envelope is a service error. - SK → server: data envelope, meta.type =
sk-data,meta.data={"target","v","sid","kind","code"}(the code from the challenge sits next tosidinside the AEAD),text= a JSON object with the chosen record's field values per thekinddictionary, e.g.{"holder":"IVAN IVANOV","pan":"4111 1111 1111 1111","exp":"12/29","cvv":"123"}. Values are sent as stored in the Vault (normalisation - stripping spaces frompan, parsingexp- is the receiver's job); empty fields and the record name (title) are not sent. A 2xx response with an empty body means accepted.
SK performs both POSTs back to back without user involvement (except
confirming the request context when the server sends a v2 challenge -
the same mechanism as in § 4.5: "challenge": 2 in the request and in
the reply, challenge text = JSON {code, items}; a refusal on the
confirmation sends a sk-data-cancel envelope with {"target","v","sid"},
like sk-login-cancel in § 4.5); there is no manual
code entry as in § 4.5 - if step 3 fails (network, 5xx), SK shows an
error and the service offers to refresh the QR.
Mandatory server checks are those of § 4.5 (successful decrypt =
sender authentication, type/target/v per step, a live one-time
sid, a code with TTL and attempt limit bound to the sid and the
sender), plus: kind in the meta of both envelopes equals the one
issued for this sid, and the text keys of step 3 belong to the
kind dictionary. The server keeps the values only until it hands them
to its own client (the page) and never logs them.
A server refusal is a 4xx status with a JSON body
{"error", "message"} under the rules of § 4.5, with the same codes
(bad-envelope, bad-meta, in-progress, sid-expired,
code-invalid, access-denied) and one new one: kind-mismatch - the
type in the meta is not the one requested for this sid. On bad-*,
in-progress, sid-expired, code-invalid, kind-mismatch SK shows
its own "refresh the QR" text; on access-denied and unknown codes -
a "service declined" title and the server's message. access-denied
is decided at step 3, once the sender's address is proven.
The security model is § 4.5 in full; the challenge is needed here for the same reason: the service makes an access decision by the sender's address, so the address must be proven with the same strength as for sign-in, independently of the secrecy of the server key. In addition, the data type in the link rules out "over-asking" for extra fields - the consent screen shows the user exactly what will leave; someone else's QR is stopped only by that screen.
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[], chats{}, settings, vault[]} |
.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.
6.1. Vault records
Typed records (passwords, cards, personal data) and user folders reach
the backup by two routes: in .sk1 as the vault and vaultFolders
arrays, and in the .skb archive as the .storage/records.sk1e file.
The latter is an armored envelope encrypted to self (like
meta.sk1e); its plaintext:
{"version": 1, "folders": [{...}, ...], "records": [{...}, ...]}
A record:
{"id": "<ms>-<rnd>", "kind": "login-password", "title": "...",
"fields": {"site": "...", "login": "...", "password": "..."},
"tags": ["..."], "favorite": true, "folder": "<folder id>",
"createdAt": ms, "updatedAt": ms}
A folder:
{"id": "<ms>-<rnd>", "name": "...", "parent": "<folder id>",
"createdAt": ms, "updatedAt": ms}
kind is the immutable record type; it defines the dictionary of fields
keys. v1 types: login-password (site, login, password),
card-details (holder, pan, exp, cvv, billingAddress), personal-data (name,
birthdate, phone, email, address), text-note (text - the note
body, same markdown as payload text, see 4.1.1), checklist (items -
the checklist as a GFM task list: one item per line, - [ ] text open and
- [x] text done, done items after open ones; a reader treats a line
without a marker as an open item), links (items - the link list as a
GFM list of markdown links: one item per line, - [title](url) or a
bare - url without a title, line order is display order; a reader
shows the host for an item without a title), file (name, size,
sha256 - see below). Readers keep unknown kind
values and unknown fields keys as they are without displaying them: a
record made by a newer client must survive an import-export cycle. Empty
tags, a false favorite and an absent folder are not written.
favorite ("default for the type") is held by at most one record per
kind.
A file record holds metadata only: name is the original file name,
size is the plaintext size in bytes (as a string), sha256 is the hex
hash of the plaintext. The content lives next to the index as a separate
SKF container encrypted to self:
.storage/files/<record id>.skf
The blob is immutable: moving the record between Vault folders is a
metadata change, the file is not touched. Blobs travel inside .skb
together with the archive; .sk1 carries only the metadata records -
after such an import the reader shows the record and reports that the
file is not available in this copy.
Folders are an orthogonal axis of organization: the type belongs to the
record, not to the folder. Nesting is set by the folder's parent field
(absent means the folder lives at the root). A record points to a folder
via the folder field; a reference to a non-existent folder - whether in
a record's folder or a folder's parent - reads as the root (which is
also how folder deletion works without cascades: its records and
subfolders return to the root).
Import merging is keyed by id (the same for records and folders): a
new one is added; on an id match the fresher updatedAt wins. Older
clients that predate the Vault ignore the .storage directory in the
archive, and backups without it read back with an empty record list.
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. 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.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.