feat(yandex): password and credit card decryption (#585)

This commit is contained in:
Roger
2026-04-23 17:00:09 +08:00
committed by GitHub
parent 7e64d50891
commit 0c6c781567
17 changed files with 1005 additions and 48 deletions
+5 -1
View File
@@ -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)
+1
View File
@@ -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)
+88
View File
@@ -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
}
@@ -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))
}
+71 -4
View File
@@ -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
}
+78 -5
View File
@@ -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:])
}
+16 -3
View File
@@ -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,
+55
View File
@@ -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) != ""
}
+208
View File
@@ -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
}
+17
View File
@@ -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() {
-12
View File
@@ -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
+48
View File
@@ -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
}
+150
View File
@@ -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)
}
}
+1 -1
View File
@@ -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"}},
}
+17 -21
View File
@@ -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 `<InstallDir>\Application\<version>\elevation_service.exe`. PowerShell + `ITypeLib.GetTypeInfo` enumerates them. Map `IElevator<Vendor>` → v1 IID, `IElevator2<Vendor>` → 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):
+158
View File
@@ -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.
+4 -1
View File
@@ -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.