Files
Roger 1a3aea553e feat: add Firefox Browser with new v2 architecture (#536)
* feat: add Firefox Browser implementation with new v2 architecture

Add Firefox NewBrowsers + Extract pipeline following the Chromium v2
pattern. Firefox-specific differences handled:
- Profile discovery: random directory names (e.g. abc123.default-release)
- Master key: NSS/ASN1PBE from key4.db (platform-agnostic, no DPAPI/Keychain)
- Key validation: reuse logins.json from acquireFiles tempPaths
- Extract: only Password needs masterKey; Cookie is plaintext
- No CreditCard or SessionStorage support

Files:
- firefox_new.go: Browser struct, NewBrowsers, Extract, getMasterKey,
  extractCategory, deriveKeys, validateKeyWithLogins, profile discovery
- masterkey.go: extracted shared NSS logic (processMasterKey, queryMetaData,
  queryNssPrivateCandidates, parseLoginCipherPairs, canDecryptAnyLoginCipherPair)
- firefox_new_test.go: table-driven tests with shared fixtures
- source.go: remove dataSource wrapper, use []sourcePath directly
- firefox.go: remove functions moved to masterkey.go

* fix: address Copilot review feedback on Firefox v2

- Fix stale comment referencing removed readLoginCipherPairs
- Rename finallyKey to derivedKey for clarity in processMasterKey
- Add sqlite driver import to masterkey.go for self-containedness

* refactor: rewrite Firefox masterkey and improve naming

Masterkey rewrite:
- Replace raw SQL functions with structured key4DB type (globalSalt,
  passwordCheck, privateKeys) for clear data modeling
- Split processMasterKey into verifyPasswordCheck + decryptPrivateKey
- Add nssKeyTypeTag constant for the magic bytes
- Rename finallyKey to derivedKey
- Add sqlite driver import for self-containedness
- Return error (not fallback) when logins validation explicitly fails

Naming cleanup:
- loginPair → encryptedLogin (clarify these are encrypted blobs)
- parseLoginPairs → sampleEncryptedLogins (clarify sampling purpose)
- canDecryptLogin → tryDecryptLogins (accurate verb, plural alignment)
- Expand abbreviated variables: p→login, uPBE→userPBE, pPBE→pwdPBE

Password extraction:
- Keep entries when decryptPBE fails (URL preserved, user/pwd empty)
- Align with Chromium behavior where decrypt failure doesn't skip records

Old code cleanup:
- firefox.go GetMasterKey now delegates to retrieveMasterKey
- Remove functions moved to masterkey.go

* docs: add RFC-003 for crypto package naming cleanup

Track accumulated naming and structural issues in crypto/asn1pbe.go
and cross-browser shared code for a future dedicated refactoring pass.

* refactor: move masterkey tests to masterkey_test.go

- Rename firefox_test.go to masterkey_test.go since all tests in
  this file test masterkey.go functions (readKey4DB, sampleEncryptedLogins)
- Fix TestReadKey4DB to check nssPrivate rows as a set instead of
  assuming SQLite insertion order
- Future deletion of firefox.go won't accidentally remove masterkey tests
2026-04-04 01:41:02 +08:00

210 lines
6.2 KiB
Go

package firefox
import (
"bytes"
"database/sql"
"encoding/base64"
"errors"
"fmt"
"os"
"github.com/tidwall/gjson"
_ "modernc.org/sqlite"
"github.com/moond4rk/hackbrowserdata/crypto"
"github.com/moond4rk/hackbrowserdata/log"
)
// key4DB holds the parsed contents of Firefox's key4.db NSS key storage.
//
// Firefox stores the master encryption key in key4.db using two SQLite tables:
// - metaData: contains the global salt and an encrypted "password-check" marker
// - nssPrivate: contains one or more encrypted master key candidates
//
// Reference: https://searchfox.org/mozilla-central/source/security/nss/lib/softoken/
type key4DB struct {
globalSalt []byte // metaData.item1: salt used as PBE decryption input
passwordCheck []byte // metaData.item2: encrypted marker to verify DB integrity
privateKeys []privateKey // nssPrivate rows: encrypted master key candidates
}
// privateKey is a single encrypted master key entry from nssPrivate.
type privateKey struct {
encrypted []byte // a11: PBE-encrypted master key blob
typeTag []byte // a102: key type identifier (must match nssKeyTypeTag)
}
// nssKeyTypeTag identifies valid master key entries in key4.db.
// Only nssPrivate rows where a102 matches this tag contain actual master keys;
// other rows may be certificates or other NSS objects.
// See: https://searchfox.org/mozilla-central/source/security/nss/lib/softoken/pkcs11i.h
var nssKeyTypeTag = []byte{248, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}
// readKey4DB opens key4.db and parses it into a structured key4DB.
func readKey4DB(path string) (*key4DB, error) {
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("open key4.db: %w", err)
}
defer db.Close()
var record key4DB
// Read metaData table
const metaQuery = `SELECT item1, item2 FROM metaData WHERE id = 'password'`
if err := db.QueryRow(metaQuery).Scan(&record.globalSalt, &record.passwordCheck); err != nil {
return nil, fmt.Errorf("query metaData: %w", err)
}
// Read nssPrivate table
const nssQuery = `SELECT a11, a102 FROM nssPrivate`
rows, err := db.Query(nssQuery)
if err != nil {
return nil, fmt.Errorf("query nssPrivate: %w", err)
}
defer rows.Close()
for rows.Next() {
var pk privateKey
if err := rows.Scan(&pk.encrypted, &pk.typeTag); err != nil {
return nil, fmt.Errorf("scan nssPrivate row: %w", err)
}
record.privateKeys = append(record.privateKeys, pk)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate nssPrivate: %w", err)
}
if len(record.privateKeys) == 0 {
return nil, errors.New("nssPrivate table is empty")
}
return &record, nil
}
// deriveKeys verifies the database integrity via the password-check marker,
// then decrypts all valid master key candidates.
func (k *key4DB) deriveKeys() ([][]byte, error) {
if err := k.verifyPasswordCheck(); err != nil {
return nil, err
}
var keys [][]byte
for _, pk := range k.privateKeys {
if !bytes.Equal(pk.typeTag, nssKeyTypeTag) {
continue
}
key, err := k.decryptPrivateKey(pk)
if err != nil {
log.Debugf("decrypt nss private key: %v", err)
continue
}
keys = append(keys, key)
}
return keys, nil
}
// verifyPasswordCheck decrypts the password-check marker from metaData
// to confirm the database is valid and accessible.
func (k *key4DB) verifyPasswordCheck() error {
pbe, err := crypto.NewASN1PBE(k.passwordCheck)
if err != nil {
return fmt.Errorf("parse password check: %w", err)
}
plain, err := pbe.Decrypt(k.globalSalt)
if err != nil {
return fmt.Errorf("decrypt password check: %w", err)
}
if !bytes.Contains(plain, []byte("password-check")) {
return errors.New("password check verification failed")
}
return nil
}
// decryptPrivateKey decrypts a single master key candidate using the global salt.
func (k *key4DB) decryptPrivateKey(pk privateKey) ([]byte, error) {
pbe, err := crypto.NewASN1PBE(pk.encrypted)
if err != nil {
return nil, fmt.Errorf("parse private key: %w", err)
}
derivedKey, err := pbe.Decrypt(k.globalSalt)
if err != nil {
return nil, fmt.Errorf("decrypt private key: %w", err)
}
if len(derivedKey) < 24 {
return nil, fmt.Errorf("derived key too short: %d bytes (need >= 24)", len(derivedKey))
}
// Firefox 144+ uses AES-256-CBC instead of 3DES; the full derived key
// must be preserved to support modern cipher suites.
return derivedKey, nil
}
// encryptedLogin holds PBE-encrypted credentials from logins.json,
// used as test samples for master key validation.
type encryptedLogin struct {
username []byte // PBE-encrypted username blob
password []byte // PBE-encrypted password blob
}
// validateKeyWithLogins reads logins.json and returns the first key that
// can successfully decrypt an actual login entry. Returns nil if no key matches.
func validateKeyWithLogins(keys [][]byte, loginsPath string) []byte {
raw, err := os.ReadFile(loginsPath)
if err != nil {
return nil
}
samples := sampleEncryptedLogins(raw)
if len(samples) == 0 {
return nil
}
for _, key := range keys {
if tryDecryptLogins(key, samples) {
return key
}
}
return nil
}
// sampleEncryptedLogins extracts up to 5 encrypted login entries from
// logins.json as test samples for master key validation.
func sampleEncryptedLogins(raw []byte) []encryptedLogin {
arr := gjson.GetBytes(raw, "logins").Array()
var samples []encryptedLogin
for _, v := range arr {
userRaw, err := base64.StdEncoding.DecodeString(v.Get("encryptedUsername").String())
if err != nil {
continue
}
pwdRaw, err := base64.StdEncoding.DecodeString(v.Get("encryptedPassword").String())
if err != nil {
continue
}
samples = append(samples, encryptedLogin{username: userRaw, password: pwdRaw})
if len(samples) >= 5 {
break
}
}
return samples
}
// tryDecryptLogins checks if masterKey can decrypt at least one encrypted
// login entry (both username and password).
func tryDecryptLogins(masterKey []byte, samples []encryptedLogin) bool {
for _, login := range samples {
userPBE, err := crypto.NewASN1PBE(login.username)
if err != nil {
continue
}
if _, err := userPBE.Decrypt(masterKey); err != nil {
continue
}
pwdPBE, err := crypto.NewASN1PBE(login.password)
if err != nil {
continue
}
if _, err := pwdPBE.Decrypt(masterKey); err == nil {
return true
}
}
return false
}