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.

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.

2.1. Passkey keys (WebAuthn)

Secret Keeper acts as a passkey store (WebAuthn / FIDO2): the site (the relying party, RP) knows nothing about the SK protocol, it sees an ordinary authenticator. Passkey keys are derived from the same seed, so they are recovered from the mnemonic without a backup; the address and the envelope (§ 3, § 4) are unchanged.

The key for an RP with identifier rpId (a domain, lower case) and ordinal n (a decimal number without leading zeros; the first key for a domain is 0, the next is the highest used plus one):

okm = HKDF-SHA256(ikm = seed, salt = empty,
                  info = "secret-keeper/webauthn/v1/" + rpId + "/" + n, L = 48)
d   = (int_be(okm) mod (q - 1)) + 1        // q - the P-256 group order
Q   = d * G                                 // public key
credentialId = 0x01 || HKDF-SHA256(seed, salt = empty,
                  info = "secret-keeper/webauthn-id/v1/" + rpId + "/" + n, L = 16)

Curve P-256 (secp256r1), ECDSA with SHA-256 (COSE alg -7, ES256) and a deterministic RFC 6979 nonce: the same input gives the same signature, which the test vectors check; s is normalized to the lower half of the order (low-S). The signature is ASN.1 DER (SEQUENCE { r INTEGER, s INTEGER }). There are no other algorithms: if the RP does not accept -7 in pubKeyCredParams, registration is refused. credentialId is 17 bytes and deterministic: given rpId, the keys of a domain are recovered by iterating n. One key per RP account: a repeated registration with the same rpId and user.id replaces the previous record with a new key (the next n), as makeCredential does in CTAP2; the old key is no longer used.

The public key inside authenticatorData is a COSE_Key in canonical (CTAP2) CBOR: {1: 2, 3: -7, -1: 1, -2: x, -3: y} (kty EC2, alg ES256, crv P-256, 32-byte coordinates with leading zeros).

authenticatorData = SHA-256(rpId) (32) || flags (1) || signCount (4, always 0: a synced key, no counter) || [attestedCredentialData]. Flags: UP, UV, BE, BS are always set (the owner confirms with PIN or biometrics; the key is recoverable from the seed, hence backup eligible and backed up); AT only on registration. attestedCredentialData = AAGUID (16) || credentialId length (2, big-endian) || credentialId || COSE_Key. The Secret Keeper AAGUID is 46c8e517-2789-481f-a374-3aca8eeb452b. Attestation is none: attestationObject = CBOR {"fmt": "none", "attStmt": {}, "authData": <bytes>} (canonical key order: fmt, attStmt, authData).

What is signed is authenticatorData || SHA-256(clientDataJSON). When SK builds clientDataJSON itself (the caller is a native app) it is {"type":"webauthn.create"|"webauthn.get","challenge":<base64url>, "origin":<origin>,"crossOrigin":false}; browsers and system intermediaries pass a ready hash, and clientDataJSON in the response is empty. Responses use the WebAuthn Level 3 JSON serialization (id, rawId, type: "public-key", authenticatorAttachment: "platform", response.{clientDataJSON, attestationObject | authenticatorData, signature, userHandle}, transports: ["internal", "hybrid"], clientExtensionResults.credProps.rk: true), fields are base64url without padding. userHandle is the user.id received from the RP at registration; it is returned in every assertion so that sign-in without typing a login (discoverable credential) works.

A passkey in the Vault is a record of type passkey (§ 6.1).

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); "sk-records" - transferring Vault records (§ 4.7).

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, and after explicit user consent SK proves to the service's server that it owns the key of its address. The link has two forms:

  • a site: https://secretkeeper.net/auth?v=1&sid=<...>&site=<host>&address=<sk1...> (or sk://auth?...). site is the site's host (ASCII only; IDN in punycode), address is the sk1 address of its server. The endpoints are derived from the host by convention: https://<host>/sk/login for sign-in and https://<host>/sk/request for data requests (§ 4.6); there is no URL in the payload and there cannot be one. The service name on the consent screen is the host itself.
  • a built-in target: ...&target=<id> - an application for which SK knows the URL, the server address and the way to bring the user back (today tetatet). Only the record name is read from the payload.

Only v, sid, site, address, target (and kind in § 4.6) are read from the payload. A link with site but no address, with a malformed host (port, IP, localhost, scheme, path) or with both site and target is not served.

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>} (target is the site host or the built-in record id; the server checks it against its own) - 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 service error: there is no one-step variant, without a challenge the user would have no confirmation point.
  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. 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": 2 to meta.data of 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 >= 2 answers with a challenge envelope carrying "challenge": 2 in meta.data (next to target, v, sid) and text = JSON {"code": "123456", "items": [{"name": "From", "value": "Moscow, Russia"}, {"name": "Browser", "value": "Chrome, macOS"}]}. code is a string; items is an array of name/value string pairs whose content and language (per the request's Accept-Language) are up to the server. The v field 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 items verbatim (no parsing or comparison); with v1 or empty items only 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 without code is 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 link (or 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:

  • The site's address arrives in the QR, like a contact's address. In this scheme a site is no different from a peer who showed their QR: SK encrypts to the address it saw, and the two-step exchange proves that the other side holds the key of exactly that address. The binding of the address to the host comes from the channel the key arrived through
    • the site's page in the user's browser, not the application's network path. So tampering with SK's traffic (a proxy, a foreign root certificate) gains nothing: the request is already encrypted to the real key, and a challenge from the right address cannot be forged. Swapping the address on the page (XSS, a phishing copy of the site) is useless on its own: the endpoints are derived from the host, the envelope goes to the real https://<host>/sk/login, and the site cannot decrypt it; an attacker needs control of both the page and the network at once - nothing protects against that except a key known in advance, and only built-in targets have one. Lookalike domains and inattention to the host on the consent screen remain, as on the web; SK makes no network requests before consent.
  • 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 link (from the list for built-in targets) - 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=<...>&site=<host>&address=<sk1...>&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, site, address (or target), kind and the optional mode (see "Test mode" below) are read from the payload. The endpoint is https://<host>/sk/request by the host from site, the server address comes from address, as in § 4.5 (for a built-in target - from the client's list; a service without a data endpoint gets a refusal on /request). 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:

  1. SK → server: request envelope, empty text, meta.type = sk-data-request.
  2. Server → SK (2xx body): challenge envelope, text = one-time code, meta.type = sk-data-challenge, data.sid - the same sid. SK accepts it only from the server identity in the link (or the list) and only for its own request. There is no one-step variant: a response body without an envelope is a service error.
  3. SK → server: data envelope, meta.type = sk-data, meta.data = {"target","v","sid","kind","code"} (the code from the challenge sits next to sid inside the AEAD), text = a JSON object with the chosen record's field values per the kind dictionary, 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 from pan, parsing exp - 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.

Test mode. So that real details never reach a demo or debugging site, SK can send the field values partially masked. The mode is turned on by either of two signals:

  • the host from site is one of SK's demo sites (currently auth.secretkeeper.net): masking is always on and the QR cannot turn it off;
  • the /request payload carries mode=test: for integrators' staging environments during development. Any other mode value makes the link unsupported ("update the app" screen); on /auth the parameter is ignored.

The mask is the same for every field: letters and digits (\p{L}, \p{N}) become *, other characters (spaces, /, -, @, ., +) stay in place; the edges of the value stay visible depending on its length in code points: 8 or more characters - the first two and the last two, 4 to 7 - one at each edge, up to 3 - all *. Examples: 4111 1111 1111 1111 → 41** **** **** **11, 12/29 → 1*/*9, 123 → ***, ivan@mail.ru → iv**@****.ru. The edges deliberately reveal part of the secret (the first and last card digits): this is a mode for demonstrating the format, not for transferring data.

In test mode meta.data of both SK envelopes (sk-data-request and sk-data) carries "masked": true; in normal mode the key is absent. The server must not treat such values as real or store them; the page may show the user a "test data" badge.

4.7. Transferring Vault records (sk-records)

Vault records (§ 6.1) leave the app only inside an envelope: to yourself (a second device of the same profile) or to a peer in a conversation. The only things that go out in plain text are a single record field and the QR code of a one-time code - the "paste into a form" and "move to another authenticator" scenarios.

It is a regular envelope (§ 4): the recipients are the usual slots, text is a human-readable duplicate of the records (a client without support shows it as a message or a note), meta carries the records themselves:

{"type":"sk-records","data":{"v":1,"records":[
  {"kind":"login-password","title":"Mail",
   "fields":{"site":"https://mail.example.com","login":"alice","password":"..."}}
]}}
  • v is the format version, currently 1; any other value is a parse failure.
  • records is a non-empty list. A record has kind - a type from § 6.1, title - the name (may be empty: the recipient derives it from the fields), fields - a string dictionary keyed by the type's field keys. The identifier, folder, dates and other record properties are not transferred.
  • The file kind is not transferable: file records are metadata of a blob that lives in the sender's archive. A record with kind: "file" or an unknown kind fails parsing of the whole meta.
  • Receiving: records land in the root of the recipient's Vault (kinds with their own section go to that section), deduplicated by kind and fields - the same envelope pasted twice adds nothing. A passkey record is accepted only if the key derived from the recipient's seed by rpId/keyIndex (§ 2.1) yields the same credentialId: records of another profile are useless and dropped. A one-time code (totp) needs a parsable secret.
  • An envelope to self (no recipients besides the sender) with such meta puts the records into the Vault right away; in a conversation the message is shown as text and the records are saved with "Save to Vault".

The canonical JSON (key order type, data → v, records → kind, title, fields) and the rejection cases are in the test vectors (section records); the self-envelope fixture is envelopes.to_self_records.

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. Backups from older versions may carry backupDir and backupDirBookmark (custom copies folder, removed in 1.1.7): readers ignore them.

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 contact is {"name", "address", "isFavorite", "updatedAt": ms}; updatedAt (last edit time, may be absent in older copies) drives the merge: for the same address the later one wins, and a copy without the field loses to any copy with it.

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. Outgoing lines carry hand-off flags when they differ from the defaults: "handedOff": false (the envelope has not been passed to the peer yet; absence means handed off) and "delivered": true (read receipt). Ledger lines are ordered by at, then sha; when two copies of one chat are merged, lines are united by sha and the flags are OR-ed (a "handed off" mark is never withdrawn). A client without a history model (the browser extension) imports only settings and contacts from the archive and ignores the rest.

Deletions travel between copies as markers, not as missing files: .storage/tombstones.sk1e is an envelope to self with plaintext {"version": 1, "items": [...]}, where an item is one of

{"k": "msg",    "peer": "sk1…", "sha": "…", "at": ms}   a message
{"k": "chat",   "peer": "sk1…", "at": ms}               a chat with a peer
{"k": "record", "id": "…", "at": ms}                    a Vault record
{"k": "folder", "id": "…", "at": ms}                    a Vault folder

at is the deletion time. A msg marker removes the message for good; chat removes the chat's messages with at not later than the marker and the contact with updatedAt not later than the marker (anything written or renamed afterwards stays); record/folder win over versions with updatedAt not later than the marker, a later edit revives the record. Marker sets merge by union; for the same key the later at is kept. A reader without marker support ignores the file.

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), totp (one-time codes per RFC 6238 - see below), passkey (a WebAuthn sign-in key - see below), 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 totp record is the shared secret for second-factor one-time codes (TOTP, RFC 6238; the code is HOTP per RFC 4226 over the counter floor(unixTime / period), dynamic truncation, leading zeros kept). fields keys: secret - the shared secret in base32 (RFC 4648; writers emit upper case without padding or spaces, readers tolerate lower case, spaces and =), required; issuer - the service, account - the user's account there (hints for the user, not part of the computation); algorithm - SHA1 | SHA256 | SHA512; digits - 6 | 8; period - step length in seconds. A missing or empty algorithm/digits/period means the default: SHA1, 6, 30 - a writer may omit them. The record comes from a URI otpauth://totp/<label>?secret=…&issuer=…&algorithm=…&digits=…&period=… (the QR on a site's 2FA setup page, an "open in app" link, the clipboard): label is issuer:account or just account (URL encoding and %3A are accepted), issuer is taken from the parameter, otherwise from the label. otpauth://hotp/ (counter instead of time) is not supported - a reader rejects such a URI instead of creating a record. The type is not released on a site's request (§ 4.6).

A passkey record is a WebAuthn sign-in key (§ 2.1). It holds no secret: the private key is derived from the seed by rpId and keyIndex, the record keeps what is needed to find the key and answer the site. fields keys: rpId - the RP identifier (domain), required; keyIndex - the derivation ordinal n (a decimal number as a string), required; credentialId - base64url of the credential id (duplicates the seed derivation so that allowCredentials can be answered without recomputing); userHandle - base64url of the RP's user.id; rpName, userName, userDisplayName - labels for the user as the RP sent them. Only the passkey provider creates the record, at registration on a site; it is not entered or edited by hand (except the title). The type is not released on a site's request (§ 4.6). A reader without a passkey provider (for example the browser extension) shows the record as metadata.

Moving a passkey record to another device of the same profile (the same seed) uses the URI sk://passkey?…, which the app shows as a QR code and accepts from a QR, the clipboard, shared text and as a link of the sk scheme:

sk://passkey?rp=<rpId>&n=<keyIndex>&cid=<credentialId>
  [&uh=<userHandle>][&rpn=<rpName>][&un=<userName>]
  [&udn=<userDisplayName>][&title=<record title>]

The parameters are the record fields with the same meaning; rp, n and cid are required, empty optional ones are omitted, the order is fixed, values are percent-encoded per RFC 2396 (encodeURIComponent, space as %20). The form sk:passkey?… (without //) is equivalent. The reader derives the key from its own seed by rp/n and compares the credentialId with cid: a mismatch means the record was taken from another profile and it is not saved; a match saves the record (a key with the same rpId and credentialId already present is not duplicated). The URI carries no secret, but the domain and userHandle are personal data: show the QR only to your own device.

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). The exception is the totp and passkey kinds: in the app their home is the per-kind section rather than a folder, so their folder field is not written, and if present (an older copy, another client) the reader ignores it and drops it on the next write.

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.

6.2. Cloud copy (storage protocol SKS1)

The cloud copy is a mirror of the archive directory (§ 6) on a storage server. The server only ever sees archive files, and each of them is an envelope to self, so the storage holds ciphertext and takes no part in the cryptography: switching servers or running your own is a matter of an address in settings. The reference server is open source (secret_keeper_storage); by default the app uses our instance.

Storage key. Derived from the same seed as the identity (§ 2) but with a different info, so it cannot be computed from the sk1… address or vice versa:

storage_private = HKDF-SHA256(ikm = seed, salt = empty,
                              info = "secret-keeper/storage-ed25519/v1", L = 32)
storage_public  = Ed25519(storage_private)
storage-id      = bech32(hrp = "sks", storage_public)      → sks1…

The server knows a user only by storage-id; it cannot be linked to the messaging address.

Request signing. Every /v1/* request carries four headers:

Header Value
X-SK-Storage storage-id
X-SK-Time client clock, unix ms
X-SK-Nonce 16..128 chars of [A-Za-z0-9_=-], unique per request
X-SK-Sig base64 of the Ed25519 signature over the signing string

Signing string (UTF-8, lines joined with \n, no trailing newline):

SKS1
{METHOD}                     upper case
{path}                       request path with query, exactly as sent
{time}
{nonce}
{sha256(body) as lower hex}  sha256("") for bodiless requests

The server rejects requests whose time differs from its clock by more than the window (5 minutes by default) and remembers nonces for twice that window, so a captured request cannot be replayed. Responses carry X-SK-Server-Time so a client with a wrong clock can correct itself.

Objects. An object path is a relative file path inside the archive (meta.sk1e, .storage/records.sk1e, sk1…/messages.ndjson), up to 8 segments of [A-Za-z0-9._-], no . or ... In URLs the segments are percent-encoded: /v1/o/.storage/records.sk1e.

Request Result
GET /healthz {"ok":true,"time":…}, unsigned
GET /v1/list [{"path","size","etag","mtime"}] sorted by path
GET /v1/usage {"used","quota","maxObject"} in bytes
PUT /v1/o/<path> store the body; 201 new, 200 replaced; If-Match / If-None-Match (* allowed)
GET /v1/o/<path> body with ETag, Content-Length, Last-Modified; If-None-Match → 304
HEAD /v1/o/<path> headers only
DELETE /v1/o/<path> remove one object; If-Match
DELETE /v1/all remove everything under the storage-id

etag is the lower-hex sha256 of the body. Errors are JSON {"error","message"}: 401 auth-*, 400 bad-path, 404 not-found, 412 precondition-failed, 413 too-large / 413 quota, 403 ids-limit. The last one comes from a host that caps the number of storage-ids (a family server for seven seeds): while the last slot is taken, a new storage-id is refused on every signed request, an id that holds data is always served, and DELETE /v1/all frees its slot. The server streams an upload to a temporary file and renames it into place only after the signature has been checked against the actual body hash: a forged body never becomes an object.

Synchronisation. The cloud is the shared archive of every device on one seed, not a copy of one of them. The client reconciles itself with it in a "pull, merge, push" cycle and runs it on its own: on entering the app, after data changes (with a delay), when going to the background; the button in settings runs the same cycle by hand.

  1. list. Objects that are missing locally, or whose etag differs from the local sha256 and has not been seen by this device yet, are downloaded and read by the same importer that reads .skb (§ 6): deletion markers are applied and remembered, contacts merge by updatedAt, messages by sha with OR-ed hand-off flags, Vault records by id and updatedAt. The ledger of every affected chat is rewritten in canonical form (union, ordered by at, sha), so the devices converge to byte-identical files. A ledger line with a container that is not present locally yet is not merged: the next cycle picks it up together with the container.
  2. If anything changed, locally or through the merge, meta.sk1e and .storage/records.sk1e are re-encrypted.
  3. Files whose sha256 differs from the cloud etag are PUT; .skf containers go before ledgers and meta so a ledger on the server never references a missing container. Objects missing locally are never deleted by the client: a deletion is a marker in .storage/tombstones.sk1e, applied by the other devices. The only explicit deletion is DELETE /v1/all.

The client remembers the etag of every object it has pulled or pushed: with no changes on either side the cycle costs one list. Settings from meta (theme, language, PIN thresholds) are not applied by the sync - they stay local to the device; only restore on a new device carries them (download everything and hand it to the .skb importer).

New device. The cloud is enabled only by explicit consent: until then the app sends nothing to the server. A new profile sees the offer once, at the first change of its data; the answer is remembered, and "not now" is an answer too, the switch stays in settings. After the seed phrase is entered, the app immediately asks the default server (the storage-id is derived from the same seed): if a copy exists it is restored and the cloud is enabled without questions; if it is empty or refuses, the app offers an .skb file or another server address where the lookup is repeated. A storage-id appears on the server only with the first upload, so a lookup alone does not take a slot of a family server.

Not in this version: a client-signed manifest (protection against rollback and against the server swapping an object) and a single-file upload instead of the per-file mirror.

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.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.

Descargar