From 0c6c781567522cb34c989738aa3e275f5315e476 Mon Sep 17 00:00:00 2001 From: Roger Date: Thu, 23 Apr 2026 17:00:09 +0800 Subject: [PATCH] feat(yandex): password and credit card decryption (#585) --- browser/chromium/chromium.go | 6 +- browser/chromium/chromium_test.go | 1 + browser/chromium/extract_creditcard.go | 88 +++++++++ browser/chromium/extract_creditcard_test.go | 88 +++++++++ browser/chromium/extract_password.go | 75 ++++++- browser/chromium/extract_password_test.go | 83 +++++++- browser/chromium/source.go | 19 +- browser/chromium/yandex_key.go | 55 ++++++ browser/chromium/yandex_testutil_test.go | 208 ++++++++++++++++++++ crypto/crypto.go | 17 ++ crypto/crypto_windows.go | 12 -- crypto/yandex.go | 48 +++++ crypto/yandex_test.go | 150 ++++++++++++++ output/reflect_test.go | 2 +- rfcs/010-chrome-abe-integration.md | 38 ++-- rfcs/012-yandex-decryption.md | 158 +++++++++++++++ types/models.go | 5 +- 17 files changed, 1005 insertions(+), 48 deletions(-) create mode 100644 browser/chromium/yandex_key.go create mode 100644 browser/chromium/yandex_testutil_test.go create mode 100644 crypto/yandex.go create mode 100644 crypto/yandex_test.go create mode 100644 rfcs/012-yandex-decryption.md diff --git a/browser/chromium/chromium.go b/browser/chromium/chromium.go index af3974d..aa7e96b 100644 --- a/browser/chromium/chromium.go +++ b/browser/chromium/chromium.go @@ -138,7 +138,11 @@ func (b *Browser) countCategory(cat types.Category, path string) int { case types.Bookmark: count, err = countBookmarks(path) case types.CreditCard: - count, err = countCreditCards(path) + if b.cfg.Kind == types.ChromiumYandex { + count, err = countYandexCreditCards(path) + } else { + count, err = countCreditCards(path) + } case types.Extension: if b.cfg.Kind == types.ChromiumOpera { count, err = countOperaExtensions(path) diff --git a/browser/chromium/chromium_test.go b/browser/chromium/chromium_test.go index 13577c7..e769b0e 100644 --- a/browser/chromium/chromium_test.go +++ b/browser/chromium/chromium_test.go @@ -337,6 +337,7 @@ func TestExtractorsForKind(t *testing.T) { yandexExt := extractorsForKind(types.ChromiumYandex) require.NotNil(t, yandexExt) assert.Contains(t, yandexExt, types.Password) + assert.Contains(t, yandexExt, types.CreditCard) operaExt := extractorsForKind(types.ChromiumOpera) require.NotNil(t, operaExt) diff --git a/browser/chromium/extract_creditcard.go b/browser/chromium/extract_creditcard.go index afa4042..2135c75 100644 --- a/browser/chromium/extract_creditcard.go +++ b/browser/chromium/extract_creditcard.go @@ -2,8 +2,12 @@ package chromium import ( "database/sql" + "encoding/json" + "errors" + "github.com/moond4rk/hackbrowserdata/crypto" "github.com/moond4rk/hackbrowserdata/crypto/keyretriever" + "github.com/moond4rk/hackbrowserdata/log" "github.com/moond4rk/hackbrowserdata/types" "github.com/moond4rk/hackbrowserdata/utils/sqliteutil" ) @@ -12,8 +16,26 @@ const ( defaultCreditCardQuery = `SELECT COALESCE(guid, ''), name_on_card, expiration_month, expiration_year, card_number_encrypted, COALESCE(nickname, ''), COALESCE(billing_address_id, '') FROM credit_cards` countCreditCardQuery = `SELECT COUNT(*) FROM credit_cards` + + yandexCreditCardQuery = `SELECT guid, public_data, private_data FROM records` + yandexCreditCardCountQuery = `SELECT COUNT(*) FROM records` ) +// yandexPublicData is the plaintext JSON in records.public_data. +type yandexPublicData struct { + CardHolder string `json:"card_holder"` + CardTitle string `json:"card_title"` + ExpireDateYear string `json:"expire_date_year"` + ExpireDateMonth string `json:"expire_date_month"` +} + +// yandexPrivateData is the AES-GCM-sealed JSON in records.private_data. +type yandexPrivateData struct { + FullCardNumber string `json:"full_card_number"` + PinCode string `json:"pin_code"` + SecretComment string `json:"secret_comment"` +} + func extractCreditCards(keys keyretriever.MasterKeys, path string) ([]types.CreditCardEntry, error) { cards, err := sqliteutil.QueryRows(path, false, defaultCreditCardQuery, func(rows *sql.Rows) (types.CreditCardEntry, error) { @@ -39,6 +61,72 @@ func extractCreditCards(keys keyretriever.MasterKeys, path string) ([]types.Cred return cards, nil } +// extractYandexCreditCards reads the records table (not Chromium's credit_cards). AAD = guid. See RFC-012 §4. +func extractYandexCreditCards(keys keyretriever.MasterKeys, path string) ([]types.CreditCardEntry, error) { + dataKey, err := loadYandexDataKey(path, keys.V10) + if err != nil { + if errors.Is(err, errYandexMasterPasswordSet) { + log.Warnf("%s: %v", path, err) + return nil, nil + } + return nil, err + } + + return sqliteutil.QueryRows(path, false, yandexCreditCardQuery, + func(rows *sql.Rows) (types.CreditCardEntry, error) { + var guid, publicData string + var privateData []byte + if err := rows.Scan(&guid, &publicData, &privateData); err != nil { + return types.CreditCardEntry{}, err + } + + var public yandexPublicData + if publicData != "" { + if err := json.Unmarshal([]byte(publicData), &public); err != nil { + log.Debugf("yandex: parse public_data for %s: %v", guid, err) + } + } + entry := types.CreditCardEntry{ + GUID: guid, + Name: public.CardHolder, + ExpMonth: public.ExpireDateMonth, + ExpYear: public.ExpireDateYear, + NickName: public.CardTitle, + } + + plaintext, err := crypto.AESGCMDecryptBlob(dataKey, privateData, yandexCardAAD(guid, nil)) + if err != nil { + log.Debugf("yandex: decrypt card %s: %v", guid, err) + return entry, nil + } + + var private yandexPrivateData + if err := json.Unmarshal(plaintext, &private); err != nil { + log.Debugf("yandex: parse private_data for %s: %v", guid, err) + return entry, nil + } + entry.Number = private.FullCardNumber + entry.CVC = private.PinCode + entry.Comment = private.SecretComment + return entry, nil + }) +} + func countCreditCards(path string) (int, error) { return sqliteutil.CountRows(path, false, countCreditCardQuery) } + +func countYandexCreditCards(path string) (int, error) { + return sqliteutil.CountRows(path, false, yandexCreditCardCountQuery) +} + +// yandexCardAAD is the raw guid bytes (+ keyID if the profile has a master password). +func yandexCardAAD(guid string, keyID []byte) []byte { + if len(keyID) == 0 { + return []byte(guid) + } + out := make([]byte, 0, len(guid)+len(keyID)) + out = append(out, guid...) + out = append(out, keyID...) + return out +} diff --git a/browser/chromium/extract_creditcard_test.go b/browser/chromium/extract_creditcard_test.go index 8a4378e..846baa7 100644 --- a/browser/chromium/extract_creditcard_test.go +++ b/browser/chromium/extract_creditcard_test.go @@ -1,6 +1,7 @@ package chromium import ( + "bytes" "testing" "github.com/stretchr/testify/assert" @@ -51,3 +52,90 @@ func TestCountCreditCards_Empty(t *testing.T) { require.NoError(t, err) assert.Equal(t, 0, count) } + +func TestExtractYandexCreditCards(t *testing.T) { + masterKey := bytes.Repeat([]byte{0x11}, 32) + dataKey := bytes.Repeat([]byte{0x22}, 32) + + path := setupYandexCreditCardDB(t, masterKey, dataKey, + yandexCreditCard{ + GUID: "card-1", + CardHolder: "Alice Smith", + CardTitle: "Personal Visa", + ExpYear: "2030", + ExpMonth: "06", + FullCardNumber: "4111111111111111", + PinCode: "123", + SecretComment: "main card", + }, + yandexCreditCard{ + GUID: "card-2", + CardHolder: "Alice Smith", + CardTitle: "Backup", + ExpYear: "2028", + ExpMonth: "12", + FullCardNumber: "5555555555554444", + PinCode: "456", + SecretComment: "", + }, + ) + + got, err := extractYandexCreditCards(keyretriever.MasterKeys{V10: masterKey}, path) + require.NoError(t, err) + require.Len(t, got, 2) + + byGUID := map[string]int{} + for i, c := range got { + byGUID[c.GUID] = i + } + + c1 := got[byGUID["card-1"]] + assert.Equal(t, "Alice Smith", c1.Name) + assert.Equal(t, "Personal Visa", c1.NickName) + assert.Equal(t, "2030", c1.ExpYear) + assert.Equal(t, "06", c1.ExpMonth) + assert.Equal(t, "4111111111111111", c1.Number) + assert.Equal(t, "123", c1.CVC) + assert.Equal(t, "main card", c1.Comment) + + c2 := got[byGUID["card-2"]] + assert.Equal(t, "5555555555554444", c2.Number) + assert.Equal(t, "456", c2.CVC) + assert.Empty(t, c2.Comment) +} + +func TestCountYandexCreditCards(t *testing.T) { + masterKey := bytes.Repeat([]byte{0x11}, 32) + dataKey := bytes.Repeat([]byte{0x22}, 32) + + path := setupYandexCreditCardDB(t, masterKey, dataKey, + yandexCreditCard{GUID: "g1", FullCardNumber: "x"}, + yandexCreditCard{GUID: "g2", FullCardNumber: "y"}, + yandexCreditCard{GUID: "g3", FullCardNumber: "z"}, + ) + + count, err := countYandexCreditCards(path) + require.NoError(t, err) + assert.Equal(t, 3, count) +} + +func TestExtractYandexCreditCards_WrongMasterKey(t *testing.T) { + goodKey := bytes.Repeat([]byte{0x11}, 32) + wrongKey := bytes.Repeat([]byte{0x99}, 32) + dataKey := bytes.Repeat([]byte{0x22}, 32) + + path := setupYandexCreditCardDB(t, goodKey, dataKey, + yandexCreditCard{GUID: "g1", FullCardNumber: "4111"}, + ) + + _, err := extractYandexCreditCards(keyretriever.MasterKeys{V10: wrongKey}, path) + require.Error(t, err) +} + +func TestYandexCardAAD(t *testing.T) { + got := yandexCardAAD("card-guid-1", nil) + assert.Equal(t, "card-guid-1", string(got)) + + got = yandexCardAAD("g", []byte("ID")) + assert.Equal(t, "gID", string(got)) +} diff --git a/browser/chromium/extract_password.go b/browser/chromium/extract_password.go index 98000c6..a7c3cb2 100644 --- a/browser/chromium/extract_password.go +++ b/browser/chromium/extract_password.go @@ -1,10 +1,14 @@ package chromium import ( + "crypto/sha1" "database/sql" + "errors" "sort" + "github.com/moond4rk/hackbrowserdata/crypto" "github.com/moond4rk/hackbrowserdata/crypto/keyretriever" + "github.com/moond4rk/hackbrowserdata/log" "github.com/moond4rk/hackbrowserdata/types" "github.com/moond4rk/hackbrowserdata/utils/sqliteutil" ) @@ -12,6 +16,9 @@ import ( const ( defaultLoginQuery = `SELECT origin_url, username_value, password_value, date_created FROM logins` countLoginQuery = `SELECT COUNT(*) FROM logins` + + yandexLoginQuery = `SELECT origin_url, username_element, username_value, + password_element, password_value, signon_realm, date_created FROM logins` ) func extractPasswords(keys keyretriever.MasterKeys, path string) ([]types.LoginEntry, error) { @@ -45,13 +52,73 @@ func extractPasswordsWithQuery(keys keyretriever.MasterKeys, path, query string) return logins, nil } -// extractYandexPasswords extracts passwords from Yandex's Ya Passman Data, which stores the URL in -// action_url instead of origin_url. +// extractYandexPasswords walks Ya Passman Data; protocol in RFC-012 §4. +// Note: URL column is origin_url — it's what the per-row AAD is computed over (not action_url). func extractYandexPasswords(keys keyretriever.MasterKeys, path string) ([]types.LoginEntry, error) { - const yandexLoginQuery = `SELECT action_url, username_value, password_value, date_created FROM logins` - return extractPasswordsWithQuery(keys, path, yandexLoginQuery) + dataKey, err := loadYandexDataKey(path, keys.V10) + if err != nil { + if errors.Is(err, errYandexMasterPasswordSet) { + log.Warnf("%s: %v", path, err) + return nil, nil + } + return nil, err + } + + logins, err := sqliteutil.QueryRows(path, false, yandexLoginQuery, + func(rows *sql.Rows) (types.LoginEntry, error) { + var originURL, usernameElem, usernameVal, passwordElem, signonRealm string + var passwordValue []byte + var created int64 + if err := rows.Scan(&originURL, &usernameElem, &usernameVal, &passwordElem, &passwordValue, &signonRealm, &created); err != nil { + return types.LoginEntry{}, err + } + entry := types.LoginEntry{ + URL: originURL, + Username: usernameVal, + CreatedAt: timeEpoch(created), + } + aad := yandexLoginAAD(originURL, usernameElem, usernameVal, passwordElem, signonRealm, nil) + plaintext, err := crypto.AESGCMDecryptBlob(dataKey, passwordValue, aad) + if err != nil { + log.Debugf("yandex: decrypt password for %s: %v", originURL, err) + return entry, nil + } + entry.Password = string(plaintext) + return entry, nil + }) + if err != nil { + return nil, err + } + + sort.Slice(logins, func(i, j int) bool { + return logins[i].CreatedAt.After(logins[j].CreatedAt) + }) + return logins, nil } func countPasswords(path string) (int, error) { return sqliteutil.CountRows(path, false, countLoginQuery) } + +// yandexLoginAAD is SHA1(origin_url \x00 username_element \x00 username_value \x00 password_element \x00 signon_realm), +// with keyID appended when the profile has a master password (v1 always passes nil). +func yandexLoginAAD(originURL, usernameElem, usernameVal, passwordElem, signonRealm string, keyID []byte) []byte { + h := sha1.New() + h.Write([]byte(originURL)) + h.Write([]byte{0}) + h.Write([]byte(usernameElem)) + h.Write([]byte{0}) + h.Write([]byte(usernameVal)) + h.Write([]byte{0}) + h.Write([]byte(passwordElem)) + h.Write([]byte{0}) + h.Write([]byte(signonRealm)) + sum := h.Sum(nil) + if len(keyID) == 0 { + return sum + } + out := make([]byte, 0, len(sum)+len(keyID)) + out = append(out, sum...) + out = append(out, keyID...) + return out +} diff --git a/browser/chromium/extract_password_test.go b/browser/chromium/extract_password_test.go index 0bc2ff6..42ad9ea 100644 --- a/browser/chromium/extract_password_test.go +++ b/browser/chromium/extract_password_test.go @@ -1,6 +1,8 @@ package chromium import ( + "bytes" + "crypto/sha1" "testing" "github.com/stretchr/testify/assert" @@ -52,12 +54,83 @@ func TestCountPasswords_Empty(t *testing.T) { } func TestExtractYandexPasswords(t *testing.T) { - path := createTestDB(t, "Ya Passman Data", loginsSchema, - insertLogin("https://origin.yandex.ru", "https://action.yandex.ru/submit", "user", "", 13350000000000000), + masterKey := bytes.Repeat([]byte{0x11}, 32) + dataKey := bytes.Repeat([]byte{0x22}, 32) + + path := setupYandexPasswordDB(t, masterKey, dataKey, false, + yandexPassword{ + OriginURL: "https://old.yandex.ru", UsernameElem: "u", UsernameVal: "alice", + PasswordElem: "p", SignonRealm: "https://old.yandex.ru", Password: "hunter2", + DateCreated: 13340000000000000, + }, + yandexPassword{ + OriginURL: "https://new.yandex.ru", UsernameElem: "u", UsernameVal: "bob", + PasswordElem: "p", SignonRealm: "https://new.yandex.ru", Password: "sesame", + DateCreated: 13360000000000000, + }, ) - got, err := extractYandexPasswords(keyretriever.MasterKeys{}, path) + got, err := extractYandexPasswords(keyretriever.MasterKeys{V10: masterKey}, path) require.NoError(t, err) - require.Len(t, got, 1) - assert.Equal(t, "https://action.yandex.ru/submit", got[0].URL) // action_url, not origin_url + require.Len(t, got, 2) + + // Sorted newest-first on CreatedAt. + assert.Equal(t, "https://new.yandex.ru", got[0].URL) + assert.Equal(t, "bob", got[0].Username) + assert.Equal(t, "sesame", got[0].Password) + assert.Equal(t, "hunter2", got[1].Password) +} + +func TestExtractYandexPasswords_MasterPasswordSkipped(t *testing.T) { + masterKey := bytes.Repeat([]byte{0x11}, 32) + dataKey := bytes.Repeat([]byte{0x22}, 32) + + path := setupYandexPasswordDB(t, masterKey, dataKey, true, + yandexPassword{ + OriginURL: "https://yandex.ru", UsernameElem: "u", UsernameVal: "alice", + PasswordElem: "p", SignonRealm: "https://yandex.ru", Password: "hunter2", + DateCreated: 13340000000000000, + }, + ) + + got, err := extractYandexPasswords(keyretriever.MasterKeys{V10: masterKey}, path) + require.NoError(t, err) + assert.Empty(t, got, "master-password profiles should be skipped in v1") +} + +func TestExtractYandexPasswords_WrongMasterKey(t *testing.T) { + goodKey := bytes.Repeat([]byte{0x11}, 32) + wrongKey := bytes.Repeat([]byte{0x99}, 32) + dataKey := bytes.Repeat([]byte{0x22}, 32) + + path := setupYandexPasswordDB(t, goodKey, dataKey, false, + yandexPassword{ + OriginURL: "https://yandex.ru", UsernameElem: "u", UsernameVal: "alice", + PasswordElem: "p", SignonRealm: "https://yandex.ru", Password: "hunter2", + }, + ) + + // A wrong master key fails at the intermediate step, surfacing as an error + // from the extractor. + _, err := extractYandexPasswords(keyretriever.MasterKeys{V10: wrongKey}, path) + require.Error(t, err) +} + +func TestYandexLoginAAD_NoMasterPassword(t *testing.T) { + got := yandexLoginAAD("https://example.com/", "user", "alice", "pass", "https://example.com/", nil) + + h := sha1.New() + h.Write([]byte("https://example.com/\x00user\x00alice\x00pass\x00https://example.com/")) + want := h.Sum(nil) + + assert.Equal(t, want, got) + assert.Len(t, got, sha1.Size) +} + +func TestYandexLoginAAD_WithMasterPassword(t *testing.T) { + keyID := []byte("abc123") + got := yandexLoginAAD("u", "e1", "v1", "e2", "r", keyID) + + require.Len(t, got, sha1.Size+len(keyID)) + assert.Equal(t, keyID, got[sha1.Size:]) } diff --git a/browser/chromium/source.go b/browser/chromium/source.go index d474baf..93b71fa 100644 --- a/browser/chromium/source.go +++ b/browser/chromium/source.go @@ -94,10 +94,23 @@ func (e extensionExtractor) extract(_ keyretriever.MasterKeys, path string, data return err } -// yandexExtractors overrides Password extraction for Yandex, -// which uses action_url instead of origin_url. +// creditCardExtractor wraps a custom credit-card extract function, used by Yandex whose Ya Credit Cards DB stores +// rows as records(guid, public_data, private_data) with JSON blobs rather than Chromium's flat credit_cards table. +type creditCardExtractor struct { + fn func(keys keyretriever.MasterKeys, path string) ([]types.CreditCardEntry, error) +} + +func (e creditCardExtractor) extract(keys keyretriever.MasterKeys, path string, data *types.BrowserData) error { + var err error + data.CreditCards, err = e.fn(keys, path) + return err +} + +// yandexExtractors overrides Password and CreditCard extraction for Yandex, which wraps its data-encryption key inside +// meta.local_encryptor_data, binds per-row AAD to GCM, and stores cards as JSON blobs in a records table. var yandexExtractors = map[types.Category]categoryExtractor{ - types.Password: passwordExtractor{fn: extractYandexPasswords}, + types.Password: passwordExtractor{fn: extractYandexPasswords}, + types.CreditCard: creditCardExtractor{fn: extractYandexCreditCards}, } // operaExtractors overrides Extension extraction for Opera, diff --git a/browser/chromium/yandex_key.go b/browser/chromium/yandex_key.go new file mode 100644 index 0000000..629da17 --- /dev/null +++ b/browser/chromium/yandex_key.go @@ -0,0 +1,55 @@ +package chromium + +import ( + "database/sql" + "errors" + "fmt" + "os" + "strings" + + _ "modernc.org/sqlite" + + "github.com/moond4rk/hackbrowserdata/crypto" +) + +// errYandexMasterPasswordSet: caller warns + skips; RSA-OAEP unseal is deferred (RFC-012 §6). +var errYandexMasterPasswordSet = errors.New("yandex: profile protected by master password, skipping") + +// loadYandexDataKey honors the master-password gate and returns the per-DB data key. See RFC-012 §4.2. +func loadYandexDataKey(dbPath string, masterKey []byte) ([]byte, error) { + if len(masterKey) == 0 { + return nil, fmt.Errorf("yandex: master key not available") + } + if _, err := os.Stat(dbPath); err != nil { + return nil, fmt.Errorf("yandex db file: %w", err) + } + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return nil, err + } + defer db.Close() + + if hasMasterPassword(db) { + return nil, errYandexMasterPasswordSet + } + + var blob []byte + if err := db.QueryRow("SELECT value FROM meta WHERE key = 'local_encryptor_data'").Scan(&blob); err != nil { + return nil, fmt.Errorf("read local_encryptor_data: %w", err) + } + + dataKey, err := crypto.DecryptYandexIntermediateKey(masterKey, blob) + if err != nil { + return nil, fmt.Errorf("derive yandex data key: %w", err) + } + return dataKey, nil +} + +// hasMasterPassword: missing table (Ya Credit Cards) or empty sealed_key both mean false. +func hasMasterPassword(db *sql.DB) bool { + var sealed sql.NullString + if err := db.QueryRow("SELECT sealed_key FROM active_keys").Scan(&sealed); err != nil { + return false + } + return sealed.Valid && strings.TrimSpace(sealed.String) != "" +} diff --git a/browser/chromium/yandex_testutil_test.go b/browser/chromium/yandex_testutil_test.go new file mode 100644 index 0000000..4b26aee --- /dev/null +++ b/browser/chromium/yandex_testutil_test.go @@ -0,0 +1,208 @@ +package chromium + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "database/sql" + "encoding/hex" + "encoding/json" + "fmt" + "path/filepath" + "testing" + + _ "modernc.org/sqlite" + + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// Yandex-specific SQLite schemas and test fixtures. +// +// Ya Passman Data: +// - meta(key, value) holds local_encryptor_data +// - active_keys(key_id, sealed_key) non-empty sealed_key = master password set +// - logins(...) same column set as Chromium, minus columns we +// don't query, plus a signon_realm NOT NULL +// +// Ya Credit Cards: +// - meta(key, value) holds its own local_encryptor_data +// - records(guid, public_data, private_data) +// --------------------------------------------------------------------------- + +const yandexLoginsSchema = `CREATE TABLE logins ( + origin_url VARCHAR NOT NULL, + action_url VARCHAR, + username_element VARCHAR, + username_value VARCHAR, + password_element VARCHAR, + password_value BLOB, + signon_realm VARCHAR NOT NULL, + date_created INTEGER NOT NULL DEFAULT 0 +)` + +const yandexMetaSchema = `CREATE TABLE meta ( + key LONGVARCHAR NOT NULL UNIQUE PRIMARY KEY, + value LONGVARCHAR +)` + +const yandexActiveKeysSchema = `CREATE TABLE active_keys ( + key_id TEXT, + sealed_key TEXT +)` + +const yandexRecordsSchema = `CREATE TABLE records ( + guid TEXT PRIMARY KEY, + public_data TEXT, + private_data BLOB +)` + +// yandexTestNonce is a fixed 12-byte nonce used across fixtures so test failures +// are easy to reproduce by hand. Real Yandex uses CSPRNG nonces per row. +var yandexTestNonce = bytes.Repeat([]byte{0x77}, 12) + +// yandexMasterKeyBlobNonce is a fixed 12-byte nonce for sealing the intermediate +// data key inside meta.local_encryptor_data. Distinct from yandexTestNonce so a +// test mix-up surfaces as a decrypt error rather than a false pass. +var yandexMasterKeyBlobNonce = bytes.Repeat([]byte{0xAB}, 12) + +// yandexSignatureForFixtures duplicates crypto.yandexSignature so tests can +// construct blobs without the crypto package exporting its internal constant. +// Protobuf header bytes: field1 varint=1, field2 len=32. +var yandexSignatureForFixtures = []byte{0x08, 0x01, 0x12, 0x20} + +// yandexSealAESGCM seals plaintext under (key, nonce, aad) using AES-GCM. +func yandexSealAESGCM(t *testing.T, key, nonce, plaintext, aad []byte) []byte { + t.Helper() + block, err := aes.NewCipher(key) + require.NoError(t, err) + aead, err := cipher.NewGCM(block) + require.NoError(t, err) + return aead.Seal(nil, nonce, plaintext, aad) +} + +// buildYandexLocalEncryptorBlob produces the exact byte layout stored in +// meta.local_encryptor_data: [preamble]"v10"[12B nonce][68B plaintext + 16B GCM tag]. +// The plaintext is signature (4B) + dataKey (32B) + zero padding to 68B. +func buildYandexLocalEncryptorBlob(t *testing.T, masterKey, dataKey []byte) []byte { + t.Helper() + plaintext := append([]byte{}, yandexSignatureForFixtures...) + plaintext = append(plaintext, dataKey...) + // Pad to 68B (= 96 blob - 12 nonce - 16 tag) to match the on-disk shape. + plaintext = append(plaintext, make([]byte, 68-len(plaintext))...) + + ciphertext := yandexSealAESGCM(t, masterKey, yandexMasterKeyBlobNonce, plaintext, nil) + blob := []byte{0x12, 0x34, 0x56, 0x78} // arbitrary preamble + blob = append(blob, "v10"...) + blob = append(blob, yandexMasterKeyBlobNonce...) + blob = append(blob, ciphertext...) + return blob +} + +// yandexPassword describes one row of test data for the logins table. +type yandexPassword struct { + OriginURL, UsernameElem, UsernameVal, PasswordElem, SignonRealm, Password string + DateCreated int64 +} + +// setupYandexPasswordDB creates a Ya Passman Data SQLite file with meta, +// active_keys, and logins populated. Each logins row is sealed under dataKey +// using the same per-row AAD derivation the production extractor expects. +// Set hasMasterPassword=true to simulate a profile protected by a master +// password (a non-empty sealed_key row). +func setupYandexPasswordDB(t *testing.T, masterKey, dataKey []byte, hasMasterPassword bool, rows ...yandexPassword) string { + t.Helper() + path := filepath.Join(t.TempDir(), "Ya Passman Data") + db, err := sql.Open("sqlite", path) + require.NoError(t, err) + defer db.Close() + + for _, schema := range []string{yandexLoginsSchema, yandexMetaSchema, yandexActiveKeysSchema} { + _, err = db.Exec(schema) + require.NoError(t, err) + } + + blob := buildYandexLocalEncryptorBlob(t, masterKey, dataKey) + _, err = db.Exec(`INSERT INTO meta (key, value) VALUES ('local_encryptor_data', ?)`, blob) + require.NoError(t, err) + + if hasMasterPassword { + _, err = db.Exec(`INSERT INTO active_keys (key_id, sealed_key) VALUES ('kid', 'sealed-opaque')`) + require.NoError(t, err) + } + + for _, r := range rows { + aad := yandexLoginAAD(r.OriginURL, r.UsernameElem, r.UsernameVal, r.PasswordElem, r.SignonRealm, nil) + ciphertext := yandexSealAESGCM(t, dataKey, yandexTestNonce, []byte(r.Password), aad) + passwordBlob := append([]byte{}, yandexTestNonce...) + passwordBlob = append(passwordBlob, ciphertext...) + stmt := fmt.Sprintf( + `INSERT INTO logins (origin_url, action_url, username_element, username_value, + password_element, password_value, signon_realm, date_created) + VALUES ('%s', '', '%s', '%s', '%s', x'%s', '%s', %d)`, + r.OriginURL, r.UsernameElem, r.UsernameVal, r.PasswordElem, + hex.EncodeToString(passwordBlob), r.SignonRealm, r.DateCreated, + ) + _, err = db.Exec(stmt) + require.NoError(t, err) + } + return path +} + +// yandexCreditCard describes one row of test data for the records table. +type yandexCreditCard struct { + GUID string + CardHolder, CardTitle, ExpYear, ExpMonth string + FullCardNumber, PinCode, SecretComment string +} + +// setupYandexCreditCardDB creates a Ya Credit Cards SQLite file with meta and +// records populated. Each record's private_data is sealed under dataKey with +// AAD = guid bytes, matching the production extractor. +func setupYandexCreditCardDB(t *testing.T, masterKey, dataKey []byte, rows ...yandexCreditCard) string { + t.Helper() + path := filepath.Join(t.TempDir(), "Ya Credit Cards") + db, err := sql.Open("sqlite", path) + require.NoError(t, err) + defer db.Close() + + for _, schema := range []string{yandexRecordsSchema, yandexMetaSchema} { + _, err = db.Exec(schema) + require.NoError(t, err) + } + + blob := buildYandexLocalEncryptorBlob(t, masterKey, dataKey) + _, err = db.Exec(`INSERT INTO meta (key, value) VALUES ('local_encryptor_data', ?)`, blob) + require.NoError(t, err) + + for _, r := range rows { + public := yandexPublicData{ + CardHolder: r.CardHolder, + CardTitle: r.CardTitle, + ExpireDateYear: r.ExpYear, + ExpireDateMonth: r.ExpMonth, + } + publicJSON, err := json.Marshal(public) + require.NoError(t, err) + + private := yandexPrivateData{ + FullCardNumber: r.FullCardNumber, + PinCode: r.PinCode, + SecretComment: r.SecretComment, + } + privateJSON, err := json.Marshal(private) + require.NoError(t, err) + + aad := yandexCardAAD(r.GUID, nil) + ciphertext := yandexSealAESGCM(t, dataKey, yandexTestNonce, privateJSON, aad) + privateBlob := append([]byte{}, yandexTestNonce...) + privateBlob = append(privateBlob, ciphertext...) + + _, err = db.Exec( + `INSERT INTO records (guid, public_data, private_data) VALUES (?, ?, ?)`, + r.GUID, string(publicJSON), privateBlob, + ) + require.NoError(t, err) + } + return path +} diff --git a/crypto/crypto.go b/crypto/crypto.go index e7136d0..1602e61 100644 --- a/crypto/crypto.go +++ b/crypto/crypto.go @@ -98,6 +98,23 @@ func AESGCMDecrypt(key, nonce, ciphertext []byte) ([]byte, error) { return aead.Open(nil, nonce, ciphertext, nil) } +// AESGCMDecryptBlob decrypts a blob shaped as [12B nonce][ciphertext+16B GCM tag] with caller-supplied AAD. +// Used by protocols that wrap AES-GCM output with a fixed-length nonce prefix (Yandex passwords/cards). +func AESGCMDecryptBlob(key, blob, aad []byte) ([]byte, error) { + if len(blob) < gcmNonceSize { + return nil, errShortCiphertext + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + aead, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + return aead.Open(nil, blob[:gcmNonceSize], blob[gcmNonceSize:], aad) +} + // cbcEncrypt adds PKCS5 padding and encrypts plaintext in CBC mode. func cbcEncrypt(block cipher.Block, iv, plaintext []byte) ([]byte, error) { if len(iv) != block.BlockSize() { diff --git a/crypto/crypto_windows.go b/crypto/crypto_windows.go index ef58511..7f36b39 100644 --- a/crypto/crypto_windows.go +++ b/crypto/crypto_windows.go @@ -18,18 +18,6 @@ func DecryptChromium(key, ciphertext []byte) ([]byte, error) { return AESGCMDecrypt(key, nonce, payload) } -// DecryptYandex decrypts a Yandex-encrypted value. -// TODO: Yandex uses the same AES-GCM format as Chromium for now; -// update when Yandex-specific decryption diverges. -func DecryptYandex(key, ciphertext []byte) ([]byte, error) { - if len(ciphertext) < minGCMDataSize { - return nil, errShortCiphertext - } - nonce := ciphertext[versionPrefixLen : versionPrefixLen+gcmNonceSize] - payload := ciphertext[versionPrefixLen+gcmNonceSize:] - return AESGCMDecrypt(key, nonce, payload) -} - // DecryptDPAPI decrypts a DPAPI-protected blob using the current user's // master key. The actual Win32 call (and its DATA_BLOB / LocalFree dance) // lives in utils/winapi so every package that needs a syscall handle diff --git a/crypto/yandex.go b/crypto/yandex.go new file mode 100644 index 0000000..608a60e --- /dev/null +++ b/crypto/yandex.go @@ -0,0 +1,48 @@ +package crypto + +import ( + "bytes" + "errors" +) + +// yandexSignature is the protobuf wire-format header (field1 varint=1, field2 len=32) on every wrapped key. +var yandexSignature = []byte{0x08, 0x01, 0x12, 0x20} + +var localEncryptorPrefix = []byte("v10") + +const ( + yandexIntKeyBlobLen = 96 // 12B nonce + 68B ciphertext + 16B GCM tag + yandexDataKeyLen = 32 +) + +var ( + errYandexMarkerNotFound = errors.New("yandex: v10 marker not found in local_encryptor_data") + errYandexBlobShort = errors.New("yandex: encrypted intermediate key truncated") + errYandexBadSignature = errors.New("yandex: invalid protobuf signature on decrypted key") + errYandexKeyTooShort = errors.New("yandex: decrypted intermediate key shorter than 32 bytes") +) + +// DecryptYandexIntermediateKey unwraps the per-DB data key from meta.local_encryptor_data. See RFC-012 §4.2. +func DecryptYandexIntermediateKey(masterKey, blob []byte) ([]byte, error) { + idx := bytes.Index(blob, localEncryptorPrefix) + if idx < 0 { + return nil, errYandexMarkerNotFound + } + payload := blob[idx+len(localEncryptorPrefix):] + if len(payload) < yandexIntKeyBlobLen { + return nil, errYandexBlobShort + } + + plaintext, err := AESGCMDecryptBlob(masterKey, payload[:yandexIntKeyBlobLen], nil) + if err != nil { + return nil, err + } + if !bytes.HasPrefix(plaintext, yandexSignature) { + return nil, errYandexBadSignature + } + plaintext = plaintext[len(yandexSignature):] + if len(plaintext) < yandexDataKeyLen { + return nil, errYandexKeyTooShort + } + return plaintext[:yandexDataKeyLen], nil +} diff --git a/crypto/yandex_test.go b/crypto/yandex_test.go new file mode 100644 index 0000000..52b803c --- /dev/null +++ b/crypto/yandex_test.go @@ -0,0 +1,150 @@ +package crypto + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "errors" + "testing" +) + +// encryptAESGCM is a test helper that produces a GCM ciphertext with caller-supplied AAD. +func encryptAESGCM(t *testing.T, key, nonce, plaintext, aad []byte) []byte { + t.Helper() + block, err := aes.NewCipher(key) + if err != nil { + t.Fatalf("aes.NewCipher: %v", err) + } + aead, err := cipher.NewGCM(block) + if err != nil { + t.Fatalf("cipher.NewGCM: %v", err) + } + return aead.Seal(nil, nonce, plaintext, aad) +} + +// testPlaintextPayloadLen: plaintext size before AES-GCM seal inside meta.local_encryptor_data. +// 96 (blob) - 12 (nonce) - 16 (tag) = 68 bytes. +const testPlaintextPayloadLen = yandexIntKeyBlobLen - gcmNonceSize - 16 + +func buildLocalEncryptorBlob(t *testing.T, masterKey, dataKey []byte) []byte { + t.Helper() + nonce := bytes.Repeat([]byte{0xAB}, gcmNonceSize) + plaintext := append([]byte{}, yandexSignature...) + plaintext = append(plaintext, dataKey...) + plaintext = append(plaintext, make([]byte, testPlaintextPayloadLen-len(plaintext))...) + ciphertext := encryptAESGCM(t, masterKey, nonce, plaintext, nil) + if len(ciphertext) != yandexIntKeyBlobLen-gcmNonceSize { + t.Fatalf("unexpected ciphertext len: got %d want %d", len(ciphertext), yandexIntKeyBlobLen-gcmNonceSize) + } + blob := []byte{0x01, 0x02, 0x03, 0x04} // arbitrary protobuf preamble + blob = append(blob, localEncryptorPrefix...) + blob = append(blob, nonce...) + blob = append(blob, ciphertext...) + blob = append(blob, 0xFF, 0xFE) // trailing junk should be ignored + return blob +} + +func TestDecryptYandexIntermediateKey_RoundTrip(t *testing.T) { + masterKey := bytes.Repeat([]byte{0x11}, 32) + dataKey := bytes.Repeat([]byte{0x22}, yandexDataKeyLen) + blob := buildLocalEncryptorBlob(t, masterKey, dataKey) + + got, err := DecryptYandexIntermediateKey(masterKey, blob) + if err != nil { + t.Fatalf("DecryptYandexIntermediateKey: %v", err) + } + if !bytes.Equal(got, dataKey) { + t.Errorf("key mismatch: got %x want %x", got, dataKey) + } +} + +func TestDecryptYandexIntermediateKey_MissingMarker(t *testing.T) { + _, err := DecryptYandexIntermediateKey(bytes.Repeat([]byte{0x11}, 32), []byte("no marker here")) + if !errors.Is(err, errYandexMarkerNotFound) { + t.Fatalf("expected errYandexMarkerNotFound, got %v", err) + } +} + +func TestDecryptYandexIntermediateKey_Truncated(t *testing.T) { + blob := append([]byte{0x00, 0x00}, localEncryptorPrefix...) + blob = append(blob, bytes.Repeat([]byte{0x55}, yandexIntKeyBlobLen-1)...) + _, err := DecryptYandexIntermediateKey(bytes.Repeat([]byte{0x11}, 32), blob) + if !errors.Is(err, errYandexBlobShort) { + t.Fatalf("expected errYandexBlobShort, got %v", err) + } +} + +func TestDecryptYandexIntermediateKey_BadSignature(t *testing.T) { + masterKey := bytes.Repeat([]byte{0x11}, 32) + nonce := bytes.Repeat([]byte{0xAB}, gcmNonceSize) + plaintext := append([]byte{0xDE, 0xAD, 0xBE, 0xEF}, bytes.Repeat([]byte{0x22}, yandexDataKeyLen)...) + plaintext = append(plaintext, make([]byte, testPlaintextPayloadLen-len(plaintext))...) + ciphertext := encryptAESGCM(t, masterKey, nonce, plaintext, nil) + blob := append([]byte{}, localEncryptorPrefix...) + blob = append(blob, nonce...) + blob = append(blob, ciphertext...) + + _, err := DecryptYandexIntermediateKey(masterKey, blob) + if !errors.Is(err, errYandexBadSignature) { + t.Fatalf("expected errYandexBadSignature, got %v", err) + } +} + +// TestDecryptYandexIntermediateKey_TrailingDataIgnored verifies that trailing bytes past +// signature+32 are discarded. +func TestDecryptYandexIntermediateKey_TrailingDataIgnored(t *testing.T) { + masterKey := bytes.Repeat([]byte{0x11}, 32) + nonce := bytes.Repeat([]byte{0xAB}, gcmNonceSize) + plaintext := append([]byte{}, yandexSignature...) + plaintext = append(plaintext, bytes.Repeat([]byte{0x22}, 16)...) + plaintext = append(plaintext, make([]byte, testPlaintextPayloadLen-len(plaintext))...) + ciphertext := encryptAESGCM(t, masterKey, nonce, plaintext, nil) + blob := append([]byte{}, localEncryptorPrefix...) + blob = append(blob, nonce...) + blob = append(blob, ciphertext...) + + got, err := DecryptYandexIntermediateKey(masterKey, blob) + if err != nil { + t.Fatalf("DecryptYandexIntermediateKey: %v", err) + } + want := bytes.Repeat([]byte{0x22}, 16) + want = append(want, make([]byte, 16)...) + if !bytes.Equal(got, want) { + t.Errorf("key mismatch: got %x want %x", got, want) + } +} + +func TestAESGCMDecryptBlob_RoundTrip(t *testing.T) { + key := bytes.Repeat([]byte{0x55}, 32) + nonce := bytes.Repeat([]byte{0x66}, gcmNonceSize) + aad := []byte("row-aad") + plaintext := []byte("row-plaintext") + blob := append([]byte{}, nonce...) + blob = append(blob, encryptAESGCM(t, key, nonce, plaintext, aad)...) + + got, err := AESGCMDecryptBlob(key, blob, aad) + if err != nil { + t.Fatalf("AESGCMDecryptBlob: %v", err) + } + if !bytes.Equal(got, plaintext) { + t.Errorf("plaintext mismatch: got %q want %q", got, plaintext) + } +} + +func TestAESGCMDecryptBlob_BadAAD(t *testing.T) { + key := bytes.Repeat([]byte{0x55}, 32) + nonce := bytes.Repeat([]byte{0x66}, gcmNonceSize) + blob := append([]byte{}, nonce...) + blob = append(blob, encryptAESGCM(t, key, nonce, []byte("x"), []byte("aad-A"))...) + + if _, err := AESGCMDecryptBlob(key, blob, []byte("aad-B")); err == nil { + t.Fatal("expected authentication failure with mismatched AAD") + } +} + +func TestAESGCMDecryptBlob_TooShort(t *testing.T) { + _, err := AESGCMDecryptBlob(bytes.Repeat([]byte{0x55}, 32), []byte{0x01, 0x02}, nil) + if !errors.Is(err, errShortCiphertext) { + t.Fatalf("expected errShortCiphertext, got %v", err) + } +} diff --git a/output/reflect_test.go b/output/reflect_test.go index 033d79e..e14fdca 100644 --- a/output/reflect_test.go +++ b/output/reflect_test.go @@ -58,7 +58,7 @@ func TestStructCSVHeader(t *testing.T) { {"BookmarkEntry", types.BookmarkEntry{}, []string{"id", "name", "type", "url", "folder", "created_at"}}, {"HistoryEntry", types.HistoryEntry{}, []string{"url", "title", "visit_count", "last_visit"}}, {"DownloadEntry", types.DownloadEntry{}, []string{"url", "target_path", "mime_type", "total_bytes", "start_time", "end_time"}}, - {"CreditCardEntry", types.CreditCardEntry{}, []string{"guid", "name", "number", "exp_month", "exp_year", "nick_name", "address"}}, + {"CreditCardEntry", types.CreditCardEntry{}, []string{"guid", "name", "number", "exp_month", "exp_year", "nick_name", "address", "cvc", "comment"}}, {"StorageEntry", types.StorageEntry{}, []string{"is_meta", "url", "key", "value"}}, {"ExtensionEntry", types.ExtensionEntry{}, []string{"name", "id", "description", "version", "homepage_url", "enabled"}}, } diff --git a/rfcs/010-chrome-abe-integration.md b/rfcs/010-chrome-abe-integration.md index eaf4065..ae60cef 100644 --- a/rfcs/010-chrome-abe-integration.md +++ b/rfcs/010-chrome-abe-integration.md @@ -17,17 +17,15 @@ Related RFCs: - [RFC-006](006-key-retrieval-mechanisms.md) — `KeyRetriever` / `ChainRetriever` - [RFC-009](009-windows-locked-file-bypass.md) — other Windows-specific handling -### 1.1 Tested matrix (as of 2026-04-19) +### 1.1 Compatibility contract -Single source of truth for version pins and observed-working targets. When re-validating, update dates and re-run the regression flow documented in the author's private playbook (not in this RFC). - -| Component | Contract | Last verified | -|---|---|---| -| Go toolchain | **1.20** (pinned; Go 1.21+ drops Win7) | 1.20.14 | -| Windows host | Any Win10 1909+ (PE loader + UCRT) | Windows 10 19044 | -| Chrome family | Any v127+ (ABE introduced) | Chrome 147.0.7727.57 | -| zig toolchain | 0.13+ (for `make payload`) | 0.16.0 | -| Target arch | x86_64 only (x86 / ARM64 reserved) | x86_64 | +| Component | Contract | +|---|---| +| Go toolchain | **1.20** (pinned; Go 1.21+ drops Win7) | +| Windows host | Any Win10 1909+ (PE loader + UCRT) | +| Chrome family | Any v127+ (ABE introduced) | +| zig toolchain | 0.13+ (for `make payload`) | +| Target arch | x86_64 only (x86 / ARM64 reserved) | ## 2. The constraint that shapes the design @@ -280,20 +278,18 @@ Tempting — it's known-good. But: C++ in an otherwise pure-C/Go repo; ASM tramp ## 10. Browser coverage -As of 2026-04-19, tested against Chrome 147 family. - -| Browser class | Behavior | Status | -|---|---|---| -| Chrome Stable/Beta, Brave, CocCoc | ABE v20 via `CHROME_BASE` slot (5) | ✅ verified (cookies + passwords, zero non-ASCII in output) | -| Microsoft Edge | ABE v20 via `EDGE` slot (8); v2 `E_NOINTERFACE` → v1 fallback succeeds | ✅ verified | -| Avast Secure Browser | ABE v20 via `AVAST` slot (13) | ⚠️ table entry shipped; not yet sandbox-tested | -| Opera / OperaGX / Vivaldi / Yandex / Arc / 360 / QQ / Sogou | Not in `com_iid.c` | ⚠️ legacy v10 cookies still decrypt via DPAPI; v20 cookies do not | +| Browser class | Behavior | +|---|---| +| Chrome Stable/Beta, Brave, CocCoc | ABE v20 via `CHROME_BASE` slot (5) | +| Microsoft Edge | ABE v20 via `EDGE` slot (8); v2 `E_NOINTERFACE` → v1 fallback succeeds | +| Avast Secure Browser | ABE v20 via `AVAST` slot (13) | +| Opera / OperaGX / Vivaldi / Yandex / Arc / 360 / QQ / Sogou | Not in `com_iid.c`; legacy v10 cookies still decrypt via DPAPI, v20 cookies do not | Authoritative CLSID/IID table: `crypto/windows/abe_native/com_iid.c`. ## 11. Adding support for a new Chromium fork -Three steps. Detail (dump scripts, CLSID discovery) lives in private maintainer notes. +Three steps. 1. **Discover CLSID** — find the fork's elevation Windows service, look up its AppID in `HKLM\SOFTWARE\Classes\AppID`, then the CLSID that binds to it in `HKLM\SOFTWARE\Classes\CLSID`. 2. **Mine IIDs from TypeLib** — the interface IIDs live in the TypeLib resource of `\Application\\elevation_service.exe`. PowerShell + `ITypeLib.GetTypeInfo` enumerates them. Map `IElevator` → v1 IID, `IElevator2` → v2 IID (absent for older vendors). @@ -307,8 +303,8 @@ Edit `crypto/windows/abe_native/com_iid.c` (add the entry), `utils/winutil/brows - Non-`com_iid.c` browsers (Opera, Vivaldi, Yandex, Arc, 360, QQ, Sogou) fall back to DPAPI; v20 cookies remain encrypted. Fix = §11 procedure per vendor. - ARM64 Windows unsupported. Payload is `x86_64-windows-gnu` only. xaitax ships ARM64; we'd need parallel payload builds + runtime arch dispatch. -- Chrome v20 domain-binding prefix: injector-old strips 32 bytes at the start of v20 plaintext. Not observed on Chrome 147 sandbox outputs; left unimplemented. Re-add if a future test surfaces the prefix. -- Running-browser handling: if the user has the target browser open we spawn a second instance. No observed conflict, but some vendors (Opera GX) serialize elevation service; an opt-in `--kill-running` is future work. +- Chrome v20 domain-binding prefix: injector-old strips 32 bytes at the start of v20 plaintext. Left unimplemented pending evidence that current Chrome versions emit this prefix; re-add if encountered. +- Running-browser handling: if the user has the target browser open we spawn a second instance. Some vendors (Opera GX) serialize the elevation service, which could surface conflicts; an opt-in `--kill-running` is future work. **Future** (ordered by value): diff --git a/rfcs/012-yandex-decryption.md b/rfcs/012-yandex-decryption.md new file mode 100644 index 0000000..450d2ca --- /dev/null +++ b/rfcs/012-yandex-decryption.md @@ -0,0 +1,158 @@ +# RFC-012: Yandex Browser Decryption + +**Author**: moonD4rk +**Status**: Living Document +**Created**: 2026-04-22 +**Last updated**: 2026-04-22 + +## 1. Overview + +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: + +1. The Chromium master key (DPAPI on Windows, Keychain on macOS) does not decrypt `password_value` directly — it decrypts a per-DB *intermediate* key stored in `meta.local_encryptor_data`. That intermediate key is what actually decrypts rows. +2. Each row's AES-GCM ciphertext is sealed with row-specific Additional Authenticated Data (AAD). A password row's AAD is a SHA-1 digest over five form fields joined by `\x00`; a credit-card row's AAD is the row's `guid`. AAD mismatch → GCM tag failure → empty plaintext. +3. Credit cards live in `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: + +- [RFC-003](003-chromium-encryption.md) — Chromium cipher versions (v10 / v11 / v20) +- [RFC-006](006-key-retrieval-mechanisms.md) — master-key retrieval chain + +Deferred to a follow-up RFC / PR: + +- Master-password (RSA-OAEP + PBKDF2) unseal path. +- Windows ABE v20 for Yandex — not in scope until Yandex adopts App-Bound Encryption. +- Linux support; Yandex Browser has no official Linux build. + +## 2. Protocol differences at a glance + +| 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) | + +## 3. On-disk layout + +### 3.1 `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. + +### 3.2 Password row (`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. + +### 3.3 Credit card row (`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`. + +## 4. Architecture + +### 4.1 Two-level key hierarchy + +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. + +### 4.2 Recovery steps + +For every target DB (`Ya Passman Data` for passwords, `Ya Credit Cards` for cards), the extractor runs the same five steps: + +1. **Master key**: read `Local State`, base64-decode `os_crypt.encrypted_key`, strip the `DPAPI` prefix, and unwrap it via DPAPI (Windows) or Keychain (macOS). Yields 32 bytes. +2. **Open DB**: open the target SQLite file (a temp copy is used to avoid lock contention if the browser is running). +3. **Master-password gate**: `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. +4. **Data key**: `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. +5. **Per-row decryption**: for each row, compute AAD (see §4.4), split `[12B nonce][ct+tag]`, AES-GCM-decrypt with the data key under that AAD. + +### 4.3 Key hierarchy + +| 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 | + +### 4.4 Per-category decryption inputs + +| 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. + +### 4.5 Independence property + +The two level-2 data keys are unwrapped from **different** `meta.local_encryptor_data` blobs — one per DB. This matters in two ways: + +- A profile with a master password blocks passwords (step 3 trips) but credit cards can still decrypt, because the card DB has no `active_keys` table. +- Corruption of one DB's meta blob does not cascade to the other. + +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. + +## 5. Layering rationale + +### 5.1 Yandex-specific derivation stays in the extract path, not the key-retrieval layer + +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. + +### 5.2 AAD construction belongs with the consumer, not the crypto layer + +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: + +- A single generic AES-GCM-with-AAD primitive in the crypto layer. Any current or future protocol that needs per-row AAD can reuse it without the crypto layer growing per-product surface. +- Yandex-specific AAD helpers next to the consumer that builds the AAD inputs. Product knowledge stays with the product. + +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). + +## 6. Non-goals and deferred work + +1. **Master-password unseal** (#90 edge case). Profiles with a non-empty `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. +2. **Windows ABE v20 for Yandex**. Yandex has not adopted App-Bound Encryption. If that changes, Yandex joins the RFC-010 vendor table and the ABE path begins returning a non-empty v20 key for Yandex ciphertexts. +3. **Linux support**. Yandex Browser has no official Linux release, so there is no Linux code path to add. + +## 7. Test strategy + +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: + +- Intermediate-key unwrap: round-trip, missing `v10` marker, truncated blob, bad protobuf signature, trailing bytes ignored. +- AES-GCM-with-AAD primitive: round-trip, mismatched AAD surfaces as authentication failure, under-sized blob surfaces as a distinct error. +- Password extraction: round-trip on multi-row fixtures, master-password skip path, wrong master key surfaces as error. +- Credit-card extraction: round-trip on multi-card fixtures verifying every JSON field maps to the output schema; count; wrong master key surfaces as error. +- AAD formulas: SHA-1 field concatenation (passwords), GUID bytes (cards), both with and without a master-password keyID appended. + +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. + +## 8. Rollout + +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. diff --git a/types/models.go b/types/models.go index ea5278b..9c9d8f4 100644 --- a/types/models.go +++ b/types/models.go @@ -52,7 +52,8 @@ type DownloadEntry struct { EndTime time.Time `json:"end_time" csv:"end_time"` } -// CreditCardEntry represents a single saved credit card. +// CreditCardEntry represents a single saved credit card. CVC and Comment are +// Yandex-specific; Chromium leaves them empty. type CreditCardEntry struct { GUID string `json:"guid" csv:"guid"` Name string `json:"name" csv:"name"` @@ -61,6 +62,8 @@ type CreditCardEntry struct { ExpYear string `json:"exp_year" csv:"exp_year"` NickName string `json:"nick_name" csv:"nick_name"` Address string `json:"address" csv:"address"` + CVC string `json:"cvc" csv:"cvc"` + Comment string `json:"comment" csv:"comment"` } // StorageEntry represents a single key-value pair from local or session storage.