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
|
||||
}
|
||||
Reference in New Issue
Block a user