rfcs/012-yandex-decryption.md
Author: moonD4rk Status: Living Document Created: 2026-04-22 Last updated: 2026-04-22
Yandex Browser is a Chromium fork, but its saved-credential encryption diverges from the Chromium reference in three ways that together make a plain Chromium extractor produce zero plaintext:
password_value directly — it decrypts a per-DB intermediate key stored in meta.local_encryptor_data. That intermediate key is what actually decrypts rows.\x00; a credit-card row's AAD is the row's guid. AAD mismatch → GCM tag failure → empty plaintext.records(guid, public_data, private_data) — two JSON blobs — not Chromium's flat credit_cards table.This RFC documents the on-disk layout, the decryption math, and how the integration plugs into the existing Chromium extract pipeline without perturbing the v10/v11/v20 paths that the rest of HackBrowserData depends on.
Resolved issues: #90 (feature request), #105 / #462 / #476 (downstream bug reports against the incomplete skeleton that was merged before this RFC).
Related RFCs:
Deferred to a follow-up RFC / PR:
| Layer | Standard Chromium | Yandex |
|---|---|---|
| Master key | os_crypt.encrypted_key in Local State, unwrapped via DPAPI / Keychain | Same |
| Decryption key used per row | Master key directly | Intermediate 32-byte key stored per-DB in meta.local_encryptor_data |
| Key wrapper format | "v10"|nonce|ct+tag (or DPAPI blob) | "v10"|nonce|ct+tag, plaintext prefixed by 4B protobuf signature 08 01 12 20, 32B key follows |
| Password DB file | Login Data (table: logins) | Ya Passman Data (table: logins) |
| Password ciphertext | "v10"|nonce|ct+tag, AAD = empty | No prefix; raw nonce|ct+tag; AAD = SHA1(origin_url ‖ \x00 ‖ username_element ‖ \x00 ‖ username_value ‖ \x00 ‖ password_element ‖ \x00 ‖ signon_realm) |
| Credit-card DB file | Web Data (table: credit_cards) | Ya Credit Cards (table: records) |
| Credit-card layout | Columns: name_on_card, expiration_month, card_number_encrypted, … | JSON: public_data (plaintext) + private_data (AES-GCM sealed JSON, AAD = guid) |
| Master password | n/a | Optional; when set, active_keys.sealed_key holds an RSA-OAEP envelope (deferred) |
meta.local_encryptor_data[protobuf preamble bytes...] "v10" [12B nonce] [68B plaintext + 16B GCM tag]
The 68-byte plaintext (decrypted with the Chromium master key, empty AAD) has the shape:
08 01 12 20 | KK KK ... KK (32 bytes) | padding / extra protobuf fields
^ signature | ^ data-encryption key
The data-encryption key is the first 32 bytes after the signature; trailing bytes are ignored. The fixed 96-byte region after "v10" is a Yandex invariant (the reference implementation slices [:96] unconditionally) and is checked as a minimum length.
logins.password_value)[12B nonce] [ciphertext] [16B GCM tag]
No version prefix. AAD binds five form columns:
SHA1(origin_url ‖ 0x00 ‖ username_element ‖ 0x00 ‖ username_value ‖ 0x00 ‖ password_element ‖ 0x00 ‖ signon_realm)
When a master password is set, the sealed keyID is appended after the SHA-1 sum. v1 always passes nil and skips sealed profiles.
records.private_data)Same byte shape as passwords but AAD = the row's guid bytes (plus optional keyID). Decrypted plaintext is a JSON object with full_card_number, pin_code, secret_comment. The sibling public_data column is plaintext JSON with card_holder, card_title, expire_date_month, expire_date_year.
Yandex adds a second key layer on top of the standard Chromium key. The Chromium master key — unwrapped from Local State via DPAPI (Windows) or Keychain (macOS) — never decrypts row ciphertext directly. Instead, each target SQLite database carries its own data key in meta.local_encryptor_data, and only that data key decrypts row-level ciphertext. The master key's only job is to unwrap the data key.
For every target DB (Ya Passman Data for passwords, Ya Credit Cards for cards), the extractor runs the same five steps:
Local State, base64-decode os_crypt.encrypted_key, strip the DPAPI prefix, and unwrap it via DPAPI (Windows) or Keychain (macOS). Yields 32 bytes.SELECT sealed_key FROM active_keys. Non-empty → log a warning and skip the profile (v1 limitation — RSA-OAEP unseal deferred). Table missing (credit-card DB) or empty value → continue.SELECT value FROM meta WHERE key='local_encryptor_data'. Find the "v10" byte sequence, take the 96 bytes that follow, split into 12B nonce + 84B (ciphertext+tag), AES-GCM-decrypt with the master key (no AAD), strip the 4-byte protobuf signature 08 01 12 20, keep the first 32 bytes.[12B nonce][ct+tag], AES-GCM-decrypt with the data key under that AAD.| Level | Key | Origin | Scope |
|---|---|---|---|
| 1 | Chromium master key | Local State → DPAPI / Keychain | Whole profile (shared with cookies, history, etc.) |
| 2a | Passwords data key | Ya Passman Data → meta.local_encryptor_data | logins rows in this DB only |
| 2b | Credit cards data key | Ya Credit Cards → meta.local_encryptor_data | records rows in this DB only |
| Category | DB file | Table / column | Ciphertext layout | AAD |
|---|---|---|---|---|
| Password | Ya Passman Data | logins.password_value | [12B nonce][ct+tag] | SHA1(origin_url ‖ \x00 ‖ username_element ‖ \x00 ‖ username_value ‖ \x00 ‖ password_element ‖ \x00 ‖ signon_realm) |
| Credit card | Ya Credit Cards | records.private_data | [12B nonce][ct+tag] | raw guid bytes |
Credit-card plaintext is a JSON object (full_card_number, pin_code, secret_comment) that the extractor unmarshals into CreditCardEntry. The sibling records.public_data is plaintext JSON (card_holder, card_title, expire_date_year, expire_date_month) and needs no decryption.
The two level-2 data keys are unwrapped from different meta.local_encryptor_data blobs — one per DB. This matters in two ways:
active_keys table.Both data keys still ultimately derive from the same level-1 Chromium master key, so loss of DPAPI (e.g., Windows user-profile rebuild) breaks both simultaneously.
The key-retrieval layer dispatches on cipher-version prefix — v10 / v11 / v20. Yandex password rows carry no such prefix; they are raw [nonce][ct+tag]. Folding Yandex's intermediate-key step into the prefix dispatcher would overload an abstraction that is purely "pick the key for this byte prefix". The intermediate-key unwrap therefore lives alongside the Yandex extractor and consumes the standard Chromium master key as input; the prefix dispatcher is untouched.
The crypto layer exposes cryptographic primitives — transforms of bytes under a key (AES, GCM, 3DES, DPAPI, PBKDF2). Yandex's AAD rules (SHA-1 over five form fields for passwords, the row's GUID for cards) are not cryptography; they are Yandex's per-row identification scheme that happens to be bound to GCM's authentication tag. Placing them in the crypto layer would leak product-specific knowledge into a layer that otherwise sees only bytes and keys.
The final split:
This keeps the crypto surface minimal — the only Yandex symbol it owns is the intermediate-key unwrap, because that one function genuinely is cryptography (it strips a protobuf frame and decrypts AES-GCM).
active_keys.sealed_key are detected and skipped with a warning. A follow-up RFC will cover the RSA-OAEP path: PBKDF2-SHA256 derives a KEK; the KEK decrypts encrypted_private_key with AAD = unlock_key_salt; the resulting PKCS8 RSA private key + RSA-OAEP-SHA256 decrypts encrypted_encryption_key; the signature strip then yields the dataKey.Decryption math is covered by cross-platform unit tests that build synthetic DBs by running the encryption path in reverse — no real Yandex install or Windows host is required. Coverage spans:
v10 marker, truncated blob, bad protobuf signature, trailing bytes ignored.End-to-end validation on a Windows host with a real Yandex profile is expected before shipping changes that touch the decryption path; the existing Chromium full-sweep doubles as a regression gate against unintended impact on other Chromium forks.
Single PR that wires all of the above; merge automatically closes #90 / #105 / #462 / #476. Follow-up PRs for master password and (if/when Yandex adopts ABE) v20 integration reference this RFC rather than reopening the decryption design question.