mirror of
https://github.com/moonD4rk/HackBrowserData.git
synced 2026-05-19 18:58:03 +02:00
feat(yandex): password and credit card decryption (#585)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:])
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) != ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,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):
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user